| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/activator" |
| 12 | "github.com/carlosframework/platform/internal/appconfig" |
| 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/instancekey" |
| 17 | "github.com/carlosframework/platform/internal/registry" |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | ) |
| 20 | |
| 21 | // Ruling 26 on the claim path, which has no control socket before O6: a |
| 22 | // keyless replica is never selected (its helper would mint), a key that is |
| 23 | // not the home's is refused, and the home's key is selected and named. |
| 24 | func TestClaimReplicaLaunchIsGated(t *testing.T) { |
| 25 | t.Parallel() |
| 26 | homeKey := strings.Repeat("H", 43) + "=" |
| 27 | for _, tc := range []struct { |
| 28 | name, config string |
| 29 | refused bool |
| 30 | }{ |
| 31 | {"keyless", "", true}, |
| 32 | {"another key", activator.InstanceKeyVar + "=configured-B\n", true}, |
| 33 | {"the home's key", activator.InstanceKeyVar + "=" + homeKey + "\n", false}, |
| 34 | } { |
| 35 | t.Run(tc.name, func(t *testing.T) { |
| 36 | f := newAdminTokenFixture(t, "box-1") |
| 37 | f.rt.Replica = true |
| 38 | f.backend.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(homeKey), true } |
| 39 | f.backend.ConfiguredKey = func(context.Context, registry.Route) (string, error) { return instancekey.Digest(homeKey), nil } |
| 40 | own := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n" |
| 41 | if err := os.WriteFile(f.envPath(), []byte(own), 0o600); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | if tc.config != "" { |
| 45 | if err := os.WriteFile(filepath.Join(f.data, adminTokenHost+".config.env"), []byte(tc.config), 0o600); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | } |
| 49 | launch, err := claimServingLaunchSource(f.backend, f.tokens)(t.Context(), f.rt) |
| 50 | if tc.refused { |
| 51 | if !errors.Is(err, activator.ErrReplicaKey) { |
| 52 | t.Fatalf("launch source = %v, want ErrReplicaKey", err) |
| 53 | } |
| 54 | } else { |
| 55 | found := false |
| 56 | for _, kv := range launch.Env { |
| 57 | found = found || kv == activator.InstanceKeyVar+"="+homeKey |
| 58 | } |
| 59 | if err != nil || !found { |
| 60 | t.Fatalf("launch source = %v, key delivered %v", err, found) |
| 61 | } |
| 62 | } |
| 63 | if raw, _ := os.ReadFile(f.envPath()); string(raw) != own { |
| 64 | t.Fatal("a replica's claim launch changed its own env file: a replica never mints") |
| 65 | } |
| 66 | }) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // Final review I4: the claim's input policy asks whether a Work's identity |
| 71 | // still names this host's inputs, and launches nothing. It snapshots |
| 72 | // without the replica gate, so a configured-key replica costs it no bucket |
| 73 | // read, and its answer keeps the policy's contract (nil or ErrChanged), |
| 74 | // never a launch refusal. The launch source on the same backend runs the |
| 75 | // gate, and its fresh read, as before. |
| 76 | func TestClaimInputPolicyNeverReadsTheBucket(t *testing.T) { |
| 77 | homeKey := strings.Repeat("H", 43) + "=" |
| 78 | f := newAdminTokenFixture(t, "box-1") |
| 79 | f.rt.Replica = true |
| 80 | f.backend.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(homeKey), true } |
| 81 | reads := 0 |
| 82 | f.backend.ConfiguredKey = func(context.Context, registry.Route) (string, error) { |
| 83 | reads++ |
| 84 | return "", errors.New("bucket unreachable") // a gate that ran here would refuse |
| 85 | } |
| 86 | if err := os.WriteFile(f.envPath(), []byte("CARLOS_ADMIN_TOKEN="+strings.Repeat("c", 64)+"\n"), 0o600); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | if err := os.WriteFile(filepath.Join(f.data, adminTokenHost+".config.env"), []byte(activator.InstanceKeyVar+"="+homeKey+"\n"), 0o600); err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | snap, err := f.backend.SnapshotClaimLaunch(t.Context(), f.rt) |
| 93 | if err != nil { |
| 94 | t.Fatalf("snapshot: %v", err) |
| 95 | } |
| 96 | o, err := hostclaim.Read(t.Context(), f.claims, adminTokenHost) |
| 97 | if err != nil || o.Claim == nil || o.Claim.Holder == nil { |
| 98 | t.Fatalf("claim: %+v, %v", o, err) |
| 99 | } |
| 100 | h := o.Claim.Holder |
| 101 | sel := hostbox.Selection{Host: adminTokenHost, Captured: hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}} |
| 102 | policy := claimInputPolicy(func(string) (*registry.Route, error) { return &f.rt, nil }, f.claims, nil, f.backend) |
| 103 | if err := policy(t.Context(), o.Claim, sel, snap.Identity); err != nil { |
| 104 | t.Fatalf("the policy refused the snapshot's own identity: %v", err) |
| 105 | } |
| 106 | changed := snap.Identity |
| 107 | changed.Config = "0000" |
| 108 | if err := policy(t.Context(), o.Claim, sel, changed); !errors.Is(err, hostbox.ErrChanged) { |
| 109 | t.Fatalf("another identity = %v, want ErrChanged", err) |
| 110 | } |
| 111 | if reads != 0 { |
| 112 | t.Fatalf("the policy read the bucket %d times", reads) |
| 113 | } |
| 114 | if _, err := claimServingLaunchSource(f.backend, f.tokens)(t.Context(), f.rt); !errors.Is(err, activator.ErrReplicaKey) || reads != 1 { |
| 115 | t.Fatalf("the launch source = %v after %d bucket reads, want ErrReplicaKey after one", err, reads) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // Final review I1: the claim path's backend is built by claimExecBackend |
| 120 | // with the same readers the agent's backend gets, so a keyed replica |
| 121 | // whose configured key is listed and still the bucket's starts on the |
| 122 | // claim path (ruling 26), and a first wake asks the bucket before it mints |
| 123 | // (ruling 5). The fixture's backend is not used: the readers are the ones |
| 124 | // the production constructor wires, built from a box of this test's. |
| 125 | func TestClaimBackendIsWiredWithTheKeyReaders(t *testing.T) { |
| 126 | homeKey := strings.Repeat("H", 43) + "=" |
| 127 | f := newAdminTokenFixture(t, "box-1") |
| 128 | f.rt.Replica, f.rt.Account, f.rt.App = true, "bab", "jam" |
| 129 | bucket := release.NewDirStore(t.TempDir()) |
| 130 | priv, _, err := appconfig.GenerateKey() |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | if err := appconfig.SetEnv(t.Context(), bucket, "bab", "jam", map[string]string{instancekey.Var: homeKey}); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | list := []byte(adminTokenHost + "=" + instancekey.Digest(homeKey) + "\n") |
| 138 | keys := newInstanceKeyReaders( |
| 139 | func(k string) string { |
| 140 | if k == "CARLOS_SECRETS_KEY" { |
| 141 | return priv |
| 142 | } |
| 143 | return "" |
| 144 | }, |
| 145 | func(context.Context) (release.Store, error) { return bucket, nil }, |
| 146 | func() ([]byte, error) { return list, nil }) |
| 147 | backend := claimExecBackend(ClaimRuntimeResources{DataDir: f.backend.DataDir, OptDir: f.backend.OptDir}, keys) |
| 148 | if backend.ReplicaHomeKeys == nil || backend.ConfiguredKey == nil { |
| 149 | t.Fatal("claimExecBackend left a reader nil") |
| 150 | } |
| 151 | if err := os.WriteFile(f.envPath(), []byte("CARLOS_ADMIN_TOKEN="+strings.Repeat("c", 64)+"\n"), 0o600); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | if err := os.WriteFile(filepath.Join(f.data, adminTokenHost+".config.env"), []byte(activator.InstanceKeyVar+"="+homeKey+"\n"), 0o600); err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | launch := claimServingLaunchSource(backend, f.tokens) |
| 158 | got, err := launch(t.Context(), f.rt) |
| 159 | if err != nil { |
| 160 | t.Fatalf("a listed replica on the claim path: %v", err) |
| 161 | } |
| 162 | found := false |
| 163 | for _, kv := range got.Env { |
| 164 | found = found || kv == activator.InstanceKeyVar+"="+homeKey |
| 165 | } |
| 166 | if !found { |
| 167 | t.Fatal("the replica's launch does not deliver its home's key") |
| 168 | } |
| 169 | // The list no longer names it: refused, by the reader the constructor wired. |
| 170 | list = nil |
| 171 | if _, err := launch(t.Context(), f.rt); !errors.Is(err, activator.ErrReplicaKey) { |
| 172 | t.Fatalf("an unlisted replica = %v, want ErrReplicaKey", err) |
| 173 | } |
| 174 | // A first wake of a home row whose configuration sets a key this box |
| 175 | // has not delivered is deferred by the same backend (ruling 5). |
| 176 | f.rt.Replica = false |
| 177 | if err := os.Remove(filepath.Join(f.data, adminTokenHost+".config.env")); err != nil { |
| 178 | t.Fatal(err) |
| 179 | } |
| 180 | if _, err := launch(t.Context(), f.rt); !errors.Is(err, activator.ErrInstanceKeyDeferred) { |
| 181 | t.Fatalf("a first wake before delivery = %v, want ErrInstanceKeyDeferred", err) |
| 182 | } |
| 183 | prod := productionInstanceKeyReaders() |
| 184 | if prod.HomeKeys == nil || prod.Configured == nil { |
| 185 | t.Fatal("the production readers are not both built") |
| 186 | } |
| 187 | } |
| 188 | |