From d3d3d54b2827910939682be658e39a07e8721704 Mon Sep 17 00:00:00 2001 From: ElMagAmine <36851541+ElMagAmine@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:42:35 +0200 Subject: [PATCH 1/3] rofl: Replace expired machines still returned by the market --- cmd/rofl/deploy.go | 51 ++++++++++++++++++++------- cmd/rofl/deploy_test.go | 76 +++++++++++++++++++++++++++++++++++++++++ docs/rofl.md | 4 ++- 3 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 cmd/rofl/deploy_test.go diff --git a/cmd/rofl/deploy.go b/cmd/rofl/deploy.go index 6c188e70..5888e544 100644 --- a/cmd/rofl/deploy.go +++ b/cmd/rofl/deploy.go @@ -291,19 +291,15 @@ var ( var insDsc *roflmarket.Instance insDsc, err = conn.Runtime(npa.ParaTime).ROFLMarket.Instance(ctx, client.RoundLatest, *providerAddr, machineID) - // The "instance not found" error originates from Rust code, - // so we can't compare it nicely here. - if err != nil && strings.Contains(err.Error(), "instance not found") { - if deployReplaceMachine { - fmt.Printf("Machine instance not found. Obtaining new one...") - machine.ID = "" - _, insDsc, err = obtainMachine() - return insDsc, err - } - - cobra.CheckErr("Machine instance not found.\nTip: If your instance expired, run this command with --replace-machine flag to replace it with a new machine.") + insDsc, replaced, err := replaceUnavailableMachine(insDsc, err, time.Now(), deployReplaceMachine, func() (*roflmarket.Instance, error) { + fmt.Printf("Machine instance expired or not found. Obtaining new one...\n") + machine.ID = "" + _, replacement, obtainErr := obtainMachine() + return replacement, obtainErr + }) + if err != nil || replaced { + return insDsc, err } - cobra.CheckErr(err) if insDsc.Deployment != nil && insDsc.Deployment.AppID != machineDeployment.AppID && !deployForce { fmt.Printf("Machine already contains a deployment of ROFL app '%s'.\n", insDsc.Deployment.AppID) @@ -361,6 +357,37 @@ var ( } ) +// replaceUnavailableMachine checks the paid period independently of the instance status. +// A provider may not have removed an expired instance yet. +func replaceUnavailableMachine( + instance *roflmarket.Instance, + queryErr error, + now time.Time, + replace bool, + obtain func() (*roflmarket.Instance, error), +) (*roflmarket.Instance, bool, error) { + var reason string + switch { + case queryErr != nil: + // The "instance not found" error originates from Rust code, + // so we can't compare it nicely here. + if !strings.Contains(queryErr.Error(), "instance not found") { + return nil, false, queryErr + } + reason = "not found" + case !now.Before(time.Unix(int64(instance.PaidUntil), 0)): + reason = "expired" + default: + return instance, false, nil + } + + if !replace { + return nil, false, fmt.Errorf("machine instance %s; use --replace-machine to rent a new machine", reason) + } + instance, err := obtain() + return instance, true, err +} + // ociRepository returns the OCI repository for the deployment (or a default one if not set). func ociRepository(deployment *buildRofl.Deployment) string { if deployment.OCIRepository == "" { diff --git a/cmd/rofl/deploy_test.go b/cmd/rofl/deploy_test.go new file mode 100644 index 00000000..ca8f8276 --- /dev/null +++ b/cmd/rofl/deploy_test.go @@ -0,0 +1,76 @@ +package rofl + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/roflmarket" +) + +func TestReplaceUnavailableMachine(t *testing.T) { + queryFailure := errors.New("query failed") + rentFailure := errors.New("rental failed") + missing := errors.New("roflmarket: instance not found") + for _, tc := range []struct { + name string + now time.Time + queryErr error + replace bool + rentErr error + wantRent bool + wantErr error + wantErrMsg string + }{ + {name: "unexpired", now: time.Unix(99, 999999999), replace: true}, + {name: "unexpired without replacement", now: time.Unix(99, 0)}, + {name: "expiration boundary", now: time.Unix(100, 0), replace: true, wantRent: true}, + {name: "expired accepted", now: time.Unix(101, 0), replace: true, wantRent: true}, + {name: "expired without replacement", now: time.Unix(101, 0), wantErrMsg: "machine instance expired; use --replace-machine"}, + {name: "missing", queryErr: missing, replace: true, wantRent: true}, + {name: "missing without replacement", queryErr: missing, wantErrMsg: "machine instance not found; use --replace-machine"}, + {name: "query failure", queryErr: queryFailure, replace: true, wantErr: queryFailure}, + {name: "expired rental failure", now: time.Unix(100, 0), replace: true, rentErr: rentFailure, wantRent: true, wantErr: rentFailure}, + {name: "missing rental failure", queryErr: missing, replace: true, rentErr: rentFailure, wantRent: true, wantErr: rentFailure}, + } { + t.Run(tc.name, func(t *testing.T) { + original := &roflmarket.Instance{PaidUntil: 100, Status: roflmarket.InstanceStatusAccepted} + instance := original + if tc.queryErr != nil { + instance = nil + } + replacement := &roflmarket.Instance{PaidUntil: 200} + rentals := 0 + got, replaced, err := replaceUnavailableMachine(instance, tc.queryErr, tc.now, tc.replace, func() (*roflmarket.Instance, error) { + rentals++ + if tc.rentErr != nil { + return nil, tc.rentErr + } + return replacement, nil + }) + require.Equal(t, tc.wantRent, replaced) + if tc.wantRent { + require.Equal(t, 1, rentals) + } else { + require.Zero(t, rentals) + } + switch { + case tc.wantErr != nil: + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, got) + case tc.wantErrMsg != "": + require.ErrorContains(t, err, tc.wantErrMsg) + require.Nil(t, got) + default: + require.NoError(t, err) + if tc.wantRent { + require.Same(t, replacement, got) + } else { + require.Same(t, original, got) + } + } + }) + } +} diff --git a/docs/rofl.md b/docs/rofl.md index ab399693..e9955d3d 100644 --- a/docs/rofl.md +++ b/docs/rofl.md @@ -275,7 +275,9 @@ offer: available provider term by default. - `--term-count ` specifies the multiplier. Default is `1`. - `--replace-machine` if the existing machine in the manifest file expired, - rent a new machine and replace the existing machine ID. + rent a new machine and replace the existing machine ID. This also applies + when an expired machine is still listed as accepted. Without this flag, + deploying to an expired machine fails. An unexpired machine is reused. ![code shell](../examples/rofl/deploy.in.static) From 1a3a5b5db7b0a470a5cf831835ee90bdcb4d68d7 Mon Sep 17 00:00:00 2001 From: ElMagAmine <36851541+ElMagAmine@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:01:31 +0200 Subject: [PATCH 2/3] rofl: Replace expired machines still returned by the market --- cmd/rofl/deploy.go | 3 ++- cmd/rofl/deploy_test.go | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/rofl/deploy.go b/cmd/rofl/deploy.go index 5888e544..c4dd4a06 100644 --- a/cmd/rofl/deploy.go +++ b/cmd/rofl/deploy.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "maps" + "math" "os" "sort" "strings" @@ -375,7 +376,7 @@ func replaceUnavailableMachine( return nil, false, queryErr } reason = "not found" - case !now.Before(time.Unix(int64(instance.PaidUntil), 0)): + case instance.PaidUntil <= math.MaxInt64 && !now.Before(time.Unix(int64(instance.PaidUntil), 0)): reason = "expired" default: return instance, false, nil diff --git a/cmd/rofl/deploy_test.go b/cmd/rofl/deploy_test.go index ca8f8276..35e58d37 100644 --- a/cmd/rofl/deploy_test.go +++ b/cmd/rofl/deploy_test.go @@ -2,6 +2,7 @@ package rofl import ( "errors" + "math" "testing" "time" @@ -16,6 +17,7 @@ func TestReplaceUnavailableMachine(t *testing.T) { missing := errors.New("roflmarket: instance not found") for _, tc := range []struct { name string + paidUntil uint64 now time.Time queryErr error replace bool @@ -26,6 +28,8 @@ func TestReplaceUnavailableMachine(t *testing.T) { }{ {name: "unexpired", now: time.Unix(99, 999999999), replace: true}, {name: "unexpired without replacement", now: time.Unix(99, 0)}, + {name: "beyond signed timestamp range", paidUntil: math.MaxInt64 + 1, now: time.Unix(101, 0), replace: true}, + {name: "maximum timestamp", paidUntil: math.MaxUint64, now: time.Unix(101, 0)}, {name: "expiration boundary", now: time.Unix(100, 0), replace: true, wantRent: true}, {name: "expired accepted", now: time.Unix(101, 0), replace: true, wantRent: true}, {name: "expired without replacement", now: time.Unix(101, 0), wantErrMsg: "machine instance expired; use --replace-machine"}, @@ -37,6 +41,9 @@ func TestReplaceUnavailableMachine(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { original := &roflmarket.Instance{PaidUntil: 100, Status: roflmarket.InstanceStatusAccepted} + if tc.paidUntil != 0 { + original.PaidUntil = tc.paidUntil + } instance := original if tc.queryErr != nil { instance = nil From b33aac2f378004b2ae90ccaf4fbde85912088dbc Mon Sep 17 00:00:00 2001 From: ElMagAmine <36851541+ElMagAmine@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:05:16 +0200 Subject: [PATCH 3/3] rofl: Replace expired machines still returned by the market --- cmd/rofl/deploy.go | 5 +++-- cmd/rofl/deploy_test.go | 35 +++++++++++++++++++++-------------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/cmd/rofl/deploy.go b/cmd/rofl/deploy.go index c4dd4a06..44b1fa79 100644 --- a/cmd/rofl/deploy.go +++ b/cmd/rofl/deploy.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "maps" - "math" "os" "sort" "strings" @@ -367,6 +366,8 @@ func replaceUnavailableMachine( replace bool, obtain func() (*roflmarket.Instance, error), ) (*roflmarket.Instance, bool, error) { + // Compare Unix seconds directly to avoid overflowing time.Time for large paid periods. + nowUnix := now.Unix() var reason string switch { case queryErr != nil: @@ -376,7 +377,7 @@ func replaceUnavailableMachine( return nil, false, queryErr } reason = "not found" - case instance.PaidUntil <= math.MaxInt64 && !now.Before(time.Unix(int64(instance.PaidUntil), 0)): + case nowUnix >= 0 && uint64(nowUnix) >= instance.PaidUntil: reason = "expired" default: return instance, false, nil diff --git a/cmd/rofl/deploy_test.go b/cmd/rofl/deploy_test.go index 35e58d37..d6f63d4e 100644 --- a/cmd/rofl/deploy_test.go +++ b/cmd/rofl/deploy_test.go @@ -15,6 +15,8 @@ func TestReplaceUnavailableMachine(t *testing.T) { queryFailure := errors.New("query failed") rentFailure := errors.New("rental failed") missing := errors.New("roflmarket: instance not found") + // time.Time stores seconds relative to year 1 instead of the Unix epoch. + const unixToInternalSeconds = 62135596800 for _, tc := range []struct { name string paidUntil uint64 @@ -26,24 +28,29 @@ func TestReplaceUnavailableMachine(t *testing.T) { wantErr error wantErrMsg string }{ - {name: "unexpired", now: time.Unix(99, 999999999), replace: true}, - {name: "unexpired without replacement", now: time.Unix(99, 0)}, + {name: "unexpired", paidUntil: 100, now: time.Unix(99, 999999999), replace: true}, + {name: "unexpired without replacement", paidUntil: 100, now: time.Unix(99, 0)}, + {name: "before internal time overflow", paidUntil: math.MaxInt64 - unixToInternalSeconds - 1, now: time.Unix(101, 0), replace: true}, + {name: "internal time overflow boundary", paidUntil: math.MaxInt64 - unixToInternalSeconds, now: time.Unix(101, 0), replace: true}, + {name: "after internal time overflow", paidUntil: math.MaxInt64 - unixToInternalSeconds + 1, now: time.Unix(101, 0), replace: true}, + {name: "maximum signed timestamp", paidUntil: math.MaxInt64, now: time.Unix(101, 0), replace: true}, + {name: "before epoch", paidUntil: 100, now: time.Unix(-1, 999999999), replace: true}, + {name: "zero timestamp before epoch", paidUntil: 0, now: time.Unix(-1, 999999999), replace: true}, + {name: "zero timestamp at epoch", paidUntil: 0, now: time.Unix(0, 0), replace: true, wantRent: true}, + {name: "zero timestamp after epoch", paidUntil: 0, now: time.Unix(0, 1), wantErrMsg: "machine instance expired; use --replace-machine"}, {name: "beyond signed timestamp range", paidUntil: math.MaxInt64 + 1, now: time.Unix(101, 0), replace: true}, {name: "maximum timestamp", paidUntil: math.MaxUint64, now: time.Unix(101, 0)}, - {name: "expiration boundary", now: time.Unix(100, 0), replace: true, wantRent: true}, - {name: "expired accepted", now: time.Unix(101, 0), replace: true, wantRent: true}, - {name: "expired without replacement", now: time.Unix(101, 0), wantErrMsg: "machine instance expired; use --replace-machine"}, - {name: "missing", queryErr: missing, replace: true, wantRent: true}, - {name: "missing without replacement", queryErr: missing, wantErrMsg: "machine instance not found; use --replace-machine"}, - {name: "query failure", queryErr: queryFailure, replace: true, wantErr: queryFailure}, - {name: "expired rental failure", now: time.Unix(100, 0), replace: true, rentErr: rentFailure, wantRent: true, wantErr: rentFailure}, - {name: "missing rental failure", queryErr: missing, replace: true, rentErr: rentFailure, wantRent: true, wantErr: rentFailure}, + {name: "expiration boundary", paidUntil: 100, now: time.Unix(100, 0), replace: true, wantRent: true}, + {name: "expired accepted", paidUntil: 100, now: time.Unix(101, 0), replace: true, wantRent: true}, + {name: "expired without replacement", paidUntil: 100, now: time.Unix(101, 0), wantErrMsg: "machine instance expired; use --replace-machine"}, + {name: "missing", paidUntil: 100, queryErr: missing, replace: true, wantRent: true}, + {name: "missing without replacement", paidUntil: 100, queryErr: missing, wantErrMsg: "machine instance not found; use --replace-machine"}, + {name: "query failure", paidUntil: 100, queryErr: queryFailure, replace: true, wantErr: queryFailure}, + {name: "expired rental failure", paidUntil: 100, now: time.Unix(100, 0), replace: true, rentErr: rentFailure, wantRent: true, wantErr: rentFailure}, + {name: "missing rental failure", paidUntil: 100, queryErr: missing, replace: true, rentErr: rentFailure, wantRent: true, wantErr: rentFailure}, } { t.Run(tc.name, func(t *testing.T) { - original := &roflmarket.Instance{PaidUntil: 100, Status: roflmarket.InstanceStatusAccepted} - if tc.paidUntil != 0 { - original.PaidUntil = tc.paidUntil - } + original := &roflmarket.Instance{PaidUntil: tc.paidUntil, Status: roflmarket.InstanceStatusAccepted} instance := original if tc.queryErr != nil { instance = nil