| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/base64" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "github.com/carlosframework/platform/internal/activator" |
| 9 | "github.com/carlosframework/platform/internal/genselect" |
| 10 | "os" |
| 11 | "os/exec" |
| 12 | "path/filepath" |
| 13 | "reflect" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/carlosframework/platform/internal/hostbox" |
| 19 | "github.com/carlosframework/platform/internal/hostclaim" |
| 20 | "github.com/carlosframework/platform/internal/hostguard" |
| 21 | "github.com/carlosframework/platform/internal/hostwrite" |
| 22 | "github.com/carlosframework/platform/internal/moves" |
| 23 | "github.com/carlosframework/platform/internal/provision" |
| 24 | "github.com/carlosframework/platform/internal/registry" |
| 25 | ) |
| 26 | |
| 27 | func TestPlan10G2ReadAccess(t *testing.T) { |
| 28 | t.Parallel() |
| 29 | f := newAdminTokenFixture(t, "box-1") |
| 30 | ctx := t.Context() |
| 31 | o, err := hostclaim.Read(ctx, f.claims, adminTokenHost) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | o, err = hostclaim.Update(ctx, f.claims, o, func(c *hostclaim.Claim) error { |
| 36 | c.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: "m1", Phase: hostclaim.PhaseProbing, Exec: "box-1", Participants: []string{"box-0", "box-1"}, Intent: map[string]string{"source": "box-0", "target": "box-1", "to": "ie"}} |
| 37 | return nil |
| 38 | }) |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | if err = f.tokens.Guard.Activate(ctx, adminTokenHost, "box-1", nil); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | captured, err := hostguard.Capture(f.data, adminTokenHost) |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 50 | if err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | defer reg.Close() |
| 54 | if err = reg.Upsert(f.rt); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | reads, launches, prepared := 0, 0, 0 |
| 58 | original := ClaimServingLaunch{Args: []string{"arg"}, Env: []string{"A=B"}} |
| 59 | report := moves.EnvReport{Vars: map[string]string{"A": "hash"}} |
| 60 | a := &claimMoveReadAccess{actor: f.actor, check: claimBoundIdentity(f.verifyActor, f.actor), route: func(host string) (*registry.Route, error) { reads++; return reg.Get(host) }, launch: func(context.Context, registry.Route) (ClaimServingLaunch, moves.EnvReport, error) { |
| 61 | launches++ |
| 62 | return original, report, nil |
| 63 | }} |
| 64 | checkAll := func(want error) { |
| 65 | t.Helper() |
| 66 | if _, err := a.Route(ctx, adminTokenHost); !errors.Is(err, want) { |
| 67 | t.Fatalf("route: %v", err) |
| 68 | } |
| 69 | if _, _, err := a.Launch(ctx, adminTokenHost); !errors.Is(err, want) { |
| 70 | t.Fatalf("launch: %v", err) |
| 71 | } |
| 72 | if _, err := a.HeldWork(ctx, adminTokenHost, "held-config", captured); !errors.Is(err, want) { |
| 73 | t.Fatalf("work: %v", err) |
| 74 | } |
| 75 | } |
| 76 | checkAll(hostwrite.ErrUnavailable) |
| 77 | if reads != 0 || launches != 0 { |
| 78 | t.Fatal("access before Build") |
| 79 | } |
| 80 | source := hostbox.WorkSource{Guard: f.tokens.Guard, Box: f.actor.Box, Region: f.actor.Region, Now: time.Now, Policy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { return nil }, Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil }, Prepare: func(context.Context, hostclaim.Observed) error { prepared++; return nil }} |
| 81 | f.verify = hostbox.ErrChanged |
| 82 | if err = a.bind(ctx, source); !errors.Is(err, hostbox.ErrChanged) { |
| 83 | t.Fatal("binding without verification", err) |
| 84 | } |
| 85 | checkAll(hostwrite.ErrUnavailable) |
| 86 | f.verify = nil |
| 87 | if err = a.bind(ctx, source); err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | if _, err = a.HeldWork(ctx, adminTokenHost, "held-config", captured); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | if prepared != 0 { |
| 94 | t.Fatal("read-only selection called Prepare") |
| 95 | } |
| 96 | for _, action := range []string{"spawn", "install", "held-timers", "delete"} { |
| 97 | if _, err = a.HeldWork(ctx, adminTokenHost, action, captured); !errors.Is(err, hostbox.ErrUnsupported) { |
| 98 | t.Fatalf("action %s: %v", action, err) |
| 99 | } |
| 100 | } |
| 101 | rt, err := a.Route(ctx, adminTokenHost) |
| 102 | if err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | rt.DBPath = "foreign" |
| 106 | stored, err := reg.Get(adminTokenHost) |
| 107 | if err != nil || stored.DBPath != f.rt.DBPath { |
| 108 | t.Fatal("route read changed registry", err) |
| 109 | } |
| 110 | launch, got, err := a.Launch(ctx, adminTokenHost) |
| 111 | if err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | launch.Env[0] = "changed" |
| 115 | got.Vars["A"] = "changed" |
| 116 | if original.Env[0] != "A=B" || report.Vars["A"] != "hash" { |
| 117 | t.Fatal("accessor aliases owner data") |
| 118 | } |
| 119 | // A present host executed by another box is foreign too. |
| 120 | current, err := hostclaim.Read(ctx, f.claims, adminTokenHost) |
| 121 | if err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | current, err = hostclaim.Update(ctx, f.claims, current, func(c *hostclaim.Claim) error { c.Op.Exec = "box-2"; return nil }) |
| 125 | if err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | checkAll(hostbox.ErrChanged) |
| 129 | if _, err = hostclaim.Update(ctx, f.claims, current, func(c *hostclaim.Claim) error { c.Op.Exec = "box-1"; return nil }); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | previous := reads |
| 133 | if _, err = a.Route(ctx, "foreign.example"); !errors.Is(err, hostbox.ErrChanged) { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | if _, _, err = a.Launch(ctx, "foreign.example"); !errors.Is(err, hostbox.ErrChanged) { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | if _, err = a.HeldWork(ctx, "foreign.example", "held-config", captured); !errors.Is(err, hostbox.ErrChanged) { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | if reads != previous { |
| 143 | t.Fatal("foreign host reached registry") |
| 144 | } |
| 145 | work, err := a.HeldWork(ctx, adminTokenHost, "held-release", captured) |
| 146 | if err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | f.verify = hostbox.ErrChanged |
| 150 | checkAll(hostbox.ErrChanged) |
| 151 | if err = work.With(ctx, hostwrite.Identity{}, func() error { t.Fatal("effect after lost identity"); return nil }); !errors.Is(err, hostbox.ErrChanged) { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | if reads != previous { |
| 155 | t.Fatal("unverified identity reached registry") |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func TestPlan10G2LaunchReadOnlyAndSameReport(t *testing.T) { |
| 160 | f := newAdminTokenFixture(t, "box-1") |
| 161 | source := claimMoveLaunchSource(f.backend) |
| 162 | if _, _, err := source(t.Context(), f.rt); err == nil { |
| 163 | t.Fatal("missing token accepted") |
| 164 | } |
| 165 | if _, err := os.Stat(f.envPath()); !errors.Is(err, os.ErrNotExist) { |
| 166 | t.Fatal("read created token", err) |
| 167 | } |
| 168 | t.Setenv("CARLOS_SECRETS_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32))) |
| 169 | if err := os.WriteFile(f.envPath(), []byte("CARLOS_ADMIN_TOKEN=existing\nSECRET=value\nCARLOS_MOVE_PROBE=1\n"), 0600); err != nil { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | before, err := os.ReadFile(f.envPath()) |
| 173 | if err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | launch, report, err := source(t.Context(), f.rt) |
| 177 | if err != nil { |
| 178 | t.Fatal(err) |
| 179 | } |
| 180 | after, _ := os.ReadFile(f.envPath()) |
| 181 | if !reflect.DeepEqual(before, after) { |
| 182 | t.Fatal("launch read wrote environment") |
| 183 | } |
| 184 | env := map[string]string{} |
| 185 | for _, kv := range launch.Env { |
| 186 | k, v, ok := strings.Cut(kv, "=") |
| 187 | if ok { |
| 188 | env[k] = v |
| 189 | } |
| 190 | } |
| 191 | if env["CARLOS_CONTROL_SOCKET"] != f.rt.Socket+".control" { |
| 192 | t.Fatal("wrong role environment") |
| 193 | } |
| 194 | if _, ok := env["CARLOS_MOVE_PROBE"]; ok { |
| 195 | t.Fatal("probe-only input included in pin") |
| 196 | } |
| 197 | key, err := moves.ReportKeyFromSecrets(os.Getenv("CARLOS_SECRETS_KEY")) |
| 198 | if err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | if !reflect.DeepEqual(report, moves.ReportEnv(key, env)) { |
| 202 | t.Fatal("report is not the returned launch environment") |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | // Count dispatches while using main's concrete move pin checker. |
| 207 | type plan10InputOwner struct { |
| 208 | *movesProbeOwners |
| 209 | calls int |
| 210 | } |
| 211 | |
| 212 | func (m *plan10InputOwner) InputPolicy(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error { |
| 213 | m.calls++ |
| 214 | return m.movesProbeOwners.InputPolicy(ctx, c, s, id) |
| 215 | } |
| 216 | |
| 217 | func TestPlan10CommittedInputDispatch(t *testing.T) { |
| 218 | reader := &mpReader{rec: mpRecord()} |
| 219 | reader.rec.Move.State = provision.MoveCommitted |
| 220 | reader.rec.Move.Inputs.EnvReport = "env2:sha256:report" // a v2 pin (moves.CurrentEnvPin) |
| 221 | owner := &plan10InputOwner{movesProbeOwners: &movesProbeOwners{Records: reader.read}} |
| 222 | serving := 0 |
| 223 | legacy := func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { |
| 224 | serving++ |
| 225 | return hostbox.ErrChanged |
| 226 | } |
| 227 | out := ClaimOwnerBindings{InputPolicy: legacy, MovesPolicy: MovesClaimPolicy(reader.read), Moves: ClaimMovesOwners{ClaimKindOwners: ClaimKindOwners{Inputs: owner}}} |
| 228 | bindProductionClaimInputPolicy(func(string) string { return "1" }, &out, reader.read) |
| 229 | policy := hostbox.Policy{Inputs: out.InputPolicy, Moves: out.MovesPolicy, Held: func(context.Context, string) (string, error) { return "", nil }} |
| 230 | c := mpClaim(hostclaim.KindLanding, hostclaim.PhaseLandingCommitted) |
| 231 | c.Holder.WriterBox = mpTarget |
| 232 | s := mpSelection(c, "spawn") |
| 233 | id := pinnedLaunchIdentity(reader.rec.Move.Inputs) |
| 234 | if err := hostbox.WakePhase(c, mpTarget, false); err != nil { |
| 235 | t.Fatal("cold wake phase", err) |
| 236 | } |
| 237 | if err := policy.Check(t.Context(), c, s, id); err != nil { |
| 238 | t.Fatal("matching committed cold-spawn inputs", err) |
| 239 | } |
| 240 | for _, bad := range []hostwrite.Identity{{Config: "different", Release: id.Release}, {Config: id.Config, Release: "different"}} { |
| 241 | if err := policy.Check(t.Context(), c, s, bad); err == nil { |
| 242 | t.Fatal("mismatching committed inputs accepted") |
| 243 | } |
| 244 | } |
| 245 | if owner.calls != 3 || serving != 0 { |
| 246 | t.Fatalf("moves=%d serving=%d", owner.calls, serving) |
| 247 | } |
| 248 | for _, kind := range []string{hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindReverting, hostclaim.KindRestoring} { |
| 249 | c.Op.Kind = kind |
| 250 | s.Kind = kind |
| 251 | before := owner.calls |
| 252 | _ = out.InputPolicy(t.Context(), c, s, id) |
| 253 | if owner.calls != before+1 || serving != 0 { |
| 254 | t.Fatalf("%s fell back", kind) |
| 255 | } |
| 256 | } |
| 257 | c.Op.Kind = hostclaim.KindServing |
| 258 | s.Kind = c.Op.Kind |
| 259 | _ = out.InputPolicy(t.Context(), c, s, id) |
| 260 | if serving != 1 { |
| 261 | t.Fatal("serving lost own check") |
| 262 | } |
| 263 | c.Op.Kind = hostclaim.KindLanding |
| 264 | s.Kind = c.Op.Kind |
| 265 | missing := ClaimOwnerDispatch{Serving: ClaimKindOwners{InputPolicy: legacy}} |
| 266 | if err := missing.InputPolicy(t.Context(), c, s, id); err == nil || serving != 1 { |
| 267 | t.Fatal("missing move owner fell back") |
| 268 | } |
| 269 | for _, off := range []string{"CARLOS_HOST_CLAIM", "CARLOS_MOVES_PHASE1"} { |
| 270 | legacyOut := ClaimOwnerBindings{InputPolicy: legacy, MovesPolicy: MovesClaimPolicy(reader.read), Moves: out.Moves} |
| 271 | bindProductionClaimInputPolicy(func(k string) string { |
| 272 | if k == off { |
| 273 | return "" |
| 274 | } |
| 275 | return "1" |
| 276 | }, &legacyOut, reader.read) |
| 277 | if reflect.ValueOf(legacyOut.InputPolicy).Pointer() != reflect.ValueOf(legacy).Pointer() { |
| 278 | t.Fatal("flag-off callback changed") |
| 279 | } |
| 280 | if err := legacyOut.MovesPolicy(t.Context(), c, s, id); err == nil { |
| 281 | t.Fatal("flag-off committed spawn granted") |
| 282 | } |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | type plan10PinnedInputs struct { |
| 287 | plan9D2Inputs |
| 288 | identity hostwrite.Identity |
| 289 | } |
| 290 | |
| 291 | func (p plan10PinnedInputs) Select(ctx context.Context, rt registry.Route, o hostclaim.Observed, policy activator.StartPolicy) (ClaimLaunchInputs, error) { |
| 292 | in, err := p.plan9D2Inputs.Select(ctx, rt, o, policy) |
| 293 | in.Identity = p.identity |
| 294 | return in, err |
| 295 | } |
| 296 | |
| 297 | func TestPlan10CommittedColdAdmissionInputs(t *testing.T) { |
| 298 | for _, mismatch := range []bool{false, true} { |
| 299 | t.Run(fmt.Sprint(mismatch), func(t *testing.T) { |
| 300 | f := newRecoveryFixture(t) |
| 301 | f.recordPrelaunch = true |
| 302 | ctx := t.Context() |
| 303 | o, err := hostclaim.Update(ctx, f.claims, f.read(), func(c *hostclaim.Claim) error { |
| 304 | c.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: "m1", Phase: hostclaim.PhaseLandingCommitted, Exec: "box-1", Participants: []string{"box-0", "box-1"}, Intent: map[string]string{"to": "ie", "source": "box-0", "target": "box-1"}} |
| 305 | return nil |
| 306 | }) |
| 307 | if err != nil { |
| 308 | t.Fatal(err) |
| 309 | } |
| 310 | rec := &provision.Record{Host: recoveryHost, Move: &provision.MoveState{ID: "m1", Incarnation: o.Claim.Holder.Incarnation, State: provision.MoveCommitted, Inputs: mpPins()}} |
| 311 | rec.Move.Inputs.EnvReport = "env2:sha256:report" // a v2 pin (moves.CurrentEnvPin) |
| 312 | read := MovesRecordReader(func(context.Context, string, string, string) (*provision.Record, error) { return rec, nil }) |
| 313 | owner := &plan10InputOwner{movesProbeOwners: &movesProbeOwners{Records: read}} |
| 314 | bindings := ClaimOwnerBindings{InputPolicy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { |
| 315 | t.Fatal("serving fallback") |
| 316 | return nil |
| 317 | }, Moves: ClaimMovesOwners{ClaimKindOwners: ClaimKindOwners{Inputs: owner}}} |
| 318 | bindProductionClaimInputPolicy(func(string) string { return "1" }, &bindings, read) |
| 319 | policy := hostbox.Policy{Inputs: bindings.InputPolicy, Moves: bindings.MovesPolicy, Held: func(context.Context, string) (string, error) { return "", nil }} |
| 320 | c := f.controller("boot-1", "controller-1", 4101, "start-1") |
| 321 | id := pinnedLaunchIdentity(rec.Move.Inputs) |
| 322 | if mismatch { |
| 323 | id.Release = "different" |
| 324 | } |
| 325 | c.wake.Inputs = plan10PinnedInputs{plan9D2Inputs{recoveryInputs{f}}, id} |
| 326 | c.wake.Continue = hostclaim.SessionContinues |
| 327 | c.wake.Operations = &plan9D2Attempts{} |
| 328 | c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil } |
| 329 | source := hostbox.WorkSource{Guard: f.guard, Box: "box-1", Region: "ie", Now: func() time.Time { return f.now }, Policy: policy.Check, Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil }} |
| 330 | writer := hostbox.Writer{Claims: f.claims, Box: "box-1", Region: "ie", AdmissionEvidence: func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error { return nil }} |
| 331 | c.wake.mu.Lock() |
| 332 | c.wake.pending = nil |
| 333 | c.wake.mu.Unlock() |
| 334 | runtime, err := c.wake.Build(ctx, source, writer) |
| 335 | if err != nil { |
| 336 | t.Fatal(err) |
| 337 | } |
| 338 | live, cancel := context.WithCancel(ctx) |
| 339 | defer cancel() |
| 340 | done := make(chan struct{}) |
| 341 | go func() { <-live.Done(); close(done) }() |
| 342 | c.wake.mu.Lock() |
| 343 | c.wake.pending[recoveryHost] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done} |
| 344 | c.wake.mu.Unlock() |
| 345 | admitted, err := runtime.Admit(ctx, registry.Route{Host: recoveryHost}, o, activator.StartPolicy{MaxStarts: 5, Window: 30 * time.Second}) |
| 346 | if mismatch { |
| 347 | if err == nil { |
| 348 | t.Fatal("mismatching committed cold admission accepted") |
| 349 | } |
| 350 | } else if err != nil { |
| 351 | t.Fatal(err) |
| 352 | } else if admitted.Claim.Holder.Session <= o.Claim.Holder.Session || admitted.Claim.Holder.Generation == o.Claim.Holder.Generation { |
| 353 | t.Fatal("not a cold admission") |
| 354 | } |
| 355 | if owner.calls == 0 { |
| 356 | t.Fatal("admission bypassed moves input check") |
| 357 | } |
| 358 | }) |
| 359 | } |
| 360 | } |
| 361 | |