| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "slices" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/activator" |
| 14 | "github.com/carlosframework/platform/internal/hostclaim" |
| 15 | "github.com/carlosframework/platform/internal/hostwrite" |
| 16 | "github.com/carlosframework/platform/internal/instancekey" |
| 17 | "github.com/carlosframework/platform/internal/lease/leasetest" |
| 18 | "github.com/carlosframework/platform/internal/registry" |
| 19 | ) |
| 20 | |
| 21 | // keyCapturingFiles is a C8 provider that records the instance's own env |
| 22 | // file as it stands when Establish runs: the moment the provider must |
| 23 | // publish it (ruling 6). |
| 24 | type keyCapturingFiles struct { |
| 25 | path string |
| 26 | seen []byte |
| 27 | } |
| 28 | |
| 29 | func (k *keyCapturingFiles) Establish(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error { |
| 30 | raw, err := os.ReadFile(k.path) |
| 31 | k.seen = raw |
| 32 | return err |
| 33 | } |
| 34 | |
| 35 | type keyTestMarkers struct{} |
| 36 | |
| 37 | func (keyTestMarkers) PutMarker(context.Context, string, string, []byte, string, func(context.Context) error) (string, error) { |
| 38 | return "v1", nil |
| 39 | } |
| 40 | |
| 41 | type passWork struct{} |
| 42 | |
| 43 | func (passWork) With(_ context.Context, _ hostwrite.Identity, fn func() error) error { return fn() } |
| 44 | |
| 45 | // The claim's first wake mints the key in Select (the launch source), so |
| 46 | // the env file the C8 provider publishes in Materialize already carries it, |
| 47 | // and the launch env the app will run with carries the same value. |
| 48 | func TestClaimFirstWakeKeyIsOnDiskBeforeTheFilesManifest(t *testing.T) { |
| 49 | f := newAdminTokenFixture(t, "box-1") |
| 50 | files := &keyCapturingFiles{path: f.envPath()} |
| 51 | owners := &claimServingOwners{Data: leasetest.NewVersionedBucket(), Bucket: "data", Region: "ie", Box: "box-1", |
| 52 | NewID: func() string { return "g1" }, Now: func() time.Time { return time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC) }, |
| 53 | Sources: ClaimServingSources{Launch: claimServingLaunchSource(f.backend, f.tokens), Markers: keyTestMarkers{}, Files: files}} |
| 54 | o, err := hostclaim.Read(t.Context(), f.claims, adminTokenHost) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | in, err := owners.Select(t.Context(), f.rt, o, activator.StartPolicy{}) |
| 59 | if err != nil { |
| 60 | t.Fatalf("select: %v", err) |
| 61 | } |
| 62 | if err := owners.Materialize(t.Context(), o, in, passWork{}); err != nil { |
| 63 | t.Fatalf("materialize: %v", err) |
| 64 | } |
| 65 | var key string |
| 66 | for _, line := range strings.Split(string(files.seen), "\n") { |
| 67 | if v, ok := strings.CutPrefix(line, activator.InstanceKeyVar+"="); ok { |
| 68 | key = v |
| 69 | } |
| 70 | } |
| 71 | if len(key) != 44 { |
| 72 | t.Fatalf("the env file at Establish carries no minted key: %d bytes", len(files.seen)) |
| 73 | } |
| 74 | found := false |
| 75 | for _, kv := range in.Env { |
| 76 | found = found || kv == activator.InstanceKeyVar+"="+key |
| 77 | } |
| 78 | if !found { |
| 79 | t.Fatal("the launch env does not carry the key the provider published") |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // Final review I2 on the claim path: a first wake whose configuration is |
| 84 | // still on its way is deferred by the launch source and nothing is minted; |
| 85 | // once the config sync has delivered the configured key, the launch |
| 86 | // delivers that key and the own file still holds none. |
| 87 | func TestClaimFirstWakeWaitsForTheConfiguredKey(t *testing.T) { |
| 88 | f := newAdminTokenFixture(t, "box-1") |
| 89 | f.backend.ConfiguredKey = func(context.Context, registry.Route) (string, error) { return instancekey.Digest("key-A"), nil } |
| 90 | launch := claimServingLaunchSource(f.backend, f.tokens) |
| 91 | if _, err := launch(t.Context(), f.rt); !errors.Is(err, activator.ErrInstanceKeyDeferred) || !strings.Contains(err.Error(), adminTokenHost) { |
| 92 | t.Fatalf("launch before delivery = %v, want ErrInstanceKeyDeferred naming the host", err) |
| 93 | } |
| 94 | raw, _ := os.ReadFile(f.envPath()) |
| 95 | if strings.Contains(string(raw), activator.InstanceKeyVar) { |
| 96 | t.Fatal("a key was minted while the configured one was on its way") |
| 97 | } |
| 98 | if err := os.WriteFile(filepath.Join(f.data, adminTokenHost+".config.env"), []byte(activator.InstanceKeyVar+"=key-A\n"), 0o600); err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | got, err := launch(t.Context(), f.rt) |
| 102 | if err != nil { |
| 103 | t.Fatalf("launch after delivery: %v", err) |
| 104 | } |
| 105 | if !slices.Contains(got.Env, activator.InstanceKeyVar+"=key-A") { |
| 106 | t.Fatal("the launch does not deliver the configured key") |
| 107 | } |
| 108 | if raw, _ := os.ReadFile(f.envPath()); strings.Contains(string(raw), activator.InstanceKeyVar) { |
| 109 | t.Fatal("a key was minted beside the configured one") |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // An instance from before this build has a token and no key. Its next |
| 114 | // claim launch mints the key under the same guarded write, leaves the |
| 115 | // token alone, and a retry keeps both. |
| 116 | func TestClaimLaunchMintsAKeyBesideAnExistingToken(t *testing.T) { |
| 117 | f := newAdminTokenFixture(t, "box-1") |
| 118 | tok := strings.Repeat("c", 64) |
| 119 | if err := os.WriteFile(f.envPath(), []byte("CARLOS_ADMIN_TOKEN="+tok+"\n"), 0o600); err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | launch := claimServingLaunchSource(f.backend, f.tokens) |
| 123 | first, err := launch(t.Context(), f.rt) |
| 124 | if err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | raw, _ := os.ReadFile(f.envPath()) |
| 128 | if f.token() != tok || !strings.HasPrefix(string(raw), "CARLOS_ADMIN_TOKEN="+tok+"\n"+activator.InstanceKeyVar+"=") { |
| 129 | t.Fatalf("env file after the launch: %d bytes, token kept %v", len(raw), f.token() == tok) |
| 130 | } |
| 131 | again, err := launch(t.Context(), f.rt) |
| 132 | if err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | raw2, _ := os.ReadFile(f.envPath()) |
| 136 | if string(raw2) != string(raw) || again.Identity != first.Identity { |
| 137 | t.Fatal("a retried launch changed the env file or the launch identity") |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // Ruling 21: the claim snapshot names the key its env delivers, by digest, |
| 142 | // and the file that supplied it, for O6 to check a process's report |
| 143 | // against. The identity is the env's alone, so it does not change. |
| 144 | func TestClaimSnapshotNamesTheDeliveredKeyAndItsSource(t *testing.T) { |
| 145 | f := newAdminTokenFixture(t, "box-1") |
| 146 | first, err := claimServingLaunchSource(f.backend, f.tokens)(t.Context(), f.rt) |
| 147 | if err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | snap, err := f.backend.SnapshotClaimLaunch(t.Context(), f.rt) |
| 151 | if err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | own, _ := os.ReadFile(f.envPath()) |
| 155 | var key string |
| 156 | for _, line := range strings.Split(string(own), "\n") { |
| 157 | if v, ok := strings.CutPrefix(line, activator.InstanceKeyVar+"="); ok { |
| 158 | key = v |
| 159 | } |
| 160 | } |
| 161 | if snap.InstanceKeySource != instancekey.SourcePlatform || snap.InstanceKeySHA256 != instancekey.Digest(key) || snap.Identity != first.Identity { |
| 162 | t.Fatalf("platform key: source %q, digest match %v, identity kept %v", snap.InstanceKeySource, snap.InstanceKeySHA256 == instancekey.Digest(key), snap.Identity == first.Identity) |
| 163 | } |
| 164 | tok := "CARLOS_ADMIN_TOKEN=" + f.token() + "\n" |
| 165 | if err := os.WriteFile(f.envPath(), []byte(tok+activator.InstanceKeyVar+"=\n"), 0o600); err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | if err := os.WriteFile(filepath.Join(f.data, adminTokenHost+".config.env"), []byte(activator.InstanceKeyVar+"=from-config\n"), 0o600); err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | snap, err = f.backend.SnapshotClaimLaunch(t.Context(), f.rt) |
| 172 | if err != nil || snap.InstanceKeySource != instancekey.SourceConfig || snap.InstanceKeySHA256 != instancekey.Digest("from-config") { |
| 173 | t.Fatalf("configured key: source %q, %v", snap.InstanceKeySource, err) |
| 174 | } |
| 175 | // The named key is the delivered key: Env carries the configured value, |
| 176 | // not the own file's empty override (mergeEnv applies Effective). |
| 177 | if !slices.Contains(snap.Env, activator.InstanceKeyVar+"=from-config") { |
| 178 | t.Fatal("the snapshot names the configured key but its env does not deliver it") |
| 179 | } |
| 180 | } |
| 181 | |