| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/activator" |
| 13 | "github.com/carlosframework/platform/internal/hostbox" |
| 14 | "github.com/carlosframework/platform/internal/hostclaim" |
| 15 | "github.com/carlosframework/platform/internal/hostguard" |
| 16 | "github.com/carlosframework/platform/internal/registry" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | ) |
| 19 | |
| 20 | const adminTokenHost = "shop.bab.oncarlos.com" |
| 21 | |
| 22 | type adminTokenFixture struct { |
| 23 | t *testing.T |
| 24 | claims release.Store |
| 25 | data string |
| 26 | backend *activator.ExecBackend |
| 27 | tokens claimAdminTokens |
| 28 | rt registry.Route |
| 29 | actor hostbox.Actor |
| 30 | verify error |
| 31 | } |
| 32 | |
| 33 | // newAdminTokenFixture is a first unscheduled wake on box-1: a serving claim |
| 34 | // whose executor is execBox, no local hostguard record yet (Activate runs at |
| 35 | // Admit, after selection), a staged live release and no <host>.env at all. |
| 36 | func newAdminTokenFixture(t *testing.T, execBox string) *adminTokenFixture { |
| 37 | t.Helper() |
| 38 | ctx := t.Context() |
| 39 | st := release.NewDirStore(t.TempDir()) |
| 40 | now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) |
| 41 | in := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + adminTokenHost + ".json", Account: "bab", App: "jam", Host: adminTokenHost, Region: "ie", RecordRegion: "ie", CreatedAt: now} |
| 42 | body := []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie","created_at":"2026-10-06T12:00:00Z"}`) |
| 43 | absent, err := hostclaim.Read(ctx, st, adminTokenHost) |
| 44 | if err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | o, err := hostclaim.Update(ctx, st, absent, hostclaim.BeginCreate(in, body, "create", "console", now)) |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | if _, err = hostclaim.Update(ctx, st, o, hostclaim.CompleteCreate("create", execBox)); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | root := t.TempDir() |
| 55 | data, opt := filepath.Join(root, "data"), filepath.Join(root, "opt") |
| 56 | for _, dir := range []string{filepath.Join(data, "hostguard"), filepath.Join(opt, "versions", "v1", "bab"), filepath.Join(opt, "live")} { |
| 57 | if err = os.MkdirAll(dir, 0o770); err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | } |
| 61 | bin := filepath.Join(opt, "versions", "v1", "bab", "jam") |
| 62 | if err = os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | if err = os.Symlink(bin, filepath.Join(opt, "live", adminTokenHost)); err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | reg, err := registry.Open(filepath.Join(root, "registry.db")) |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | t.Cleanup(func() { reg.Close() }) |
| 73 | f := &adminTokenFixture{t: t, claims: st, data: data, backend: &activator.ExecBackend{DataDir: data, OptDir: opt}, actor: hostbox.Actor{Box: "box-1", Region: "ie", Enrollment: "enr-1"}} |
| 74 | f.rt = registry.Route{Host: adminTokenHost, Socket: filepath.Join(root, "run", "app.sock"), DBPath: filepath.Join(data, adminTokenHost+".db")} |
| 75 | local := &productionClaimLocal{legacyParticipation: true, Resources: ClaimRuntimeResources{Mode: "agent", DataDir: data}, slots: map[string]*claimParticipation{}} |
| 76 | f.tokens = claimAdminTokens{ |
| 77 | Guard: hostguard.Guard{DataDir: data, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: st}}, |
| 78 | Claims: st, |
| 79 | Verify: f.verifyActor, |
| 80 | Local: func(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection) error { |
| 81 | a, err := f.verifyActor(ctx) |
| 82 | if err != nil { |
| 83 | return err |
| 84 | } |
| 85 | if s.Box != a.Box || s.Region != a.Region { |
| 86 | return hostbox.ErrChanged |
| 87 | } |
| 88 | return local.Check(ctx, a, c, s) |
| 89 | }, |
| 90 | Policy: hostbox.Policy{Held: hostbox.RegistryHoldPolicy(reg)}.Check, |
| 91 | Now: time.Now, |
| 92 | } |
| 93 | return f |
| 94 | } |
| 95 | |
| 96 | func (f *adminTokenFixture) verifyActor(context.Context) (hostbox.Actor, error) { |
| 97 | return f.actor, f.verify |
| 98 | } |
| 99 | |
| 100 | func (f *adminTokenFixture) envPath() string { return filepath.Join(f.data, adminTokenHost+".env") } |
| 101 | |
| 102 | func (f *adminTokenFixture) token() string { |
| 103 | f.t.Helper() |
| 104 | raw, err := os.ReadFile(f.envPath()) |
| 105 | if err != nil { |
| 106 | f.t.Fatal(err) |
| 107 | } |
| 108 | var tok string |
| 109 | for _, line := range strings.Split(string(raw), "\n") { |
| 110 | if v, ok := strings.CutPrefix(line, "CARLOS_ADMIN_TOKEN="); ok { |
| 111 | if tok != "" { |
| 112 | f.t.Fatalf("env file holds two tokens: %q", raw) |
| 113 | } |
| 114 | tok = v |
| 115 | } |
| 116 | } |
| 117 | return tok |
| 118 | } |
| 119 | |
| 120 | func envHasToken(env []string, tok string) bool { |
| 121 | for _, kv := range env { |
| 122 | if kv == "CARLOS_ADMIN_TOKEN="+tok { |
| 123 | return true |
| 124 | } |
| 125 | } |
| 126 | return false |
| 127 | } |
| 128 | |
| 129 | // B3 regression: the claim path bypasses the legacy launch preparation that |
| 130 | // mints CARLOS_ADMIN_TOKEN. The production serving launch source must |
| 131 | // initialize the token under the claim's own serving authority before the |
| 132 | // read-only snapshot, and every later selection must see the same token. |
| 133 | func TestClaimProductionFirstWakeInitializesAdminToken(t *testing.T) { |
| 134 | f := newAdminTokenFixture(t, "box-1") |
| 135 | launch := claimServingLaunchSource(f.backend, f.tokens) |
| 136 | first, err := launch(t.Context(), f.rt) |
| 137 | if err != nil { |
| 138 | t.Fatalf("first unscheduled wake: launch selection failed: %v", err) |
| 139 | } |
| 140 | tok := f.token() |
| 141 | if len(tok) != 64 { |
| 142 | t.Fatalf("minted token %q, want 64 hex characters", tok) |
| 143 | } |
| 144 | if !envHasToken(first.Env, tok) || first.Identity.Config == "" || first.Identity.Release == "" { |
| 145 | t.Fatalf("snapshot does not carry the minted token: %+v", first) |
| 146 | } |
| 147 | // Retry selection, still before Admit: no rotation, identical identity. |
| 148 | again, err := launch(t.Context(), f.rt) |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | if f.token() != tok || again.Identity != first.Identity { |
| 153 | t.Fatalf("retry rotated the token or changed the launch identity: %q→%q %+v→%+v", tok, f.token(), first.Identity, again.Identity) |
| 154 | } |
| 155 | // After Admit activates the local record, the token is still stable. |
| 156 | if err = f.tokens.Guard.Activate(t.Context(), adminTokenHost, "box-1", nil); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | after, err := launch(t.Context(), f.rt) |
| 160 | if err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | if f.token() != tok || after.Identity != first.Identity { |
| 164 | t.Fatalf("post-admission selection rotated the token: %q→%q", tok, f.token()) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // With a local record in place and the token removed, the token is minted |
| 169 | // again only through the guarded installation path (Guard.With). |
| 170 | func TestClaimProductionAdminTokenUnderInstalledRecord(t *testing.T) { |
| 171 | f := newAdminTokenFixture(t, "box-1") |
| 172 | if err := f.tokens.Guard.Activate(t.Context(), adminTokenHost, "box-1", nil); err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | if _, err := claimServingLaunchSource(f.backend, f.tokens)(t.Context(), f.rt); err != nil { |
| 176 | t.Fatalf("installed serving holder: %v", err) |
| 177 | } |
| 178 | if len(f.token()) != 64 { |
| 179 | t.Fatal("no token minted under the installed record") |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // Missing or stale authority refuses the wake before any file is written. |
| 184 | func TestClaimProductionAdminTokenRefusesWithoutAuthority(t *testing.T) { |
| 185 | t.Parallel() |
| 186 | for _, tc := range []struct { |
| 187 | name string |
| 188 | setup func(*adminTokenFixture) |
| 189 | }{ |
| 190 | {"other executor", func(f *adminTokenFixture) {}}, |
| 191 | {"identity unverifiable", func(f *adminTokenFixture) { f.verify = errors.New("enrollment revoked") }}, |
| 192 | {"no authorizer", func(f *adminTokenFixture) { f.tokens = claimAdminTokens{} }}, |
| 193 | {"stale local record", func(f *adminTokenFixture) { |
| 194 | // A record from an earlier incarnation is not this holder's installation. |
| 195 | if err := os.WriteFile(filepath.Join(f.data, "hostguard", adminTokenHost+".json"), []byte(`{"v":1,"host":"`+adminTokenHost+`","incarnation":"old","active_stamp":1,"retired_stamps":[]}`), 0o600); err != nil { |
| 196 | f.t.Fatal(err) |
| 197 | } |
| 198 | }}, |
| 199 | } { |
| 200 | t.Run(tc.name, func(t *testing.T) { |
| 201 | box := "box-1" |
| 202 | if tc.name == "other executor" { |
| 203 | box = "box-2" |
| 204 | } |
| 205 | f := newAdminTokenFixture(t, box) |
| 206 | tc.setup(f) |
| 207 | if _, err := claimServingLaunchSource(f.backend, f.tokens)(t.Context(), f.rt); err == nil { |
| 208 | t.Fatal("launch selection succeeded without serving authority") |
| 209 | } |
| 210 | if _, err := os.Stat(f.envPath()); !errors.Is(err, os.ErrNotExist) { |
| 211 | t.Fatalf("token initialization wrote %s without authority: %v", f.envPath(), err) |
| 212 | } |
| 213 | }) |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // The snapshot itself stays read-only: it refuses a missing token and never |
| 218 | // creates one, so the InputPolicy re-check under hostguard cannot mint. |
| 219 | func TestSnapshotClaimLaunchNeverMintsAdminToken(t *testing.T) { |
| 220 | f := newAdminTokenFixture(t, "box-1") |
| 221 | if _, err := f.backend.SnapshotClaimLaunch(t.Context(), f.rt); err == nil { |
| 222 | t.Fatal("snapshot accepted a missing token") |
| 223 | } |
| 224 | if _, err := os.Stat(f.envPath()); !errors.Is(err, os.ErrNotExist) { |
| 225 | t.Fatalf("snapshot wrote %s: %v", f.envPath(), err) |
| 226 | } |
| 227 | } |
| 228 | |