carlos move resolve --declare-lost: claim Plan 10's inspect then declare, confirmed by the host name (Plan 6 Task 1b)
- declareHostLoss (hostclaim_cli.go) is the declare-lost half that
carlos ops host resolve and carlos move resolve share: inspect, the
door's own confirmation, the operator's EC2 reader in the challenge's
region (awsinv.NewEC2Reader, through rootaws), ObserveForChallenge, and
the declaration of the unchanged challenge confirmed by its ID with the
standing attestation. carlos ops host resolve behaves as before.
- carlos move resolve --host <h> --declare-lost <box>: POSTs
{"declare_lost":box} to the move door, says M-C18 (flip time from
detail=1) for a move-target-lost inspection or MV-N372 for
destroyed-local-only, reads the host name from stdin (a wrong name
refuses, MV-N373), refuses without AWS credentials (MV-N374) or unless
AWS shows the box stopped or terminated (MV-N375), then POSTs the
submission and waits as any mutation. Console text only from roundTrip.
- The resolve door of a console without the host claim answers MV-N11 to
a declare-lost and writes nothing (preflight L7).
- Copy: M-C18 and MV-N11 approved verbatim; MV-N372 to MV-N378 pending;
MV-N358 reworded (pending). docs/site/cli.md: the declare-lost line and
its paragraph.
- B8 census: moveMain's digest (the declare-lost dispatch).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+545
−41
approved_moves_copy_test.go+1 −0docs/site/cli.md+7 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+11 −4hostclaim_cli.go+91 −21hostclaim_cli_test.go+71 −1internal/console/apiinstancemove.go+9 −1internal/console/apiinstancemove_test.go+19 −0internal/provision/plan9_b8_census_test.go+1 −1move.go+132 −7move_test.go+203 −6
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 0f8e275ac..e69fcbcb9 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -79,6 +79,7 @@ var movesCopyIDs = []string{ |
| "MV-N350", "MV-N351", "MV-N352", "MV-N353", "MV-N354", "MV-N355", "MV-N356", |
| "MV-N357", "MV-N358", "MV-N359", "MV-N360", "MV-N361", "MV-N362", "MV-N363", "MV-N364", |
| "MV-N365", "MV-N366", "MV-N367", "MV-N368", "MV-N369", "MV-N370", "MV-N371", |
| + "MV-N372", "MV-N373", "MV-N374", "MV-N375", "MV-N376", "MV-N377", "MV-N378", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index a1bd6a429..89ba60caf 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -2063,6 +2063,7 @@ carlos move status --host <host> [--wait] |
| carlos move cut --host <host> |
| carlos move abort --host <host> |
| carlos move resolve --host <host> --retry |
| +carlos move resolve --host <host> --declare-lost <box> |
| ``` |
| `carlos move`, `cut`, `abort` and `resolve` wait until the move ends unless |
| @@ -2072,6 +2073,12 @@ stops once the move is ready, and `carlos move cut` starts the cut. |
| `--app` and `--account` name the instance's app and account, as for |
| `carlos instances`. |
| +`--declare-lost <box>` declares the move's target box lost, for a box that |
| +will not come back. It needs the console's host claim. It reads the box |
| +through your own AWS credentials and refuses unless AWS shows it stopped or |
| +terminated. It says what the declaration loses, and you type the host name |
| +to confirm. |
| + |
| | exit code | meaning | |
| |---|---| |
| | `0` | the move finished, the console accepted a command given `--no-wait`, or `status` found the move still under way | |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index fb8dd90f2..56f1eb305 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -30,7 +30,7 @@ Columns: id, file, text, status. |
| | M-C15 | internal/host/movesphase1.go | <host> keeps more than 256 MiB of files beside its database. Keep large files in object storage. | approved | |
| | M-C16 | internal/host/movesphase1.go | <host> does not answer its readiness path. Declare moves.ready_path for this app. | approved | |
| | M-C17 | internal/console/apiinstancemove.go | <host> uses private storage. Instances with private storage cannot move yet. | approved | |
| -| M-C18 | move.go | This declares <box> lost. Anything <host> wrote on <box> after <time> is lost. Type the host name to confirm. | pending | |
| +| M-C18 | move.go | This declares <box> lost. Anything <host> wrote on <box> after <time> is lost. Type the host name to confirm. | approved | |
| | M-C19 | internal/console/apiinstancemove.go | <host> is being moved. Delete it after the move finishes, or abort the move first. | approved | |
| | M-C20 | internal/host/movespaging.go | <box> is running a process for <host> that this move cannot account for. An operator has been paged. | pending | |
| | M-C21 | internal/console/apiinstancemove.go | <region> has more than one box. Moves between regions with several boxes are not supported yet. | approved | |
| @@ -50,7 +50,7 @@ Columns: id, file, text, status. |
| | MV-N8 | internal/console/apiinstancemove.go | the fleet's edges files could not all be read. try again | approved | |
| | MV-N9 | internal/host/movesreport.go | the staging lane holds another move's move.json | approved | |
| | MV-N10 | internal/host/systemapply.go | moves protocol floor: <version> is below <min_release> | approved | |
| -| MV-N11 | internal/console/apiinstancemove.go | declare-lost is not enabled in this deployment yet | pending | |
| +| MV-N11 | internal/console/apiinstancemove.go | declare-lost is not enabled in this deployment yet | approved | |
| | MV-N12 | internal/provision/provision.go | no journaled move in flight | approved | |
| | MV-N13 | internal/provision/provision.go | the record's move is <id> for incarnation <incarnation> | approved | |
| | MV-N14 | internal/provision/provision.go | malformed move start | approved | |
| @@ -284,7 +284,7 @@ Columns: id, file, text, status. |
| ## Plan 6 |
| -(completed by Plan 6 Task 7. Task 2 takes MV-N319 to MV-N324, and made the abort door speak M-C13 on a console without the host claim, so M-C13 is now approved verbatim. Task 2b made the delete door speak M-C19 through moveDeleteRefusal in internal/console/apiinstancemove.go, so its File cell moves there (preflight L6) and it is approved verbatim. Task 1a takes MV-N350 to MV-N371 (Plan 5 owns MV-N325 to MV-N349) for `carlos move` (move.go, and its usage line in main.go), and speaks M-C1 to M-C12 and M-C22 verbatim from move.go, so those rows are approved) |
| +(completed by Plan 6 Task 7. Task 2 takes MV-N319 to MV-N324, and made the abort door speak M-C13 on a console without the host claim, so M-C13 is now approved verbatim. Task 2b made the delete door speak M-C19 through moveDeleteRefusal in internal/console/apiinstancemove.go, so its File cell moves there (preflight L6) and it is approved verbatim. Task 1a takes MV-N350 to MV-N371 and Task 1b MV-N372 to MV-N378 (Plan 5 owns MV-N325 to MV-N349) for `carlos move` (move.go, and its usage line in main.go), and speaks M-C1 to M-C12 and M-C22 verbatim from move.go, so those rows are approved. Task 1b speaks M-C18 verbatim from move.go and MV-N11 from the resolve door of a console without the host claim, so both are approved) |
| | id | file | text | status | |
| |---|---|---|---| |
| @@ -297,7 +297,7 @@ Columns: id, file, text, status. |
| | MV-N355 | move.go | The console is limiting requests. Checking every 10 seconds. | pending | |
| | MV-N356 | move.go | carlos move needs --host <host>. | pending | |
| | MV-N357 | move.go | carlos move needs --to <region>. | pending | |
| -| MV-N358 | move.go | carlos move resolve needs --retry. | pending | |
| +| MV-N358 | move.go | carlos move resolve needs --retry or --declare-lost <box>. | pending | |
| | MV-N359 | move.go | No move is in flight for <host>. | pending | |
| | MV-N360 | move.go | Move <id> of <host> is <state>. | pending | |
| | MV-N361 | move.go | The move was aborted. <host> stays in <from>. | pending | |
| @@ -311,6 +311,13 @@ Columns: id, file, text, status. |
| | MV-N369 | move.go | carlos move could not sign in to a console. Run carlos auth login, or check --console, CARLOS_CONSOLE and CARLOS_SERVICE_TOKEN. | pending | |
| | MV-N370 | move.go | This console has moved to <url>. Run carlos auth login --console <url>, then run the command again. | pending | |
| | MV-N371 | move.go | Stopped waiting. | pending | |
| +| MV-N372 | move.go | This records that <box> is gone with its disks. <host> keeps nothing there that it still needs. Type the host name to confirm. | pending | |
| +| MV-N373 | move.go | That is not <host>. Nothing was declared. | pending | |
| +| MV-N374 | move.go | declare-lost needs your AWS credentials to confirm the box is stopped or terminated. | pending | |
| +| MV-N375 | move.go | AWS does not show <box> stopped or terminated, so it cannot be declared lost. | pending | |
| +| MV-N376 | move.go | The move's flip time could not be read, so its target cannot be declared lost from here. | pending | |
| +| MV-N377 | move.go | The console asks to confirm a loss carlos move does not know. Use carlos ops host resolve. | pending | |
| +| MV-N378 | move.go | carlos move resolve takes --retry or --declare-lost, not both. | pending | |
| ## Review of 2026-10-09 |
diff --git a/hostclaim_cli.go b/hostclaim_cli.go| index 0cf438948..be78374e1 100644 |
| --- a/hostclaim_cli.go |
| +++ b/hostclaim_cli.go |
| @@ -137,30 +137,37 @@ func (s *apiSession) resolveHost(ctx context.Context, o hostResolveOptions) erro |
| request["action"] = "readmit" |
| status, body, err = s.postJSON(ctx, base, request) |
| } else { |
| - var inspection hostresolve.Inspection |
| - if err = json.Unmarshal(body, &inspection); err != nil { |
| - return err |
| - } |
| - c := inspection.Details |
| - // TLS-authenticated console response; the console verifies its unchanged |
| - // signed envelope on submission. The client does not call this AWS proof. |
| - fmt.Fprintln(os.Stdout, string(body)) |
| - fmt.Fprintln(os.Stdout, "Confirm that compute remains fenced, any restart will be cold through quarantine, and the listed storage was destroyed if asserted. Type the declaration ID:") |
| - line, _ := bufio.NewReader(os.Stdin).ReadString('\n') |
| - if strings.TrimSpace(line) != c.ID { |
| - return errors.New("fencing attestation not confirmed") |
| + // The declare-lost half is shared with carlos move resolve |
| + // --declare-lost (declareHostLoss); this door's inspection is the |
| + // body above, its confirmation the declaration ID. |
| + door := hostLossDoor{ |
| + inspect: func(context.Context) (hostresolve.Inspection, error) { |
| + var inspection hostresolve.Inspection |
| + if err := json.Unmarshal(body, &inspection); err != nil { |
| + return inspection, err |
| + } |
| + // TLS-authenticated console response; the console verifies its |
| + // unchanged signed envelope on submission. The client does not |
| + // call this AWS proof. |
| + fmt.Fprintln(os.Stdout, string(body)) |
| + return inspection, nil |
| + }, |
| + confirm: func(inspection hostresolve.Inspection) bool { |
| + fmt.Fprintln(os.Stdout, "Confirm that compute remains fenced, any restart will be cold through quarantine, and the listed storage was destroyed if asserted. Type the declaration ID:") |
| + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') |
| + // --yes does not silently attest facts about compute or disks. |
| + return strings.TrimSpace(line) == inspection.Details.ID |
| + }, |
| + reader: awsinv.NewEC2Reader, |
| + declare: func(ctx context.Context, submission hostfence.Submission) error { |
| + status, body, err = s.postJSON(ctx, base, map[string]any{"action": o.Action, "submission": submission}) |
| + return err |
| + }, |
| + now: time.Now, |
| } |
| - // --yes does not silently attest facts about compute or disks. |
| - api, err := awsinv.NewEC2Reader(ctx, c.Cloud.Region) |
| - if err != nil { |
| + if _, err := declareHostLoss(ctx, door); err != nil { |
| return err |
| } |
| - observation, err := hostfence.ObserveForChallenge(ctx, api, c, time.Now) |
| - if err != nil { |
| - return err |
| - } |
| - submission := hostfence.Submission{Challenge: inspection.Challenge, Observation: observation, RequestID: observation.RequestID, Confirmation: c.ID, Attestation: hostfence.OperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true, Disks: c.Consequences.DestroyedStorageAssertion}} |
| - status, body, err = s.postJSON(ctx, base, map[string]any{"action": o.Action, "submission": submission}) |
| } |
| if err != nil { |
| return err |
| @@ -171,3 +178,66 @@ func (s *apiSession) resolveHost(ctx context.Context, o hostResolveOptions) erro |
| fmt.Fprintln(os.Stdout, string(body)) |
| return nil |
| } |
| + |
| +// hostLossDoor is one door's declare-lost conversation (claim Plan 10, Paul's |
| +// ruling J4: inspect, then declare). The ops host door and the move door |
| +// take different requests at different paths, and each says its own |
| +// sentences, so each brings its own calls; declareHostLoss is the sequence |
| +// they share. |
| +type hostLossDoor struct { |
| + // inspect asks the door for the signed challenge of the loss. |
| + inspect func(ctx context.Context) (hostresolve.Inspection, error) |
| + // confirm shows the operator what the declaration costs and reads the |
| + // confirmation the door asks for. |
| + confirm func(hostresolve.Inspection) bool |
| + // reader is the operator's own read-only EC2 seam in the challenge's |
| + // region (awsinv.NewEC2Reader, which loads through rootaws). |
| + reader func(ctx context.Context, region string) (awsinv.ExactEC2API, error) |
| + // declare submits the declaration. |
| + declare func(ctx context.Context, sub hostfence.Submission) error |
| + now func() time.Time |
| +} |
| + |
| +// hostLossStage names the step at which declareHostLoss stopped. |
| +type hostLossStage int |
| + |
| +const ( |
| + hostLossInspect hostLossStage = iota + 1 |
| + hostLossConfirm |
| + hostLossReader |
| + hostLossObserve |
| + hostLossDeclare |
| +) |
| + |
| +var errHostLossNotConfirmed = errors.New("fencing attestation not confirmed") |
| + |
| +// declareHostLoss is the declare-lost half both doors share: inspect, have |
| +// the operator confirm, observe the box through their own EC2 credentials, |
| +// and declare the inspected challenge unchanged, confirmed by its ID, with |
| +// the standing attestation: compute stays fenced, any restart is cold |
| +// through quarantine, and the challenge's own destroyed-storage assertion. |
| +// It returns the stage that stopped it, 0 when the declaration was sent. |
| +func declareHostLoss(ctx context.Context, d hostLossDoor) (hostLossStage, error) { |
| + inspection, err := d.inspect(ctx) |
| + if err != nil { |
| + return hostLossInspect, err |
| + } |
| + c := inspection.Details |
| + if !d.confirm(inspection) { |
| + return hostLossConfirm, errHostLossNotConfirmed |
| + } |
| + api, err := d.reader(ctx, c.Cloud.Region) |
| + if err != nil { |
| + return hostLossReader, err |
| + } |
| + observation, err := hostfence.ObserveForChallenge(ctx, api, c, d.now) |
| + if err != nil { |
| + return hostLossObserve, err |
| + } |
| + submission := hostfence.Submission{Challenge: inspection.Challenge, Observation: observation, RequestID: observation.RequestID, Confirmation: c.ID, |
| + Attestation: hostfence.OperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true, Disks: c.Consequences.DestroyedStorageAssertion}} |
| + if err := d.declare(ctx, submission); err != nil { |
| + return hostLossDeclare, err |
| + } |
| + return 0, nil |
| +} |
diff --git a/hostclaim_cli_test.go b/hostclaim_cli_test.go| index 0e86834a9..e22ebcf4e 100644 |
| --- a/hostclaim_cli_test.go |
| +++ b/hostclaim_cli_test.go |
| @@ -1,6 +1,16 @@ |
| package main |
| -import "testing" |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/awsinv" |
| + "github.com/carlosframework/platform/internal/hostfence" |
| + "github.com/carlosframework/platform/internal/hostresolve" |
| +) |
| func TestHostClaimFlagUsesTheFleetConvention(t *testing.T) { |
| for _, raw := range []string{"1", "0", "on", " true "} { |
| @@ -30,3 +40,63 @@ func TestParseHostResolveRefusesAmbiguousAction(t *testing.T) { |
| t.Fatal("accepted both actions") |
| } |
| } |
| + |
| +// Task 1b (preflight L1): the declare-lost half both doors share. It inspects, |
| +// asks the door's own confirmation, reads the box through the operator's EC2 |
| +// reader in the challenge's region, and declares the inspected challenge |
| +// unchanged, with the challenge ID as the confirmation and the standing |
| +// attestation. Each stage that stops it is named, so each door can say why. |
| +func TestDeclareHostLossStages(t *testing.T) { |
| + inspection := func() hostresolve.Inspection { |
| + c, err := hostfence.PrepareChallenge(time.Now().Add(-time.Second), hostfence.Challenge{ |
| + ID: "loss-1", Actor: "op@keymail.dev", Host: "app.example.com", HolderIncarnation: "inc-1", |
| + ClaimGen: 9, ClaimETag: "etag", Op: hostfence.Operation{ID: "op-1", Kind: "landing"}, |
| + Record: hostfence.RecordObservation{Key: "k", ETag: "etag-record", CommitState: "present"}, Box: "box-1", Enrollment: "e", |
| + Cloud: hostfence.CloudBinding{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-1"}, |
| + Consequences: hostfence.Consequences{Transition: "move-target-lost", DestroyedStorageAssertion: nil}, |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return hostresolve.Inspection{Challenge: json.RawMessage(`{"signed":true}`), Details: c} |
| + } |
| + ec2For := func(state string) func(context.Context, string) (awsinv.ExactEC2API, error) { |
| + calls := 0 |
| + return func(_ context.Context, region string) (awsinv.ExactEC2API, error) { |
| + if region != "eu-west-1" { |
| + t.Errorf("reader asked for %q", region) |
| + } |
| + return fakeEC2{account: "111122223333", instance: "i-1", state: state, calls: &calls}, nil |
| + } |
| + } |
| + var declared *hostfence.Submission |
| + door := func(confirm bool, state string, readerErr error) hostLossDoor { |
| + declared = nil |
| + d := hostLossDoor{ |
| + inspect: func(context.Context) (hostresolve.Inspection, error) { return inspection(), nil }, |
| + confirm: func(hostresolve.Inspection) bool { return confirm }, |
| + reader: ec2For(state), |
| + declare: func(_ context.Context, s hostfence.Submission) error { declared = &s; return nil }, |
| + now: time.Now, |
| + } |
| + if readerErr != nil { |
| + d.reader = func(context.Context, string) (awsinv.ExactEC2API, error) { return nil, readerErr } |
| + } |
| + return d |
| + } |
| + ctx := context.Background() |
| + if stage, err := declareHostLoss(ctx, door(true, "stopped", nil)); err != nil || stage != 0 || declared == nil || |
| + declared.Confirmation != "loss-1" || string(declared.Challenge) != `{"signed":true}` || !declared.Attestation.MaintainComputeFence || |
| + !declared.Attestation.ColdQuarantineStartOnly || declared.RequestID != "aws-request-1b" { |
| + t.Fatalf("declared = %+v, stage %d, %v", declared, stage, err) |
| + } |
| + if stage, err := declareHostLoss(ctx, door(false, "stopped", nil)); stage != hostLossConfirm || !errors.Is(err, errHostLossNotConfirmed) || declared != nil { |
| + t.Fatalf("unconfirmed: stage %d, %v", stage, err) |
| + } |
| + if stage, err := declareHostLoss(ctx, door(true, "stopped", errors.New("no chain"))); stage != hostLossReader || err == nil || declared != nil { |
| + t.Fatalf("no reader: stage %d, %v", stage, err) |
| + } |
| + if stage, err := declareHostLoss(ctx, door(true, "running", nil)); stage != hostLossObserve || !errors.Is(err, hostfence.ErrObservation) || declared != nil { |
| + t.Fatalf("running: stage %d, %v", stage, err) |
| + } |
| +} |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 106f6f704..f2eecc0d2 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -1001,11 +1001,19 @@ func (s *Server) handleAPIInstanceMoveResolve(w http.ResponseWriter, r *http.Req |
| return |
| } |
| var req struct { |
| - Retry bool `json:"retry"` |
| + Retry bool `json:"retry"` |
| + DeclareLost string `json:"declare_lost"` |
| } |
| if !readJSON(w, r, &req) { |
| return |
| } |
| + // A console without the host claim cannot run the claim's loss protocol |
| + // (claim Plan 10's inspect and declare): MV-N11, nothing written |
| + // (preflight L7). |
| + if req.DeclareLost != "" { |
| + jsonError(w, http.StatusConflict, "declare-lost is not enabled in this deployment yet") |
| + return |
| + } |
| if !req.Retry { |
| jsonError(w, http.StatusConflict, "nothing to resolve yet") |
| return |
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go| index bbf282aa5..968918638 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -2149,3 +2149,22 @@ func TestMoveViewCarriesThePhase(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// Task 1b (preflight L7, MV-N11): a console without the host claim cannot run |
| +// the claim's loss protocol, so the resolve door answers a declare-lost with |
| +// MV-N11 and writes nothing; a retry is unchanged. |
| +func TestResolveDeclareLostWithoutTheClaim(t *testing.T) { |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-dl-off") |
| + readMoveAnswer(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`, http.StatusAccepted) |
| + before, _ := json.Marshal(recordMove(t, fx)) |
| + code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"declare_lost":"uk-box"}`) |
| + if code != http.StatusConflict || errorText(out) != "declare-lost is not enabled in this deployment yet" { |
| + t.Fatalf("declare-lost without the claim = %d %s", code, out) |
| + } |
| + if after, _ := json.Marshal(recordMove(t, fx)); string(after) != string(before) { |
| + t.Fatal("a refused declare-lost wrote") |
| + } |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"retry":true}`); code != http.StatusConflict || errorText(out) != provision.RefusedNotStuck { |
| + t.Fatalf("retry = %d %s", code, out) |
| + } |
| +} |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 232730442..318fa9e46 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -564,7 +564,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "3eb1b1a9b8a7fd9cc2820b5a4348046b4f6bac3496d5ac6c4e2c32722233bd15"}, |
| // Plan 6 Task 1a: `carlos move` builds the console's move door path |
| // (/api/cli/apps/.../instances/<host>/move); it writes no record. |
| - {"move.go", "moveMain", "key-site", "lit:instances", "d6c44f466f50f7454703d410bcefa624d932c2f58d430980ef2f08a426e0a029"}, |
| + {"move.go", "moveMain", "key-site", "lit:instances", "3720fd1090c2f0d81d76157eacbd1a1e338a3bae6508167590339d2f60f2399e"}, |
| {"schedule.go", "renderScheduleList", "key-site", "lit:instances", "af6d666083fbbc753eecc98809d3d307344cc15110362a7e93afab573749ea6e"}, |
| {"internal/activator/layout.go", "LayoutInputs.sockets", "key-site", "lit:instances", "1d11ac5ac5be6a42e676e064771141fe8f4367c78cbb7e5c5cca072f40c27c42"}, |
| {"internal/activator/paths.go", "ExecBackend.pathsFor", "key-site", "lit:instances", "cbc01865e27934cb38baab9329a0867b899149531b4706a533a6956afc7c71b3"}, |
diff --git a/move.go b/move.go| index cb3db48ab..ee47d411a 100644 |
| --- a/move.go |
| +++ b/move.go |
| @@ -1,6 +1,7 @@ |
| package main |
| import ( |
| + "bufio" |
| "bytes" |
| "context" |
| "encoding/json" |
| @@ -15,6 +16,10 @@ import ( |
| "slices" |
| "strings" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/awsinv" |
| + "github.com/carlosframework/platform/internal/hostfence" |
| + "github.com/carlosframework/platform/internal/hostresolve" |
| ) |
| // `carlos move` (spec §12, moves Phase 1 Plan 6 Task 1a): request a move of |
| @@ -54,7 +59,7 @@ const ( |
| moveProtocolMark = "phase1" |
| ) |
| -// The sentences this file adds (copy record MV-N350 to MV-N371, pending). |
| +// The sentences this file adds (copy record MV-N350 to MV-N378, pending). |
| const ( |
| moveNotEnabled = "Moves are not enabled on this console." |
| moveNoApp = "carlos move needs --app: name the app the instance belongs to." |
| @@ -64,12 +69,16 @@ const ( |
| moveLimited = "The console is limiting requests. Checking every 10 seconds." |
| moveNeedsHost = "carlos move needs --host <host>." |
| moveNeedsTo = "carlos move needs --to <region>." |
| - moveNeedsRetry = "carlos move resolve needs --retry." |
| + moveNeedsRetry = "carlos move resolve needs --retry or --declare-lost <box>." |
| moveUnreachable = "Could not reach the console. Check the connection and try again." |
| moveUnreadable = "The console's answer could not be read." |
| moveNotFound = "Not found. Check --account and --app, and that you are a member of the instance's account." |
| moveNoSession = "carlos move could not sign in to a console. Run carlos auth login, or check --console, CARLOS_CONSOLE and CARLOS_SERVICE_TOKEN." |
| moveStopped = "Stopped waiting." |
| + moveRetryOrLost = "carlos move resolve takes --retry or --declare-lost, not both." |
| + moveNoAWS = "declare-lost needs your AWS credentials to confirm the box is stopped or terminated." |
| + moveNoFlipTime = "The move's flip time could not be read, so its target cannot be declared lost from here." |
| + moveOtherLoss = "The console asks to confirm a loss carlos move does not know. Use carlos ops host resolve." |
| ) |
| func moveSay(out io.Writer, line string) { |
| @@ -89,7 +98,7 @@ func saySpoken(out io.Writer, t serverText) { |
| } |
| func runMove(args []string) { |
| - deps := moveDeps{session: resolveMode, euid: os.Geteuid, poll: 2 * time.Second} |
| + deps := moveDeps{session: resolveMode, euid: os.Geteuid, poll: 2 * time.Second, stdin: os.Stdin, ec2: awsinv.NewEC2Reader} |
| os.Exit(moveMain(context.Background(), args, os.Stdout, deps)) |
| } |
| @@ -99,6 +108,10 @@ type moveDeps struct { |
| session func(consoleFlag string) (*apiSession, error) |
| euid func() int |
| poll time.Duration |
| + // stdin is where a declare-lost confirmation is read; ec2 is the |
| + // operator's own read-only EC2 seam (awsinv.NewEC2Reader). |
| + stdin io.Reader |
| + ec2 func(ctx context.Context, region string) (awsinv.ExactEC2API, error) |
| } |
| type moveArgs struct { |
| @@ -108,6 +121,7 @@ type moveArgs struct { |
| console string |
| manualCut, noWait, wait bool |
| retry bool |
| + lostBox string // the box to declare lost (Task 1b) |
| } |
| // parseMoveArgs reads `carlos move [status|cut|abort|resolve] <flags>`. Each |
| @@ -142,6 +156,7 @@ func parseMoveArgs(args []string, out io.Writer) (moveArgs, error) { |
| fs.BoolVar(&a.noWait, "no-wait", false, "return once the console has accepted it") |
| case "resolve": |
| fs.BoolVar(&a.retry, "retry", false, "retry a stuck move once its cause is fixed") |
| + fs.StringVar(&a.lostBox, "declare-lost", "", "declare the move's target box lost, once AWS shows it stopped or terminated") |
| fs.BoolVar(&a.noWait, "no-wait", false, "return once the console has accepted it") |
| } |
| if err := fs.Parse(args); err != nil { |
| @@ -159,9 +174,12 @@ func parseMoveArgs(args []string, out io.Writer) (moveArgs, error) { |
| case a.verb == "request" && a.to == "": |
| moveSay(out, moveNeedsTo) |
| return moveArgs{}, errors.New("no --to") |
| - case a.verb == "resolve" && !a.retry: |
| + case a.verb == "resolve" && a.retry && a.lostBox != "": |
| + moveSay(out, moveRetryOrLost) |
| + return moveArgs{}, errors.New("--retry and --declare-lost") |
| + case a.verb == "resolve" && !a.retry && a.lostBox == "": |
| moveSay(out, moveNeedsRetry) |
| - return moveArgs{}, errors.New("no --retry") |
| + return moveArgs{}, errors.New("no --retry or --declare-lost") |
| } |
| if a.verb != "status" { |
| a.wait = !a.noWait |
| @@ -235,7 +253,10 @@ func moveMain(ctx context.Context, args []string, out io.Writer, d moveDeps) int |
| return m.mutate(ctx, http.MethodPost, "/cut", struct{}{}, a.wait, false) |
| case "abort": |
| return m.mutate(ctx, http.MethodDelete, "", nil, a.wait, false) |
| - default: // resolve --retry |
| + default: // resolve |
| + if a.lostBox != "" { |
| + return m.declareLost(ctx, a.lostBox, d, a.wait) |
| + } |
| return m.mutate(ctx, http.MethodPost, "/resolve", map[string]bool{"retry": true}, a.wait, false) |
| } |
| } |
| @@ -288,6 +309,7 @@ type moveJournal struct { |
| State string `json:"state"` |
| CutPhase string `json:"cut_phase"` |
| CutBudgetS int `json:"cut_budget_s"` |
| + FlippedAt *time.Time `json:"flipped_at"` |
| Reason string `json:"reason"` |
| WireStuck string `json:"stuck_reason"` // read only by roundTrip |
| Rollback *moveRollback `json:"rollback"` |
| @@ -338,6 +360,7 @@ const moveTransient = 5 |
| type moveReply struct { |
| status int |
| answer moveAnswer // decoded, on a 2xx |
| + body []byte // the 2xx body as read (a declare-lost inspection) |
| refusal serverText // the console's own sentence, on another status |
| refused bool // refusal holds one |
| moved string // the console's new origin, on a console-shaped redirect |
| @@ -385,6 +408,7 @@ func (m *mover) roundTrip(ctx context.Context, method, path string, in any) move |
| return r |
| } |
| if res.StatusCode/100 == 2 { |
| + r.body = raw |
| if json.Unmarshal(raw, &r.answer) != nil { |
| r.failed, r.unread = true, true |
| return r |
| @@ -524,7 +548,12 @@ func (m *mover) send(ctx context.Context, method, tail string, body any) moveRep |
| // (fix round 2, ruling 2). Else the move's detail, read once now, binds its |
| // regions, a request says M-C1, and the wait follows the move's id. |
| func (m *mover) mutate(ctx context.Context, method, tail string, body any, wait, request bool) int { |
| - r := m.send(ctx, method, tail, body) |
| + return m.accepted(ctx, m.send(ctx, method, tail, body), wait, request) |
| +} |
| + |
| +// accepted ends a mutation from the console's reply: a refusal or a failure |
| +// is said and ends it; an acceptance is said (--no-wait) or waited on. |
| +func (m *mover) accepted(ctx context.Context, r moveReply, wait, request bool) int { |
| switch { |
| case r.failed || r.moved != "": |
| m.sayReply(r) |
| @@ -802,3 +831,99 @@ func moveEstimate(seconds int) string { |
| } |
| return fmt.Sprintf("%d minutes", (seconds+59)/60) |
| } |
| + |
| +// declareLost is carlos move resolve --declare-lost <box> (Task 1b): claim |
| +// Plan 10's move door, inspect then declare, through declareHostLoss, the |
| +// half carlos ops host resolve shares. The operator types the host name to |
| +// confirm (Paul's ruling of 2026-10-10); the declaration carries the |
| +// challenge's own ID. M-C18 is said for a move-target-lost inspection, with |
| +// the move's flip time from detail=1; a destroyed-local-only one has its own |
| +// sentence. The box is read through the operator's own EC2 credentials, |
| +// never the console's. |
| +func (m *mover) declareLost(ctx context.Context, box string, d moveDeps, wait bool) int { |
| + a, code := m.readDetail(ctx) |
| + if code >= 0 { |
| + return code |
| + } |
| + var flipped *time.Time |
| + if a.Detail.Move != nil { |
| + flipped = a.Detail.Move.FlippedAt |
| + } |
| + refused := -1 // the exit code a refusal already said, if any |
| + stdin := bufio.NewReader(d.stdin) |
| + door := hostLossDoor{ |
| + inspect: func(ctx context.Context) (hostresolve.Inspection, error) { |
| + var in hostresolve.Inspection |
| + r := m.send(ctx, http.MethodPost, "/resolve", map[string]string{"declare_lost": box}) |
| + if code := m.refusal(r); code >= 0 { |
| + refused = code |
| + return in, errHostLossNotConfirmed |
| + } |
| + if err := json.Unmarshal(r.body, &in); err != nil { |
| + moveSay(m.out, moveUnreadable) |
| + refused = moveExitError |
| + return in, err |
| + } |
| + return in, nil |
| + }, |
| + confirm: func(in hostresolve.Inspection) bool { |
| + switch in.Details.Consequences.Transition { |
| + case "move-target-lost": |
| + if flipped == nil { |
| + moveSay(m.out, moveNoFlipTime) |
| + return false |
| + } |
| + moveSay(m.out, fmt.Sprintf("This declares %s lost. Anything %s wrote on %s after %s is lost. Type the host name to confirm.", box, m.host, box, flipped.UTC().Format("2006-01-02 15:04:05 UTC"))) |
| + case "destroyed-local-only": |
| + moveSay(m.out, fmt.Sprintf("This records that %s is gone with its disks. %s keeps nothing there that it still needs. Type the host name to confirm.", box, m.host)) |
| + default: |
| + moveSay(m.out, moveOtherLoss) |
| + return false |
| + } |
| + line, _ := stdin.ReadString('\n') |
| + if strings.TrimSpace(line) != m.host { |
| + moveSay(m.out, fmt.Sprintf("That is not %s. Nothing was declared.", m.host)) |
| + return false |
| + } |
| + return true |
| + }, |
| + reader: d.ec2, |
| + now: time.Now, |
| + } |
| + var declared moveReply |
| + door.declare = func(ctx context.Context, sub hostfence.Submission) error { |
| + declared = m.send(ctx, http.MethodPost, "/resolve", map[string]any{"declare_lost": box, "submission": sub}) |
| + return nil |
| + } |
| + stage, _ := declareHostLoss(ctx, door) |
| + switch { |
| + case refused >= 0: |
| + return refused |
| + case stage == hostLossConfirm: |
| + return moveExitRefused |
| + case stage == hostLossReader: |
| + moveSay(m.out, moveNoAWS) |
| + return moveExitRefused |
| + case stage == hostLossObserve: |
| + moveSay(m.out, fmt.Sprintf("AWS does not show %s stopped or terminated, so it cannot be declared lost.", box)) |
| + return moveExitRefused |
| + } |
| + return m.accepted(ctx, declared, wait, false) |
| +} |
| + |
| +// refusal says a reply that is not an acceptance and returns its exit code, |
| +// or -1 for a 2xx. |
| +func (m *mover) refusal(r moveReply) int { |
| + switch { |
| + case r.failed || r.moved != "": |
| + m.sayReply(r) |
| + return moveExitError |
| + case r.status >= 400 && r.status < 500: |
| + m.sayReply(r) |
| + return moveExitRefused |
| + case r.status < 200 || r.status > 299: |
| + m.sayReply(r) |
| + return moveExitError |
| + } |
| + return -1 |
| +} |
diff --git a/move_test.go b/move_test.go| index 269bae9ab..fdbf09daf 100644 |
| --- a/move_test.go |
| +++ b/move_test.go |
| @@ -6,6 +6,15 @@ import ( |
| "encoding/json" |
| "errors" |
| "flag" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" |
| + "github.com/aws/aws-sdk-go-v2/service/ec2" |
| + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| + "github.com/carlosframework/platform/internal/awsinv" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfence" |
| + "github.com/carlosframework/platform/internal/hostresolve" |
| "io" |
| "net/http" |
| "net/http/httptest" |
| @@ -128,6 +137,13 @@ func (c *fakeMoveConsole) mutations() []string { |
| // runMoveAgainst runs the verb against the fake, as an operator who is not |
| // root, polling every millisecond. It returns the exit code and the output. |
| func runMoveAgainst(t *testing.T, c *fakeMoveConsole, args ...string) (int, string) { |
| + t.Helper() |
| + return runMoveWith(t, c, nil, args...) |
| +} |
| + |
| +// runMoveWith is runMoveAgainst with deps adjusted first (stdin, the EC2 |
| +// reader). |
| +func runMoveWith(t *testing.T, c *fakeMoveConsole, adjust func(*moveDeps), args ...string) (int, string) { |
| t.Helper() |
| ts := httptest.NewServer(c) |
| t.Cleanup(ts.Close) |
| @@ -136,8 +152,16 @@ func runMoveAgainst(t *testing.T, c *fakeMoveConsole, args ...string) (int, stri |
| session: func(string) (*apiSession, error) { |
| return &apiSession{c: newConsoleClient(ts.URL, "cli-token"), console: ts.URL, address: "op@keymail.dev"}, nil |
| }, |
| - euid: func() int { return 1000 }, |
| - poll: time.Millisecond, |
| + euid: func() int { return 1000 }, |
| + poll: time.Millisecond, |
| + stdin: strings.NewReader(""), |
| + ec2: func(context.Context, string) (awsinv.ExactEC2API, error) { |
| + t.Error("the EC2 reader was asked for") |
| + return nil, errors.New("no reader in this test") |
| + }, |
| + } |
| + if adjust != nil { |
| + adjust(&deps) |
| } |
| args = append(args, "--account", "bab", "--app", "titogo") |
| ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) |
| @@ -549,7 +573,8 @@ func TestParseMoveArgs(t *testing.T) { |
| {[]string{"--host", tHost}, false, ""}, |
| {[]string{"resolve", "--host", tHost}, false, ""}, |
| {[]string{"cut", "--host", tHost, "--to", "uk"}, false, ""}, |
| - {[]string{"resolve", "--host", tHost, "--declare-lost", "uk-box"}, false, ""}, |
| + {[]string{"resolve", "--host", tHost, "--declare-lost", "uk-box"}, true, "resolve"}, |
| + {[]string{"resolve", "--host", tHost, "--declare-lost", "uk-box", "--retry"}, false, ""}, |
| } { |
| got, err := parseMoveArgs(c.args, io.Discard) |
| if (err == nil) != c.ok || (c.ok && got.verb != c.verb) { |
| @@ -765,10 +790,10 @@ func TestMoveFlagHelp(t *testing.T) { |
| } |
| } |
| out.Reset() |
| - if _, err := parseMoveArgs([]string{"resolve", "--host", tHost, "--declare-lost", "uk-box"}, &out); err == nil { |
| - t.Fatal("--declare-lost parsed") |
| + if _, err := parseMoveArgs([]string{"resolve", "--host", tHost, "--instance-id", "i-1"}, &out); err == nil { |
| + t.Fatal("--instance-id parsed") |
| } |
| - if !strings.Contains(out.String(), "flag provided but not defined: -declare-lost") || !strings.Contains(out.String(), "-retry") { |
| + if !strings.Contains(out.String(), "flag provided but not defined: -instance-id") || !strings.Contains(out.String(), "-retry") { |
| t.Errorf("a bad flag's help:\n%s", out.String()) |
| } |
| } |
| @@ -846,3 +871,175 @@ func TestMoveFixedSentences(t *testing.T) { |
| wantNoLine(t, out.String(), "plain text") |
| } |
| } |
| + |
| +// ---- Task 1b: carlos move resolve --declare-lost (claim Plan 10's door) ---- |
| + |
| +// fakeEC2 answers DescribeInstances for one instance in one state. |
| +type fakeEC2 struct { |
| + account, instance, state string |
| + calls *int |
| +} |
| + |
| +func (f fakeEC2) DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { |
| + *f.calls++ |
| + out := &ec2.DescribeInstancesOutput{Reservations: []ec2types.Reservation{{ |
| + OwnerId: aws.String(f.account), |
| + Instances: []ec2types.Instance{{InstanceId: aws.String(f.instance), State: &ec2types.InstanceState{Name: ec2types.InstanceStateName(f.state)}}}, |
| + }}} |
| + awsmiddleware.SetRequestIDMetadata(&out.ResultMetadata, "aws-request-1b") |
| + return out, nil |
| +} |
| + |
| +const tSignedChallenge = `{"envelope":"signed by the test console"}` |
| + |
| +// lossChallenge is an inspection as claim Plan 10's move door answers it. |
| +func lossChallenge(t *testing.T, transition string) hostresolve.Inspection { |
| + t.Helper() |
| + c := hostfence.Challenge{ |
| + ID: "loss-0123456789abcdef", Actor: "op@keymail.dev", Host: tHost, |
| + HolderIncarnation: "inc-1", HolderStamp: 3, HolderSession: 4, ClaimGen: 9, ClaimETag: "etag-claim", |
| + Op: hostfence.Operation{ID: tMoveID, Kind: hostclaim.KindLanding, Phase: "probing"}, |
| + Record: hostfence.RecordObservation{Key: "apps/bab/titogo/instances/acme.go.tito.io.json", ETag: "etag-record", CommitState: "present"}, |
| + Box: "uk-box", Enrollment: "0123456789abcdef0123456789abcdef", |
| + Cloud: hostfence.CloudBinding{Provider: "aws", Account: "111122223333", Region: "eu-west-2", InstanceID: "i-uk-box"}, |
| + Consequences: hostfence.Consequences{Transition: transition, PossibleWriteLoss: true}, |
| + } |
| + if transition == "destroyed-local-only" { |
| + c.Consequences = hostfence.Consequences{ |
| + Transition: "destroyed-local-only", |
| + DischargeLocal: []hostclaim.ObligationRef{{ID: "local", Op: tMoveID, Retirement: "retired-installation"}}, |
| + DestroyedStorageAssertion: []string{tMoveID + "/local/retired-installation"}, |
| + } |
| + } |
| + c, err := hostfence.PrepareChallenge(time.Now().Add(-time.Second), c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return hostresolve.Inspection{Challenge: json.RawMessage(tSignedChallenge), Details: c} |
| +} |
| + |
| +// lossConsole is a console in landing whose move door answers the |
| +// inspection, then the declaration (202, the flat view in rolling_back). |
| +func lossConsole(t *testing.T, in hostresolve.Inspection, declared *hostfence.Submission) *fakeMoveConsole { |
| + flipped := time.Date(2026, 10, 11, 9, 30, 0, 0, time.UTC) |
| + c := &fakeMoveConsole{frames: []frame{inFlight("landing", frame{"flipped_at": flipped.Format(time.RFC3339)}, nil)}} |
| + c.answer = func(method, path, body string) (int, any) { |
| + var req struct { |
| + DeclareLost string `json:"declare_lost"` |
| + Submission *hostfence.Submission `json:"submission"` |
| + } |
| + if path != tMovePth+"/resolve" || json.Unmarshal([]byte(body), &req) != nil || req.DeclareLost != "uk-box" { |
| + t.Errorf("unexpected %s %s %s", method, path, body) |
| + return http.StatusBadRequest, map[string]string{"error": "invalid resolution request"} |
| + } |
| + if req.Submission == nil { |
| + return http.StatusOK, in |
| + } |
| + *declared = *req.Submission |
| + return http.StatusAccepted, frame{"state": "rolling_back", "target": "uk", "id": tMoveID, "protocol": "phase1"} |
| + } |
| + return c |
| +} |
| + |
| +func TestMoveDeclareLost(t *testing.T) { |
| + const mc18 = "This declares uk-box lost. Anything acme.go.tito.io wrote on uk-box after 2026-10-11 09:30:00 UTC is lost. Type the host name to confirm." |
| + const localOnly = "This records that uk-box is gone with its disks. acme.go.tito.io keeps nothing there that it still needs. Type the host name to confirm." |
| + args := []string{"resolve", "--host", tHost, "--declare-lost", "uk-box", "--no-wait"} |
| + reader := func(state string, calls *int, region *string) func(*moveDeps) { |
| + return func(d *moveDeps) { |
| + d.stdin = strings.NewReader(tHost + "\n") |
| + d.ec2 = func(_ context.Context, r string) (awsinv.ExactEC2API, error) { |
| + *region = r |
| + return fakeEC2{account: "111122223333", instance: "i-uk-box", state: state, calls: calls}, nil |
| + } |
| + } |
| + } |
| + |
| + t.Run("move-target-lost", func(t *testing.T) { |
| + in := lossChallenge(t, "move-target-lost") |
| + var sub hostfence.Submission |
| + calls, region := 0, "" |
| + code, out := runMoveWith(t, lossConsole(t, in, &sub), reader("stopped", &calls, ®ion), args...) |
| + if code != 0 { |
| + t.Fatalf("exit %d:\n%s", code, out) |
| + } |
| + wantLines(t, out, mc18, "Move "+tMoveID+" of acme.go.tito.io is rolling_back.") |
| + wantNoLine(t, out, localOnly) |
| + if region != "eu-west-2" || calls != 1 { |
| + t.Fatalf("EC2 read in %q, %d calls; want the challenge's region, once", region, calls) |
| + } |
| + want := hostfence.OperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true} |
| + if string(sub.Challenge) != tSignedChallenge || sub.Confirmation != in.Details.ID || sub.RequestID != "aws-request-1b" || |
| + sub.Observation.State != "stopped" || sub.Observation.InstanceID != "i-uk-box" || sub.Attestation.MaintainComputeFence != want.MaintainComputeFence || |
| + sub.Attestation.ColdQuarantineStartOnly != want.ColdQuarantineStartOnly || len(sub.Attestation.Disks) != 0 { |
| + t.Fatalf("submission %+v", sub) |
| + } |
| + }) |
| + t.Run("destroyed-local-only", func(t *testing.T) { |
| + in := lossChallenge(t, "destroyed-local-only") |
| + var sub hostfence.Submission |
| + calls, region := 0, "" |
| + code, out := runMoveWith(t, lossConsole(t, in, &sub), reader("terminated", &calls, ®ion), args...) |
| + if code != 0 { |
| + t.Fatalf("exit %d:\n%s", code, out) |
| + } |
| + wantLines(t, out, localOnly) |
| + wantNoLine(t, out, "This declares uk-box lost.") |
| + if strings.Join(sub.Attestation.Disks, ",") != tMoveID+"/local/retired-installation" || sub.Confirmation != in.Details.ID { |
| + t.Fatalf("submission %+v", sub) |
| + } |
| + }) |
| + t.Run("a wrong host name refuses", func(t *testing.T) { |
| + in := lossChallenge(t, "move-target-lost") |
| + var sub hostfence.Submission |
| + c := lossConsole(t, in, &sub) |
| + code, out := runMoveWith(t, c, func(d *moveDeps) { d.stdin = strings.NewReader("acme.go.tito.io.evil\n") }, args...) |
| + if code != 5 { |
| + t.Fatalf("exit %d, want 5:\n%s", code, out) |
| + } |
| + wantLines(t, out, mc18, "That is not acme.go.tito.io. Nothing was declared.") |
| + if m := c.mutations(); len(m) != 1 || sub.Confirmation != "" { |
| + t.Fatalf("a refused confirmation declared: %q", m) |
| + } |
| + }) |
| + t.Run("a running box is refused before the declaration", func(t *testing.T) { |
| + in := lossChallenge(t, "move-target-lost") |
| + var sub hostfence.Submission |
| + calls, region := 0, "" |
| + c := lossConsole(t, in, &sub) |
| + code, out := runMoveWith(t, c, reader("running", &calls, ®ion), args...) |
| + if code != 5 { |
| + t.Fatalf("exit %d, want 5:\n%s", code, out) |
| + } |
| + wantLines(t, out, "AWS does not show uk-box stopped or terminated, so it cannot be declared lost.") |
| + if m := c.mutations(); len(m) != 1 { |
| + t.Fatalf("mutations %q: want the inspection only", m) |
| + } |
| + }) |
| + t.Run("no AWS credentials", func(t *testing.T) { |
| + in := lossChallenge(t, "move-target-lost") |
| + var sub hostfence.Submission |
| + c := lossConsole(t, in, &sub) |
| + code, out := runMoveWith(t, c, func(d *moveDeps) { |
| + d.stdin = strings.NewReader(tHost + "\n") |
| + d.ec2 = func(context.Context, string) (awsinv.ExactEC2API, error) { |
| + return nil, errors.New("no credential chain") |
| + } |
| + }, args...) |
| + if code != 5 { |
| + t.Fatalf("exit %d, want 5:\n%s", code, out) |
| + } |
| + wantLines(t, out, "declare-lost needs your AWS credentials to confirm the box is stopped or terminated.") |
| + wantNoLine(t, out, "no credential chain") |
| + }) |
| + t.Run("the console refuses", func(t *testing.T) { |
| + c := &fakeMoveConsole{frames: []frame{inFlight("landing", nil, nil)}, answer: func(string, string, string) (int, any) { |
| + return http.StatusConflict, map[string]string{"error": "declare-lost is not enabled in this deployment yet"} |
| + }} |
| + code, out := runMoveAgainst(t, c, args...) |
| + if code != 5 { |
| + t.Fatalf("exit %d, want 5:\n%s", code, out) |
| + } |
| + wantLines(t, out, "declare-lost is not enabled in this deployment yet") |
| + }) |
| +} |