| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/caps" |
| 11 | "github.com/carlosframework/platform/internal/hostbox" |
| 12 | "github.com/carlosframework/platform/internal/hostclaim" |
| 13 | "github.com/carlosframework/platform/internal/hostguard" |
| 14 | "github.com/carlosframework/platform/internal/hostwrite" |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | "github.com/carlosframework/platform/internal/rootstate" |
| 17 | ) |
| 18 | |
| 19 | func TestPlan14UnitShapeAllOperationKinds(t *testing.T) { |
| 20 | t.Parallel() |
| 21 | for _, unit := range []string{"console.service", "woodstar-socket.service", "carlos-app@shop.example.service"} { |
| 22 | t.Run(unit, func(t *testing.T) { |
| 23 | b, in, shape := plan14UnitBindingFixture(t, unit, "", "") |
| 24 | if strings.HasPrefix(unit, "carlos-app@") { |
| 25 | shape.Backing = "unit" |
| 26 | } |
| 27 | if unit == "woodstar-socket.service" { |
| 28 | shape.Addr = "" |
| 29 | shape.Socket = "/run/carlos/woodstar.sock" |
| 30 | } |
| 31 | b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil } |
| 32 | if _, err := b.establish(t.Context(), in.Host); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | candidate := shape.route() |
| 36 | read := func(ctx context.Context, rt registry.Route, s hostbox.Selection) error { |
| 37 | return checkClaimUnitShape(ctx, b, map[string]claimUnitShape{in.Host: shape}, rt, s) |
| 38 | } |
| 39 | refuse := claimUnitShapePolicy(func(string) (*registry.Route, error) { return &candidate, nil }, read) |
| 40 | for _, kind := range []string{hostclaim.KindServing, hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindReverting, hostclaim.KindRestoring, hostclaim.KindRelocating} { |
| 41 | for _, action := range []string{"install", "restore", "spawn", "writer", "config", "release", "timers", "held-config", "held-release", "held-timers", "probe"} { |
| 42 | s := hostbox.Selection{Host: in.Host, Box: in.Box, Captured: hostguard.Captured{Incarnation: in.Incarnation, Stamp: in.Stamp}, Action: action, Kind: kind} |
| 43 | c := &hostclaim.Claim{Host: in.Host, Op: hostclaim.Op{Kind: kind}} |
| 44 | candidate = shape.route() |
| 45 | if err := refuse(t.Context(), c, s, hostwrite.Identity{}); err != nil { |
| 46 | t.Fatal(kind, action, err) |
| 47 | } |
| 48 | for _, mutate := range []func(*registry.Route){ |
| 49 | func(r *registry.Route) { r.Unit = ""; r.Backing = "" }, func(r *registry.Route) { r.Unit = "sibling.service" }, func(r *registry.Route) { r.Backing = "exec" }, |
| 50 | func(r *registry.Route) { r.Account = "other" }, func(r *registry.Route) { r.App = "other" }, func(r *registry.Route) { r.Kind = "sidecar" }, |
| 51 | func(r *registry.Route) { r.DBPath = "/data/sibling.db" }, func(r *registry.Route) { r.Socket = "/run/carlos/sibling.sock" }, func(r *registry.Route) { r.Addr = "127.0.0.1:9999" }, |
| 52 | func(r *registry.Route) { r.Tunnel = "other" }, func(r *registry.Route) { r.Replica = true }, func(r *registry.Route) { r.Seed = "seed" }, |
| 53 | } { |
| 54 | candidate = shape.route() |
| 55 | mutate(&candidate) |
| 56 | if err := refuse(t.Context(), c, s, hostwrite.Identity{}); err == nil { |
| 57 | t.Fatal("shape edit reached operation", kind, action, candidate) |
| 58 | } |
| 59 | } |
| 60 | } |
| 61 | } |
| 62 | s := hostbox.Selection{Host: in.Host, Box: in.Box, Captured: hostguard.Captured{Incarnation: in.Incarnation, Stamp: in.Stamp + 1}} |
| 63 | candidate = shape.route() |
| 64 | if err := refuse(t.Context(), nil, s, hostwrite.Identity{}); err == nil { |
| 65 | t.Fatal("successor used predecessor binding") |
| 66 | } |
| 67 | sibling := registry.Route{Host: "sibling.example", Kind: "instance"} |
| 68 | if err := checkClaimUnitShape(t.Context(), b, map[string]claimUnitShape{in.Host: shape}, sibling, hostbox.Selection{Host: sibling.Host}); err != nil { |
| 69 | t.Fatal("ordinary sibling refused", err) |
| 70 | } |
| 71 | }) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | func TestPlan14BoundCapabilitiesRefuseEffects(t *testing.T) { |
| 76 | b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "") |
| 77 | if _, err := b.establish(t.Context(), in.Host); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | oldOpen, oldShapes := openClaimUnitBindingsRead, productionClaimUnitShapes |
| 81 | defer func() { openClaimUnitBindingsRead, productionClaimUnitShapes = oldOpen, oldShapes }() |
| 82 | openClaimUnitBindingsRead = func() (rootstate.Store, error) { return b.state, nil } |
| 83 | for _, config := range []string{"good", "removed", "corrupt"} { |
| 84 | t.Run(config, func(t *testing.T) { |
| 85 | productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { |
| 86 | if config == "corrupt" { |
| 87 | return nil, os.ErrPermission |
| 88 | } |
| 89 | if config == "removed" { |
| 90 | return map[string]claimUnitShape{}, nil |
| 91 | } |
| 92 | return map[string]claimUnitShape{in.Host: shape}, nil |
| 93 | } |
| 94 | dir := t.TempDir() |
| 95 | ops := productionClaimCapsOps(caps.Ops{EtcDir: dir, Systemctl: func(...string) error { t.Fatal("bound capability reached systemctl"); return nil }}, func() (bool, error) { return false, nil }) |
| 96 | if _, err := caps.Converge(ops, caps.Plan{Host: in.Host}); err == nil { |
| 97 | t.Fatal("converge accepted bound host") |
| 98 | } |
| 99 | if err := caps.Restore(ops, caps.Snapshot{Host: in.Host}); err == nil { |
| 100 | t.Fatal("restore accepted bound host") |
| 101 | } |
| 102 | if err := caps.Restart(ops, caps.Plan{Host: in.Host}); err == nil { |
| 103 | t.Fatal("restart accepted bound host") |
| 104 | } |
| 105 | entries, err := os.ReadDir(dir) |
| 106 | if err != nil || len(entries) != 0 { |
| 107 | t.Fatal("wrote capability dropin", entries, err) |
| 108 | } |
| 109 | if err := claimBoundCapsRefusal("ordinary.example"); err != nil { |
| 110 | t.Fatal("ordinary host refused", err) |
| 111 | } |
| 112 | }) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func TestPlan14BoundCapabilitiesNoCensus(t *testing.T) { |
| 117 | oldOpen, oldShapes := openClaimUnitBindingsRead, productionClaimUnitShapes |
| 118 | defer func() { openClaimUnitBindingsRead, productionClaimUnitShapes = oldOpen, oldShapes }() |
| 119 | openClaimUnitBindingsRead = func() (rootstate.Store, error) { t.Fatal("no-census binding read"); return nil, os.ErrPermission } |
| 120 | productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { |
| 121 | t.Fatal("no-census config read") |
| 122 | return nil, os.ErrPermission |
| 123 | } |
| 124 | calls := 0 |
| 125 | prior := caps.Ops{CheckHost: func(string) error { calls++; return nil }} |
| 126 | if err := productionClaimCapsOps(prior, func() (bool, error) { return true, nil }).CheckHost("console.example"); err != nil || calls != 1 { |
| 127 | t.Fatal(err, calls) |
| 128 | } |
| 129 | if err := productionClaimCapsOps(prior, func() (bool, error) { return false, os.ErrPermission }).CheckHost("console.example"); err == nil || calls != 1 { |
| 130 | t.Fatal("census error reached effect", err, calls) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func TestPlan14UnitShapePolicyProductionModes(t *testing.T) { |
| 135 | b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "") |
| 136 | if _, err := b.establish(t.Context(), in.Host); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | oldOpen, oldShapes := openClaimUnitBindingsRead, productionClaimUnitShapes |
| 140 | defer func() { openClaimUnitBindingsRead, productionClaimUnitShapes = oldOpen, oldShapes }() |
| 141 | opens := 0 |
| 142 | openClaimUnitBindingsRead = func() (rootstate.Store, error) { opens++; return b.state, nil } |
| 143 | productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { return map[string]claimUnitShape{in.Host: shape}, nil } |
| 144 | for _, mode := range []string{"instance-control", "adopt"} { |
| 145 | row := shape.route() |
| 146 | p := productionClaimUnitShapePolicy(mode, func(string) (*registry.Route, error) { return &row, nil }) |
| 147 | s := hostbox.Selection{Host: in.Host, Box: in.Box, Captured: hostguard.Captured{Incarnation: in.Incarnation, Stamp: in.Stamp}} |
| 148 | if err := p(t.Context(), nil, s, hostwrite.Identity{}); err != nil { |
| 149 | t.Fatal(mode, err) |
| 150 | } |
| 151 | row.Unit = "" |
| 152 | if err := p(t.Context(), nil, s, hostwrite.Identity{}); err == nil { |
| 153 | t.Fatal(mode, "cleared binding") |
| 154 | } |
| 155 | } |
| 156 | if opens != 4 { |
| 157 | t.Fatal("root did not read independent binding", opens) |
| 158 | } |
| 159 | openClaimUnitBindingsRead = func() (rootstate.Store, error) { return nil, os.ErrPermission } |
| 160 | p := productionClaimUnitShapePolicy("adopt", func(string) (*registry.Route, error) { r := shape.route(); return &r, nil }) |
| 161 | if p(t.Context(), nil, hostbox.Selection{Host: in.Host}, hostwrite.Identity{}) == nil { |
| 162 | t.Fatal("unavailable root read permitted") |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | func TestPlan14BoundRestartShapeRefusal(t *testing.T) { |
| 167 | for _, op := range []string{"restart", "rollback", "checked"} { |
| 168 | for _, shape := range []string{"unchanged", "cleared", "ordinary", "sibling", "backing", "socket", "missing"} { |
| 169 | t.Run(op+"/"+shape, func(t *testing.T) { |
| 170 | reg := restartTestRegistry(t) |
| 171 | b, in, bound := plan14UnitBindingFixture(t, "console.service", "", "") |
| 172 | if _, err := b.establish(t.Context(), in.Host); err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | row := bound.route() |
| 176 | switch shape { |
| 177 | case "cleared": |
| 178 | row.Unit = "" |
| 179 | case "ordinary": |
| 180 | row.Unit = "carlos-app@" + in.Host + ".service" |
| 181 | case "sibling": |
| 182 | row.Unit = "sibling.service" |
| 183 | case "backing": |
| 184 | row.Backing = "unit" |
| 185 | case "socket": |
| 186 | row.Socket = "/run/carlos/other.sock" |
| 187 | } |
| 188 | if shape != "missing" { |
| 189 | if err := reg.Upsert(row); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | } |
| 193 | calls := 0 |
| 194 | oldOpen, oldShapes := openClaimUnitBindingsRead, productionClaimUnitShapes |
| 195 | defer func() { openClaimUnitBindingsRead, productionClaimUnitShapes = oldOpen, oldShapes }() |
| 196 | openClaimUnitBindingsRead = func() (rootstate.Store, error) { return b.state, nil } |
| 197 | productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { return map[string]claimUnitShape{in.Host: bound}, nil } |
| 198 | checks := rootRestartChecks{unitShape: func(ctx context.Context, host string, rt *registry.Route) error { |
| 199 | calls++ |
| 200 | return rootClaimRestartShape(ctx, host, rt) |
| 201 | }} |
| 202 | sys := &fakeUnits{} |
| 203 | var err error |
| 204 | switch op { |
| 205 | case "restart": |
| 206 | err = rootRestartRouteWith(reg, sys, t.TempDir(), func(string) string { return "" }, nil, checks)(in.Host) |
| 207 | case "rollback": |
| 208 | err = rollbackUnitRestart(reg, sys, t.TempDir(), func(string) string { return "" }, checks)(in.Host) |
| 209 | case "checked": |
| 210 | if shape == "missing" { |
| 211 | row = registry.Route{Host: in.Host} |
| 212 | } |
| 213 | err = checkedUnitRestart(sys, t.TempDir(), func(string) string { return "" }, nil, checks)(row) |
| 214 | } |
| 215 | if err == nil || calls != 1 || len(sys.restarts) != 0 { |
| 216 | t.Fatal("shape bypass", err, calls, sys.restarts) |
| 217 | } |
| 218 | }) |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | // A fresh install has no registry row until registerRemote's second |
| 224 | // work.With writes it, and the unit-shape refusal runs on every work.With |
| 225 | // before that (harness G10, 2026-10-21). Only the installation's own work |
| 226 | // (hostbox.Selection.Installation) may go on without a row, and then the |
| 227 | // refusal checks an empty route for the host: it passes only when the host |
| 228 | // has no configured shape and no saved unit binding. Any other work refuses |
| 229 | // a missing row (fix round 2). |
| 230 | func TestClaimUnitShapePolicyFreshInstall(t *testing.T) { |
| 231 | oldOpen, oldShapes := openClaimUnitBindingsRead, productionClaimUnitShapes |
| 232 | defer func() { openClaimUnitBindingsRead, productionClaimUnitShapes = oldOpen, oldShapes }() |
| 233 | for _, tc := range []struct { |
| 234 | name string |
| 235 | row string // "", "other", "matching", "ordinary" |
| 236 | shape bool |
| 237 | binding bool |
| 238 | storeless bool |
| 239 | want error // for the installation's own work |
| 240 | }{ |
| 241 | {name: "no row, no shape, no binding", want: nil}, |
| 242 | {name: "no row, no binding store", storeless: true, want: nil}, |
| 243 | {name: "no row, configured shape", shape: true, want: hostbox.ErrUnsupported}, |
| 244 | {name: "no row, saved binding", shape: true, binding: true, want: hostbox.ErrChanged}, |
| 245 | {name: "no row, saved binding, shape removed", binding: true, want: hostbox.ErrChanged}, |
| 246 | {name: "row for another host", row: "other", want: hostbox.ErrChanged}, |
| 247 | {name: "matching row", row: "matching", shape: true, binding: true, want: nil}, |
| 248 | {name: "matching ordinary row", row: "ordinary", want: nil}, |
| 249 | } { |
| 250 | t.Run(tc.name, func(t *testing.T) { |
| 251 | b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "") |
| 252 | if tc.binding { |
| 253 | if _, err := b.establish(t.Context(), in.Host); err != nil { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | } |
| 257 | openClaimUnitBindingsRead = func() (rootstate.Store, error) { |
| 258 | if tc.storeless { |
| 259 | return nil, os.ErrNotExist |
| 260 | } |
| 261 | return b.state, nil |
| 262 | } |
| 263 | productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { |
| 264 | if tc.shape { |
| 265 | return map[string]claimUnitShape{in.Host: shape}, nil |
| 266 | } |
| 267 | return map[string]claimUnitShape{}, nil |
| 268 | } |
| 269 | var row *registry.Route |
| 270 | switch tc.row { |
| 271 | case "other": |
| 272 | r := registry.Route{Host: "sibling.example", Kind: "instance"} |
| 273 | row = &r |
| 274 | case "matching": |
| 275 | r := shape.route() |
| 276 | row = &r |
| 277 | case "ordinary": |
| 278 | r := registry.Route{Host: in.Host, Account: in.Account, App: in.App, Kind: "instance", DBPath: "/data/instances/shop/data.db", Addr: "127.0.0.1:8090"} |
| 279 | row = &r |
| 280 | } |
| 281 | lookups := 0 |
| 282 | route := func(host string) (*registry.Route, error) { |
| 283 | lookups++ |
| 284 | if host != in.Host { |
| 285 | t.Fatal("looked up", host) |
| 286 | } |
| 287 | return row, nil |
| 288 | } |
| 289 | s := hostbox.Selection{Host: in.Host, Box: in.Box, Action: "reconcile", Kind: hostclaim.KindServing, Captured: hostguard.Captured{Incarnation: in.Incarnation, Stamp: in.Stamp}} |
| 290 | if s.Installation() { |
| 291 | t.Fatal("a selection literal is marked as installation work") |
| 292 | } |
| 293 | check := func(name string, err, want error) { |
| 294 | t.Helper() |
| 295 | if want == nil && err != nil { |
| 296 | t.Fatalf("%s: refused: %v", name, err) |
| 297 | } |
| 298 | if want != nil && !errors.Is(err, want) { |
| 299 | t.Fatalf("%s: got %v, want %v", name, err, want) |
| 300 | } |
| 301 | } |
| 302 | // Ordinary work, through the production policy: the selection is |
| 303 | // not an installation's, so a missing row refuses. |
| 304 | ordinary := tc.want |
| 305 | if row == nil { |
| 306 | ordinary = hostbox.ErrChanged |
| 307 | } |
| 308 | p := productionClaimUnitShapePolicy("instance-control", route) |
| 309 | check("ordinary work", p(t.Context(), &hostclaim.Claim{Host: in.Host, Op: hostclaim.Op{Kind: hostclaim.KindServing}}, s, hostwrite.Identity{}), ordinary) |
| 310 | // The installation's own work, with instance-control's reader |
| 311 | // (productionClaimUnitShapePolicy's). |
| 312 | read := func(ctx context.Context, rt registry.Route, s hostbox.Selection) error { |
| 313 | return withClaimUnitShape(ctx, rt, s, func(claimUnitBinding) error { return nil }) |
| 314 | } |
| 315 | check("installation work", claimUnitShapeCheck(t.Context(), route, read, s, true), tc.want) |
| 316 | if lookups != 2 { |
| 317 | t.Fatal("route lookups", lookups) |
| 318 | } |
| 319 | }) |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | // The agent's reader is root's boundInputs (claimunit_policy.go's agent |
| 324 | // branch, over the role-launch socket). Ordinary work never reaches it |
| 325 | // without a row; the installation's own work sends it the empty route, which |
| 326 | // it passes only when root has no binding and no configured shape. |
| 327 | func TestClaimUnitShapePolicyFreshInstallAgentReader(t *testing.T) { |
| 328 | var seen []registry.Route |
| 329 | route := func(string) (*registry.Route, error) { return nil, nil } |
| 330 | read := func(_ context.Context, rt registry.Route, _ hostbox.Selection) error { |
| 331 | seen = append(seen, rt) |
| 332 | return nil |
| 333 | } |
| 334 | s := hostbox.Selection{Host: "shop.example"} |
| 335 | if err := claimUnitShapePolicy(route, read)(t.Context(), nil, s, hostwrite.Identity{}); !errors.Is(err, hostbox.ErrChanged) || len(seen) != 0 { |
| 336 | t.Fatal("ordinary work without a row:", err, seen) |
| 337 | } |
| 338 | if err := claimUnitShapeCheck(t.Context(), route, read, s, true); err != nil || len(seen) != 1 || seen[0] != (registry.Route{Host: "shop.example"}) { |
| 339 | t.Fatal(err, seen) |
| 340 | } |
| 341 | for _, tc := range []struct { |
| 342 | name string |
| 343 | binding, shape bool |
| 344 | want error |
| 345 | }{ |
| 346 | {"no binding, no shape", false, false, nil}, |
| 347 | {"configured shape", false, true, os.ErrPermission}, |
| 348 | {"saved binding", true, true, os.ErrPermission}, |
| 349 | } { |
| 350 | t.Run(tc.name, func(t *testing.T) { |
| 351 | b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "") |
| 352 | if tc.binding { |
| 353 | if _, err := b.establish(t.Context(), in.Host); err != nil { |
| 354 | t.Fatal(err) |
| 355 | } |
| 356 | } |
| 357 | if !tc.shape { |
| 358 | b.canonical = func(context.Context, string) (claimUnitShape, error) { return claimUnitShape{}, os.ErrNotExist } |
| 359 | } else { |
| 360 | b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil } |
| 361 | } |
| 362 | h := &ClaimLaunchRoot{unitBindings: b, unitProgram: func(claimUnitShape) (claimUnitProgram, error) { return claimUnitProgram{}, nil }} |
| 363 | inputs, err := h.boundInputs(t.Context(), registry.Route{Host: in.Host}) |
| 364 | if tc.want == nil && (err != nil || inputs != nil) { |
| 365 | t.Fatal(inputs, err) |
| 366 | } |
| 367 | if tc.want != nil && !errors.Is(err, tc.want) { |
| 368 | t.Fatalf("got %v, want %v", err, tc.want) |
| 369 | } |
| 370 | }) |
| 371 | } |
| 372 | } |
| 373 | |