package console import ( "bytes" "context" "crypto/ed25519" "encoding/json" "errors" "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" ) type activationFaultStore struct { release.Store fail bool commit bool hide bool } func (s *activationFaultStore) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { if key != release.SystemPointerKey("router", "stable") || !s.fail { return s.Store.Put(ctx, key, raw, etag) } s.fail = false if s.commit { if _, err := s.Store.Put(ctx, key, raw, etag); err != nil { return "", err } } return "", errors.New("publication response lost") } func (s *activationFaultStore) Get(ctx context.Context, key string) ([]byte, string, error) { if s.hide && key == release.SystemPointerKey("router", "stable") && !s.fail { s.hide = false return nil, "", errors.New("publication read unavailable") } return s.Store.Get(ctx, key) } func activationFixture(t *testing.T) (*Server, hostactors.Directory, release.SystemRelease) { t.Helper() ctx := t.Context() pub, key, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } store := release.NewDirStore(t.TempDir()) s := &Server{cfg: Config{Releases: store, Key: key, Bucket: "bucket"}} sg := release.RootSigner(key) 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{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"}} 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 s, hostactors.Directory{Store: store, PublicKey: pub, Bucket: "bucket"}, next } // Legacy promotion needs both floor objects absent; either half alone is // an unfinished publication (review round 4). func TestHostUpdateReservationRefusesIncompleteFloorPair(t *testing.T) { for _, drop := range []string{hostfloor.Key, hostfloor.AuthKey} { t.Run(drop, func(t *testing.T) { s, _, target := activationFixture(t) if err := s.cfg.Releases.Delete(t.Context(), drop); err != nil { t.Fatal(err) } if id, err := s.reserveHostUpdate(t.Context(), &target, "stable", "promote", release.SystemPromoteOptions{}); !errors.Is(err, hostfloor.ErrFloor) || id != "" { t.Fatalf("incomplete pair without %s = %q, %v; want ErrFloor", drop, id, err) } }) } } func TestHostUpdateReservationRetriesPublication(t *testing.T) { for _, fault := range []string{"crash", "failed", "committed", "unreadable"} { t.Run(fault, func(t *testing.T) { s, d, target := activationFixture(t) id, err := s.reserveHostUpdate(t.Context(), &target, "stable", "promote", release.SystemPromoteOptions{}) if err != nil || id == "" { t.Fatal(id, err) } first, err := d.Read(t.Context()) if err != nil { t.Fatal(err) } if fault != "crash" { broken := &activationFaultStore{Store: d.Store, fail: true, commit: fault != "failed", hide: fault == "unreadable"} s.cfg.Releases = broken _, err = s.promoteHostUpdate(t.Context(), target, "router", "stable", release.SystemPromoteOptions{}) if (err == nil) != (fault == "committed") { t.Fatalf("first publication: %v", err) } } // A new console process has no in-memory job or reservation state. s = &Server{cfg: Config{Releases: d.Store, Key: s.cfg.Key, Bucket: "bucket"}} p, err := s.promoteHostUpdate(t.Context(), target, "router", "stable", release.SystemPromoteOptions{}) if err != nil || p.ActivationID != id || p.Current.Version != target.Version { t.Fatal(p, err) } after, err := d.Read(t.Context()) if err != nil || len(after.Roster.Activations) != 1 { t.Fatal(after, err) } if !bytes.Equal(first.Roster.Activations[id].Publication.After, after.Roster.Activations[id].Publication.After) { t.Fatal("retry replaced publication identity") } }) } } func TestHostRollbackResumesWithoutPoppingAgain(t *testing.T) { s, d, _ := activationFixture(t) broken := &activationFaultStore{Store: d.Store, fail: true} s.cfg.Releases = broken if _, err := s.rollbackHostUpdate(t.Context(), "router", "stable", "operator"); err == nil { t.Fatal("missing injected failure") } before, err := d.Read(t.Context()) if err != nil || len(before.Roster.Activations) != 1 { t.Fatal(before, err) } var id string for id = range before.Roster.Activations { } s.cfg.Releases = d.Store first, err := s.rollbackHostUpdate(t.Context(), "router", "stable", "operator") if err != nil || first.ActivationID != id || first.Current.Version != "v1.1.0" { t.Fatal(first, err) } second, err := s.rollbackHostUpdate(t.Context(), "router", "stable", "operator") if err != nil || second.ActivationID != id || len(second.History) != len(first.History) { t.Fatal(second, err) } } func TestHostUpdateCancellationBlocksDelayedPublication(t *testing.T) { s, d, target := activationFixture(t) id, err := s.reserveHostUpdate(t.Context(), &target, "stable", "promote", release.SystemPromoteOptions{}) if err != nil { t.Fatal(err) } before, err := d.Read(t.Context()) if err != nil { t.Fatal(err) } stale := *before.Roster.Activations[id].Publication if err := d.CancelActivation(t.Context(), release.RootSigner(s.cfg.Key), id); err != nil { t.Fatal(err) } if _, err := release.PublishSystemPublication(t.Context(), d.Store, d.PublicKey, stale); !errors.Is(err, release.ErrCASConflict) { t.Fatalf("delayed publication: %v", err) } after, err := d.Read(t.Context()) if err != nil || len(after.Roster.Activations) != 0 { t.Fatal(after, err) } p, err := s.promoteHostUpdate(t.Context(), target, "router", "stable", release.SystemPromoteOptions{}) if err != nil || p.ActivationID == id { t.Fatal(p, err) } if err := d.CancelActivation(t.Context(), release.RootSigner(s.cfg.Key), p.ActivationID); !errors.Is(err, release.ErrCASConflict) { t.Fatalf("cancelled published activation: %v", err) } after, err = d.Read(t.Context()) if err != nil || len(after.Roster.Activations) != 1 { t.Fatal(after, err) } }