package console import ( "bytes" "context" "crypto/ed25519" "encoding/json" "errors" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" ) // plan9ActivationWorld is activationFixture's published deployment, built // on an existing bucket and key so the HTTP doors can be exercised: stable // points at v1.0.0 with v1.1.0 in its history, the floor is published and // one active box reports v1.0.0 on stable. func plan9ActivationWorld(t *testing.T, store release.Store, key ed25519.PrivateKey) release.SystemRelease { t.Helper() ctx := context.Background() sg := release.RootSigner(key) pub := sg.RootPublicKey() old := release.SystemRelease{Version: "v1.0.0", Source: "release:v1.0.0", ReleaseTag: "v1.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: release.SystemArtifactKey("v1.0.0", "linux-amd64"), SHA256: strings.Repeat("a", 64)}}} next := release.SystemRelease{Version: "v1.1.0", Source: "release:v1.1.0", ReleaseTag: "v1.1.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: release.SystemArtifactKey("v1.1.0", "linux-amd64"), SHA256: strings.Repeat("b", 64)}}} for _, rel := range []release.SystemRelease{next, old} { if _, err := release.PromoteSystem(ctx, store, sg, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { t.Fatal(err) } raw, _, err := store.Get(ctx, release.SystemPointerKey("router", "stable")) if err != nil { t.Fatal(err) } if err := release.PublishSystemIdentity(ctx, store, pub, raw); err != nil { t.Fatal(err) } } if err := hostfloor.Publish(ctx, store, sg, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}); err != nil { t.Fatal(err) } floor, err := hostfloor.Read(ctx, store, pub, "bucket") if err != nil { t.Fatal(err) } enrollment := strings.Repeat("e", 32) roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Publication: "published", FloorSHA256: floor.Digest, Boxes: map[string]hostactors.Box{"box": {BoxID: "box", Namespace: "platform", Label: "box", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box"}, Enrollment: enrollment, EvidenceSHA256: strings.Repeat("f", 64), State: "active"}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} raw, err := release.SignRootEnvelope(sg, roster) if err != nil { t.Fatal(err) } if _, err := store.Put(ctx, hostactors.Key, raw, ""); err != nil { t.Fatal(err) } box := status.Box{Label: "box", GeneratedAt: time.Now(), Metrics: &status.BoxMetrics{Enrollment: enrollment, InstanceID: "i-box", Release: old.Version, ReleaseTag: old.ReleaseTag, Platform: "linux-amd64", BinarySHA256: old.Artifacts["linux-amd64"].SHA256, MovesProtocol: 1, BootID: "boot", FloorSHA256: floor.Digest, FloorGuardReady: true, HostClaimEnabled: true, SystemChannel: "stable", Layout: &status.LayoutReport{Mode: "flat", Rows: map[string]status.RowLayout{}}}} raw, err = json.Marshal(box) if err != nil { t.Fatal(err) } if _, err := store.Put(ctx, "status/boxes/box.json", raw, ""); err != nil { t.Fatal(err) } return next } func plan9Roster(t *testing.T, s *Server) hostactors.Observed { t.Helper() d := hostactors.Directory{Store: s.cfg.Releases, PublicKey: release.RootSigner(s.cfg.Key).RootPublicKey(), Bucket: s.cfg.Bucket} o, err := d.Read(context.Background()) if err != nil { t.Fatal(err) } return o } // M-8: a host update reservation is created only against a roster that is // published at exactly the floor being enforced. A floor whose roster is // still preparing, unpublished or bound to another digest is an unfinished // publication and refuses, with no reservation and no pointer movement. func TestPlan9ReserveHostUpdateRequiresPublishedRosterAtFloor(t *testing.T) { for _, tc := range []string{"preparing", "unpublished", "other-digest"} { t.Run(tc, func(t *testing.T) { ctx := context.Background() s, d, target := activationFixture(t) before := plan9Roster(t, s) r := hostactors.Clone(before.Roster) switch tc { case "preparing": r.Publication = "preparing" case "unpublished": r.Publication, r.FloorSHA256 = "", "" case "other-digest": r.FloorSHA256 = strings.Repeat("0", 64) } raw, err := release.SignRootEnvelope(release.RootSigner(s.cfg.Key), r) if err != nil { t.Fatal(err) } if _, err := d.Store.Put(ctx, hostactors.Key, raw, before.ETag); err != nil { t.Fatal(err) } pointer, _, err := d.Store.Get(ctx, release.SystemPointerKey("router", "stable")) if err != nil { t.Fatal(err) } if id, err := s.reserveHostUpdate(ctx, &target, "stable", "promote", release.SystemPromoteOptions{}); err == nil || id != "" { t.Fatalf("%s: reservation = %q, %v; want refusal", tc, id, err) } if _, err := s.promoteHostUpdate(ctx, target, "router", "stable", release.SystemPromoteOptions{}); err == nil { t.Fatalf("%s: promote published against an unfinished floor publication", tc) } after := plan9Roster(t, s) if len(after.Roster.Activations) != 0 { t.Fatalf("%s: reservation written", tc) } now, _, err := d.Store.Get(ctx, release.SystemPointerKey("router", "stable")) if err != nil || !bytes.Equal(now, pointer) { t.Fatalf("%s: pointer moved: %v", tc, err) } }) } } // casOncePointerStore answers one conditional pointer write with the // conflict S3 gives when another writer moved the pointer first. type casOncePointerStore struct { release.Store puts int } func (s *casOncePointerStore) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { if key == release.SystemPointerKey("router", "canary") { s.puts++ return "", release.ErrCASConflict } return s.Store.Put(ctx, key, raw, etag) } // M-6: with the flag off and no floor, a rollback that loses its pointer // CAS answers 409 and does not retry. A retry would pop history again // against a pointer the operator never saw. func TestPlan9RollbackCASConflictIsAConflictAndNotRetried(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-plan9-rollback") sg := release.RootSigner(fx.priv) rel := func(v string) release.SystemRelease { return release.SystemRelease{Version: v, Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey(v, "linux-arm64"), SHA256: strings.Repeat("a", 64)}}} } if _, err := release.PromoteSystem(context.Background(), fx.rel, sg, "router", "canary", rel("v1.25.0"), release.SystemPromoteOptions{}); err != nil { t.Fatal(err) } time.Sleep(1100 * time.Millisecond) if _, err := release.PromoteSystem(context.Background(), fx.rel, sg, "router", "canary", rel("v1.26.0"), release.SystemPromoteOptions{}); err != nil { t.Fatal(err) } pointer, _, err := fx.rel.Get(context.Background(), release.SystemPointerKey("router", "canary")) if err != nil { t.Fatal(err) } wrapped := &casOncePointerStore{Store: fx.rel} fx.srv.cfg.Releases = wrapped st, out := postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, owner) if st != http.StatusConflict { t.Fatalf("rollback after a lost CAS = %d %v, want 409", st, out) } if msg, _ := out["error"].(string); strings.Contains(msg, "compare-and-swap") || strings.Contains(msg, "—") { t.Fatalf("409 body leaks the store error or uses an em dash: %q", msg) } if wrapped.puts != 1 { t.Fatalf("pointer writes = %d, want exactly one attempt", wrapped.puts) } now, _, err := fx.rel.Get(context.Background(), release.SystemPointerKey("router", "canary")) if err != nil || !bytes.Equal(now, pointer) { t.Fatalf("pointer changed: %v", err) } } // I-4: an unpublished activation reservation blocks rollback and every // other promote of its channel. An ops owner or system operator, on a person // session, cancels it: the pointer is fenced at the publication's expected // ETag first, so a delayed publication can never land, and only then is // the reservation removed. Rollback and promote work again afterwards. func TestPlan9OperatorCancelsAnUnpublishedActivation(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-plan9-cancel") fx.srv.cfg.Bucket = "bucket" target := plan9ActivationWorld(t, fx.rel, fx.priv) ctx := context.Background() id, err := fx.srv.reserveHostUpdate(ctx, &target, "stable", "promote", release.SystemPromoteOptions{}) if err != nil || id == "" { t.Fatal(id, err) } held := plan9Roster(t, fx.srv) stale := *held.Roster.Activations[id].Publication // The wedge: a different request for the channel is refused while held. if _, err := fx.srv.rollbackHostUpdate(ctx, "router", "stable", "paul@keymail.dev"); err == nil { t.Fatal("rollback succeeded through a held activation") } if fx.srv.cfg.Releases == nil { t.Fatal("fixture has no bucket") } if fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()) != nil { t.Fatal("add member") } member := cliToken(t, fx, "member@keymail.dev", "code-plan9-cancel-member") _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member) if st, body := postRaw(t, fx.ts.URL+"/api/cli/system/activation/cancel", map[string]string{"channel": "stable"}, member); st != http.StatusNotFound || body != want { t.Fatalf("member cancel = %d %q, want the bare 404", st, body) } if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/activation/cancel", map[string]string{"channel": "stable", "activation": strings.Repeat("0", 32)}, owner); st != http.StatusConflict { t.Fatalf("cancel naming another activation = %d %v, want 409", st, out) } if after := plan9Roster(t, fx.srv); len(after.Roster.Activations) != 1 { t.Fatal("mismatched cancel removed the reservation") } for _, body := range []map[string]string{{"channel": "edge"}, {"channel": ""}, {"channel": "stable", "component": "../etc"}} { if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/activation/cancel", body, owner); st != http.StatusBadRequest { t.Errorf("cancel %v = %d %v, want 400", body, st, out) } } st, out := postJSON(t, fx.ts.URL+"/api/cli/system/activation/cancel", map[string]string{"channel": "stable", "activation": id}, owner) if st != http.StatusOK || str(t, out, "activation") != id { t.Fatalf("cancel = %d %v", st, out) } if after := plan9Roster(t, fx.srv); len(after.Roster.Activations) != 0 { t.Fatal("cancel left the reservation held") } if _, err := release.PublishSystemPublication(ctx, fx.rel, fx.pub, stale); !errors.Is(err, release.ErrCASConflict) { t.Fatalf("delayed publication after cancel: %v", err) } if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/activation/cancel", map[string]string{"channel": "stable"}, owner); st != http.StatusNotFound { t.Fatalf("cancel with nothing held = %d %v, want 404", st, out) } p, err := fx.srv.rollbackHostUpdate(ctx, "router", "stable", "paul@keymail.dev") if err != nil || p.Current.Version != "v1.1.0" { t.Fatalf("rollback after cancel = %v, %v", p, err) } } // I-4: cancellation never reaches past publication. A published activation // is released only by completion or the D7 exclusions, so the door answers // 409 and the reservation stays held. A read-only console cannot cancel. func TestPlan9CancelRefusesAPublishedActivationAndReadOnlyConsole(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-plan9-published") fx.srv.cfg.Bucket = "bucket" target := plan9ActivationWorld(t, fx.rel, fx.priv) p, err := fx.srv.promoteHostUpdate(context.Background(), target, "router", "stable", release.SystemPromoteOptions{}) if err != nil || p.ActivationID == "" { t.Fatal(p, err) } st, out := postJSON(t, fx.ts.URL+"/api/cli/system/activation/cancel", map[string]string{"channel": "stable"}, owner) if st != http.StatusConflict { t.Fatalf("cancel of a published activation = %d %v, want 409", st, out) } if msg, _ := out["error"].(string); strings.Contains(msg, "—") || strings.Contains(msg, "compare-and-swap") { t.Fatalf("409 body: %q", msg) } if held := plan9Roster(t, fx.srv); len(held.Roster.Activations) != 1 { t.Fatal("published activation released by cancel") } fx.srv.cfg.Key = nil if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/activation/cancel", map[string]string{"channel": "stable"}, owner); st != http.StatusServiceUnavailable { t.Fatalf("read-only cancel = %d %v, want 503", st, out) } } // M-3: every fresh foreign-process report refuses replacement. func TestPlan9ConsoleReportRefusesAFreshForeignProcess(t *testing.T) { started := time.Now().Add(-time.Minute) for _, tc := range []struct { name string existing func() []byte replaced bool }{ {"absent", nil, true}, {"same-process", func() []byte { return plan9Report("boot:1", time.Now()) }, true}, {"foreign-after-start", func() []byte { return plan9Report("boot:2", time.Now()) }, false}, {"foreign-before-start", func() []byte { return plan9Report("boot:2", started.Add(-time.Second)) }, false}, {"foreign-stale", func() []byte { return plan9Report("boot:2", time.Now().Add(-16*time.Minute)) }, true}, {"foreign-future", func() []byte { return plan9Report("boot:2", time.Now().Add(time.Hour)) }, true}, {"malformed", func() []byte { return []byte("{") }, true}, } { t.Run(tc.name, func(t *testing.T) { ctx := t.Context() store := s3ShapedStore{release.NewDirStore(t.TempDir())} const key = "status/consoles/console-2.json" if tc.existing != nil { if _, err := store.Put(ctx, key, tc.existing(), ""); err != nil { t.Fatal(err) } } mine := plan9Report("boot:1", time.Now()) err := putHostReport(ctx, store, key, mine) got, _, readErr := store.Get(ctx, key) if readErr != nil { t.Fatal(readErr) } if tc.replaced { if err != nil || !bytes.Equal(got, mine) { t.Fatalf("%s: put = %v; want replaced", tc.name, err) } return } if err == nil || bytes.Equal(got, mine) { t.Fatalf("%s: put = %v; want refusal and the foreign report kept", tc.name, err) } if strings.Contains(err.Error(), "—") { t.Fatalf("error uses an em dash: %v", err) } }) } } func plan9Report(process string, at time.Time) []byte { raw, _ := json.Marshal(hostactors.Report{Component: "console", Actor: "console-2", ProcessID: process, GeneratedAt: at.UTC()}) return raw }