| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/hostguard" |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | "github.com/carlosframework/platform/internal/rootstate" |
| 13 | ) |
| 14 | |
| 15 | func plan14UnitBindingFixture(t *testing.T, unit, backing, socket string) (*claimUnitBindings, hostguard.RootInstallation, claimUnitShape) { |
| 16 | t.Helper() |
| 17 | in := hostguard.RootInstallation{Host: "shop.example", Account: "bab", App: "shop", Record: "apps/bab/shop/instances/shop.example.json", Incarnation: "inc", Stamp: 1, Migration: 1, InventoryRevision: 1, Box: "box", Enrollment: "enrollment"} |
| 18 | shape := claimUnitShape{Host: in.Host, Account: in.Account, App: in.App, Kind: "instance", Unit: unit, Backing: backing, Socket: socket, DBPath: "/data/instances/shop/data.db"} |
| 19 | if socket == "" { |
| 20 | shape.Addr = "127.0.0.1:8090" |
| 21 | } |
| 22 | b := &claimUnitBindings{state: &rollbackMemory{files: map[string][]byte{}}, installation: func(context.Context, string) (hostguard.RootInstallation, error) { return in, nil }, canonical: func(context.Context, string) (claimUnitShape, error) { return shape, nil }} |
| 23 | return b, in, shape |
| 24 | } |
| 25 | |
| 26 | func TestPlan14CanonicalUnitBinding(t *testing.T) { |
| 27 | for _, tc := range []struct{ name, unit, backing, socket string }{ |
| 28 | {"named", "console.service", "", ""}, |
| 29 | {"socket", "woodstar-atbridge.service", "", "/run/carlos/woodstar.sock"}, |
| 30 | {"whole-app", "carlos-app@shop.example.service", "unit", ""}, |
| 31 | } { |
| 32 | t.Run(tc.name, func(t *testing.T) { |
| 33 | b, in, shape := plan14UnitBindingFixture(t, tc.unit, tc.backing, tc.socket) |
| 34 | saved, err := b.establish(t.Context(), in.Host) |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | row := shape.route() |
| 39 | if _, err := b.check(t.Context(), in.Host, &row); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | raw, _ := b.state.Read(in.Host + ".json") |
| 43 | for _, mutate := range []func(*registry.Route){ |
| 44 | func(r *registry.Route) { r.Unit = "other.service" }, func(r *registry.Route) { r.Backing = "exec" }, |
| 45 | func(r *registry.Route) { r.Socket = "/run/other.sock" }, func(r *registry.Route) { r.Addr = "127.0.0.1:9000" }, |
| 46 | func(r *registry.Route) { r.DBPath = "/data/sibling.db" }, func(r *registry.Route) { r.Account = "other" }, |
| 47 | func(r *registry.Route) { r.Tunnel = "other" }, func(r *registry.Route) { r.Kind = "sidecar" }, |
| 48 | } { |
| 49 | forged := row |
| 50 | mutate(&forged) |
| 51 | if _, err := b.check(t.Context(), in.Host, &forged); err == nil { |
| 52 | t.Fatal("accepted changed shape", forged) |
| 53 | } |
| 54 | } |
| 55 | b.canonical = func(context.Context, string) (claimUnitShape, error) { |
| 56 | changed := shape |
| 57 | changed.Unit = "other.service" |
| 58 | return changed, nil |
| 59 | } |
| 60 | if _, err := b.establish(t.Context(), in.Host); err == nil { |
| 61 | t.Fatal("rebound a live installation") |
| 62 | } |
| 63 | if _, err := b.check(t.Context(), in.Host, &row); err == nil { |
| 64 | t.Fatal("accepted changed canonical configuration") |
| 65 | } |
| 66 | now, _ := b.state.Read(in.Host + ".json") |
| 67 | if string(now) != string(raw) { |
| 68 | t.Fatal("replaced binding") |
| 69 | } |
| 70 | b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil } |
| 71 | successor := in |
| 72 | successor.Stamp++ |
| 73 | b.installation = func(context.Context, string) (hostguard.RootInstallation, error) { return successor, nil } |
| 74 | if _, err := b.check(t.Context(), in.Host, &row); err == nil { |
| 75 | t.Fatal("adopted successor") |
| 76 | } |
| 77 | if err := b.retire(t.Context(), saved, func(claimUnitBinding) error { return os.ErrPermission }); err == nil { |
| 78 | t.Fatal("retired without stop") |
| 79 | } |
| 80 | pending, _ := b.read(in.Host) |
| 81 | if !pending.Retiring || pending.Retired { |
| 82 | t.Fatal("missing durable retirement intent") |
| 83 | } |
| 84 | if _, err := b.establish(t.Context(), in.Host); err == nil { |
| 85 | t.Fatal("overwrote predecessor retirement") |
| 86 | } |
| 87 | called := false |
| 88 | if err := b.retire(t.Context(), saved, func(got claimUnitBinding) error { |
| 89 | called = true |
| 90 | if got.Owner != saved.Owner || got.Shape != shape { |
| 91 | t.Fatal("retirement retargeted") |
| 92 | } |
| 93 | return nil |
| 94 | }); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | if !called { |
| 98 | t.Fatal("no stop verification") |
| 99 | } |
| 100 | if _, err := b.check(t.Context(), in.Host, &row); err == nil { |
| 101 | t.Fatal("retired binding granted work") |
| 102 | } |
| 103 | if _, err := b.establish(t.Context(), in.Host); err == nil { |
| 104 | t.Fatal("implicit successor grant") |
| 105 | } |
| 106 | }) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestPlan14CanonicalUnitBindingRefusals(t *testing.T) { |
| 111 | b, in, _ := plan14UnitBindingFixture(t, "console.service", "", "") |
| 112 | for _, raw := range []string{"", "{}", `{"Schema":1,"unknown":1}`} { |
| 113 | b.state.Write(in.Host+".json", []byte(raw)) |
| 114 | if _, err := b.establish(t.Context(), in.Host); err == nil { |
| 115 | t.Fatal("corrupt history became permission") |
| 116 | } |
| 117 | } |
| 118 | b.state.Remove(in.Host + ".json") |
| 119 | b.canonical = func(context.Context, string) (claimUnitShape, error) { return claimUnitShape{}, os.ErrNotExist } |
| 120 | if _, err := b.establish(t.Context(), in.Host); !errors.Is(err, os.ErrNotExist) { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | if _, err := b.state.Read(in.Host + ".json"); !errors.Is(err, os.ErrNotExist) { |
| 124 | t.Fatal("created binding without root config", err) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | func TestPlan14UnitBindingRetirementCrash(t *testing.T) { |
| 129 | b, in, _ := plan14UnitBindingFixture(t, "console.service", "", "") |
| 130 | saved, err := b.establish(t.Context(), in.Host) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | if err := b.retire(t.Context(), saved, func(claimUnitBinding) error { return os.ErrPermission }); err == nil { |
| 135 | t.Fatal("lost stop accepted") |
| 136 | } |
| 137 | restarted := *b |
| 138 | if _, err := restarted.check(t.Context(), in.Host, nil); err == nil { |
| 139 | t.Fatal("crash reopened admission") |
| 140 | } |
| 141 | if err := restarted.retire(t.Context(), saved, func(claimUnitBinding) error { return nil }); err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | raw, _ := b.state.Read(in.Host + ".json") |
| 145 | var record claimUnitBinding |
| 146 | if err := json.Unmarshal(raw, &record); err != nil || !record.Retired { |
| 147 | t.Fatal(record, err) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | func TestPlan14UnitBindingConfig(t *testing.T) { |
| 152 | _, _, s := plan14UnitBindingFixture(t, "console.service", "", "") |
| 153 | raw, _ := json.Marshal(struct { |
| 154 | Schema int |
| 155 | Units []claimUnitShape |
| 156 | }{1, []claimUnitShape{s}}) |
| 157 | if got, err := decodeClaimUnitShapes(raw); err != nil || got[s.Host] != s { |
| 158 | t.Fatal(got, err) |
| 159 | } |
| 160 | for _, kind := range []string{"duplicate host", "duplicate unit", "tunnel", "unknown field", "trailing", "unsafe addr", "template", "backing"} { |
| 161 | t.Run(kind, func(t *testing.T) { |
| 162 | other := s |
| 163 | other.Host = "sibling.example" |
| 164 | config := struct { |
| 165 | Schema int |
| 166 | Units []claimUnitShape |
| 167 | }{1, []claimUnitShape{s}} |
| 168 | switch kind { |
| 169 | case "duplicate host": |
| 170 | config.Units = append(config.Units, s) |
| 171 | case "duplicate unit": |
| 172 | config.Units = append(config.Units, other) |
| 173 | case "unsafe addr": |
| 174 | config.Units[0].Addr = "0.0.0.0:8090" |
| 175 | case "template": |
| 176 | config.Units[0].Unit = "x@.service" |
| 177 | case "backing": |
| 178 | config.Units[0].Backing = "unit" |
| 179 | } |
| 180 | body, _ := json.Marshal(config) |
| 181 | switch kind { |
| 182 | case "tunnel": |
| 183 | body = []byte(`{"Schema":1,"Units":[{"Tunnel":"x"}]}`) |
| 184 | case "unknown field": |
| 185 | body = []byte(`{"Schema":1,"Units":[],"Authority":true}`) |
| 186 | case "trailing": |
| 187 | body = append(body, []byte(`{}`)...) |
| 188 | } |
| 189 | if _, err := decodeClaimUnitShapes(body); err == nil { |
| 190 | t.Fatal("accepted invalid config") |
| 191 | } |
| 192 | }) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | type plan14UnitBindingFault struct { |
| 197 | rootstate.Store |
| 198 | fail bool |
| 199 | writes int |
| 200 | } |
| 201 | |
| 202 | func (s *plan14UnitBindingFault) Write(name string, body []byte) error { |
| 203 | s.writes++ |
| 204 | if err := s.Store.Write(name, body); err != nil { |
| 205 | return err |
| 206 | } |
| 207 | if s.fail { |
| 208 | return os.ErrInvalid |
| 209 | } |
| 210 | return nil |
| 211 | } |
| 212 | |
| 213 | func TestPlan14UnitBindingDurability(t *testing.T) { |
| 214 | b, in, _ := plan14UnitBindingFixture(t, "console.service", "", "") |
| 215 | fault := &plan14UnitBindingFault{Store: b.state, fail: true} |
| 216 | b.state = fault |
| 217 | if _, err := b.establish(t.Context(), in.Host); err == nil { |
| 218 | t.Fatal("reported unsynced creation") |
| 219 | } |
| 220 | if _, err := b.establish(t.Context(), in.Host); err == nil { |
| 221 | t.Fatal("duplicate creation skipped durability") |
| 222 | } |
| 223 | fault.fail = false |
| 224 | saved, err := b.establish(t.Context(), in.Host) |
| 225 | if err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | fault.fail = true |
| 229 | effects := 0 |
| 230 | if err := b.retire(t.Context(), saved, func(claimUnitBinding) error { effects++; return nil }); err == nil { |
| 231 | t.Fatal("reported unsynced retirement intent") |
| 232 | } |
| 233 | if effects != 0 { |
| 234 | t.Fatal("effects before durable intent") |
| 235 | } |
| 236 | if _, err := b.check(t.Context(), in.Host, nil); err == nil { |
| 237 | t.Fatal("visible retirement intent reopened") |
| 238 | } |
| 239 | fault.fail = false |
| 240 | if err := b.retire(t.Context(), saved, func(claimUnitBinding) error { effects++; return nil }); err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | if effects != 1 { |
| 244 | t.Fatal(effects) |
| 245 | } |
| 246 | fault.fail = true |
| 247 | if err := b.retire(t.Context(), saved, func(claimUnitBinding) error { effects++; return nil }); err == nil { |
| 248 | t.Fatal("duplicate completion skipped durability") |
| 249 | } |
| 250 | if effects != 1 { |
| 251 | t.Fatal("replayed completed effect") |
| 252 | } |
| 253 | } |
| 254 | |