| 1 | package appconfig |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ecdh" |
| 6 | "errors" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | "github.com/carlosframework/platform/internal/relay" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | // syncRelayTestKeys generates and parses a config-crypto keypair, the same |
| 17 | // shape TestSyncWritesFileAndRestartsOnChange (sync_test.go) uses. |
| 18 | func syncRelayTestKeys(t *testing.T) (*ecdh.PrivateKey, *ecdh.PublicKey) { |
| 19 | t.Helper() |
| 20 | privB64, pubB64, err := GenerateKey() |
| 21 | if err != nil { |
| 22 | t.Fatalf("GenerateKey: %v", err) |
| 23 | } |
| 24 | priv, err := ParsePrivateKey(privB64, "test") |
| 25 | if err != nil { |
| 26 | t.Fatalf("ParsePrivateKey: %v", err) |
| 27 | } |
| 28 | pub, err := ParsePublicKey(pubB64, "test") |
| 29 | if err != nil { |
| 30 | t.Fatalf("ParsePublicKey: %v", err) |
| 31 | } |
| 32 | return priv, pub |
| 33 | } |
| 34 | |
| 35 | // sealRelayConfig builds CARLOS_RELAY's value with relay.BuildConfig (the |
| 36 | // same helper relayConfigValue, internal/console/relayops.go, calls) and |
| 37 | // seals it into acct/app's config bundle, returning the suffixes |
| 38 | // BuildConfig issued so tests can compare against what a sync pass's ack |
| 39 | // names back. |
| 40 | func sealRelayConfig(t *testing.T, ctx context.Context, s release.Store, pub *ecdh.PublicKey, acct, app string, rec *relay.Record, masters []relay.RegionMaster) map[string]string { |
| 41 | t.Helper() |
| 42 | value, suffixes, err := relay.BuildConfig(acct, app, rec, masters, func(region string) []string { |
| 43 | return []string{"turn:" + region + ".relay.example.com:3478"} |
| 44 | }) |
| 45 | if err != nil { |
| 46 | t.Fatalf("relay.BuildConfig: %v", err) |
| 47 | } |
| 48 | envelope, err := Seal(pub, Binding{Account: acct, App: app}, []byte(value)) |
| 49 | if err != nil { |
| 50 | t.Fatalf("Seal: %v", err) |
| 51 | } |
| 52 | if err := SetSecret(ctx, s, acct, app, map[string]string{"CARLOS_RELAY": envelope}); err != nil { |
| 53 | t.Fatalf("SetSecret: %v", err) |
| 54 | } |
| 55 | return suffixes |
| 56 | } |
| 57 | |
| 58 | func TestSyncWritesConfigAckNamingRelaySuffixes(t *testing.T) { |
| 59 | s := release.NewDirStore(t.TempDir()) |
| 60 | ctx := context.Background() |
| 61 | reg := testRegistry(t) |
| 62 | envDir := t.TempDir() |
| 63 | |
| 64 | priv, pub := syncRelayTestKeys(t) |
| 65 | |
| 66 | rec := &relay.Record{Enabled: true, Incarnation: "inc00001", Regions: []string{"eu-west-1"}} |
| 67 | masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("0123456789abcdef0123456789abcdef")}} |
| 68 | wantSuffixes := sealRelayConfig(t, ctx, s, pub, "abc", "meet", rec, masters) |
| 69 | if wantSuffixes["eu-west-1"] == "" { |
| 70 | t.Fatalf("sealRelayConfig issued no suffix for eu-west-1: %v", wantSuffixes) |
| 71 | } |
| 72 | |
| 73 | if err := reg.Upsert(registry.Route{ |
| 74 | Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready", |
| 75 | Backing: registry.BackingUnit, |
| 76 | }); err != nil { |
| 77 | t.Fatalf("Upsert: %v", err) |
| 78 | } |
| 79 | |
| 80 | var acks []ConfigAck |
| 81 | ack := func(a ConfigAck) { acks = append(acks, a) } |
| 82 | restart := func(host string) error { return nil } |
| 83 | if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil { |
| 84 | t.Fatalf("Sync: %v", err) |
| 85 | } |
| 86 | |
| 87 | if len(acks) != 1 { |
| 88 | t.Fatalf("acks = %d, want 1: %+v", len(acks), acks) |
| 89 | } |
| 90 | got := acks[0] |
| 91 | if got.Host != "meet.example.com" { |
| 92 | t.Fatalf("ack.Host = %q, want meet.example.com", got.Host) |
| 93 | } |
| 94 | if got.Shape != "unit" { |
| 95 | t.Fatalf("ack.Shape = %q, want unit", got.Shape) |
| 96 | } |
| 97 | if !got.Restarted { |
| 98 | t.Fatalf("ack.Restarted = false, want true") |
| 99 | } |
| 100 | if got.RestartError != "" { |
| 101 | t.Fatalf("ack.RestartError = %q, want empty", got.RestartError) |
| 102 | } |
| 103 | if got.RelaySuffixes["eu-west-1"] != wantSuffixes["eu-west-1"] { |
| 104 | t.Fatalf("ack.RelaySuffixes[eu-west-1] = %q, want %q", got.RelaySuffixes["eu-west-1"], wantSuffixes["eu-west-1"]) |
| 105 | } |
| 106 | // The identity is account:app:incarnation:kid (spec §5) — a kid alone |
| 107 | // would not distinguish a re-enable, so the whole thing must be there. |
| 108 | if !strings.Contains(got.RelaySuffixes["eu-west-1"], "abc:meet:inc00001:k1") { |
| 109 | t.Fatalf("ack.RelaySuffixes[eu-west-1] = %q, want it to name abc:meet:inc00001:k1", got.RelaySuffixes["eu-west-1"]) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | func TestSyncAckReportsRestartFailure(t *testing.T) { |
| 114 | s := release.NewDirStore(t.TempDir()) |
| 115 | ctx := context.Background() |
| 116 | reg := testRegistry(t) |
| 117 | envDir := t.TempDir() |
| 118 | |
| 119 | priv, pub := syncRelayTestKeys(t) |
| 120 | rec := &relay.Record{Enabled: true, Incarnation: "inc00002", Regions: []string{"eu-west-1"}} |
| 121 | masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("0123456789abcdef0123456789abcdef")}} |
| 122 | sealRelayConfig(t, ctx, s, pub, "abc", "meet", rec, masters) |
| 123 | |
| 124 | if err := reg.Upsert(registry.Route{ |
| 125 | Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready", |
| 126 | Backing: registry.BackingUnit, |
| 127 | }); err != nil { |
| 128 | t.Fatalf("Upsert: %v", err) |
| 129 | } |
| 130 | |
| 131 | var acks []ConfigAck |
| 132 | ack := func(a ConfigAck) { acks = append(acks, a) } |
| 133 | restartErr := errors.New("systemctl try-restart carlos-app@meet.example.com: exit status 1") |
| 134 | restart := func(host string) error { return restartErr } |
| 135 | if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil { |
| 136 | t.Fatalf("Sync: %v", err) |
| 137 | } |
| 138 | |
| 139 | if len(acks) != 1 { |
| 140 | t.Fatalf("acks = %d, want 1: %+v", len(acks), acks) |
| 141 | } |
| 142 | got := acks[0] |
| 143 | if got.Restarted { |
| 144 | t.Fatalf("ack.Restarted = true, want false on a failed restart") |
| 145 | } |
| 146 | if got.RestartError != restartErr.Error() { |
| 147 | t.Fatalf("ack.RestartError = %q, want %q", got.RestartError, restartErr.Error()) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | func TestSyncAckWithoutRelayHasNilSuffixes(t *testing.T) { |
| 152 | s := release.NewDirStore(t.TempDir()) |
| 153 | ctx := context.Background() |
| 154 | reg := testRegistry(t) |
| 155 | envDir := t.TempDir() |
| 156 | |
| 157 | priv, _ := syncRelayTestKeys(t) |
| 158 | if err := SetEnv(ctx, s, "abc", "meet", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| 159 | t.Fatalf("SetEnv: %v", err) |
| 160 | } |
| 161 | if err := reg.Upsert(registry.Route{ |
| 162 | Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready", |
| 163 | Backing: registry.BackingUnit, |
| 164 | }); err != nil { |
| 165 | t.Fatalf("Upsert: %v", err) |
| 166 | } |
| 167 | |
| 168 | var acks []ConfigAck |
| 169 | ack := func(a ConfigAck) { acks = append(acks, a) } |
| 170 | restart := func(host string) error { return nil } |
| 171 | if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil { |
| 172 | t.Fatalf("Sync: %v", err) |
| 173 | } |
| 174 | |
| 175 | if len(acks) != 1 { |
| 176 | t.Fatalf("acks = %d, want 1: %+v", len(acks), acks) |
| 177 | } |
| 178 | if acks[0].RelaySuffixes != nil { |
| 179 | t.Fatalf("ack.RelaySuffixes = %+v, want nil (no CARLOS_RELAY entry)", acks[0].RelaySuffixes) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // TestSyncAckHibernatingRouteReportsNoRestart is spec §5's "hibernating, |
| 184 | // delivered on wake" case: try-restart on an inactive unit exits 0 having |
| 185 | // restarted nothing (sync.go's own long-standing comment on the restart |
| 186 | // call), so a hibernating route's ack must not read as an ordinary |
| 187 | // Restarted:true adoption — it has to say the config landed but nothing |
| 188 | // woke to run it. |
| 189 | func TestSyncAckHibernatingRouteReportsNoRestart(t *testing.T) { |
| 190 | s := release.NewDirStore(t.TempDir()) |
| 191 | ctx := context.Background() |
| 192 | reg := testRegistry(t) |
| 193 | envDir := t.TempDir() |
| 194 | |
| 195 | priv, pub := syncRelayTestKeys(t) |
| 196 | rec := &relay.Record{Enabled: true, Incarnation: "inc00005", Regions: []string{"eu-west-1"}} |
| 197 | masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("0123456789abcdef0123456789abcdef")}} |
| 198 | sealRelayConfig(t, ctx, s, pub, "abc", "meet", rec, masters) |
| 199 | |
| 200 | if err := reg.Upsert(registry.Route{ |
| 201 | Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready", |
| 202 | Backing: registry.BackingUnit, Hibernate: true, |
| 203 | }); err != nil { |
| 204 | t.Fatalf("Upsert: %v", err) |
| 205 | } |
| 206 | |
| 207 | var acks []ConfigAck |
| 208 | ack := func(a ConfigAck) { acks = append(acks, a) } |
| 209 | // try-restart on a hibernating (inactive) unit exits 0 having done |
| 210 | // nothing — the no-op restart callers everywhere in this package rely |
| 211 | // on returning nil for. |
| 212 | restart := func(host string) error { return nil } |
| 213 | if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil { |
| 214 | t.Fatalf("Sync: %v", err) |
| 215 | } |
| 216 | |
| 217 | if len(acks) != 1 { |
| 218 | t.Fatalf("acks = %d, want 1: %+v", len(acks), acks) |
| 219 | } |
| 220 | got := acks[0] |
| 221 | if !got.Hibernating { |
| 222 | t.Fatalf("ack.Hibernating = false, want true") |
| 223 | } |
| 224 | if got.Restarted { |
| 225 | t.Fatalf("ack.Restarted = true, want false — the route was asleep, nothing actually restarted") |
| 226 | } |
| 227 | if got.RestartError != "" { |
| 228 | t.Fatalf("ack.RestartError = %q, want empty", got.RestartError) |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | // The ack says which version of the instance layer the agent rendered |
| 233 | // (design §6.5). The console holds the etag its own write returned and |
| 234 | // compares the two, which is decidable in a way a map of delivered key |
| 235 | // names is not: an old agent and a new agent rendering an overlay that sets |
| 236 | // nothing look identical by key, and not at all by etag. |
| 237 | func TestConfigAckNamesTheInstanceOverlayItRendered(t *testing.T) { |
| 238 | ctx := context.Background() |
| 239 | s := release.NewDirStore(t.TempDir()) |
| 240 | reg := testRegistry(t) |
| 241 | envDir := t.TempDir() |
| 242 | priv, _ := syncRelayTestKeys(t) |
| 243 | |
| 244 | if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil { |
| 245 | t.Fatalf("SetEnv: %v", err) |
| 246 | } |
| 247 | _, want, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com", |
| 248 | map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil) |
| 249 | if err != nil { |
| 250 | t.Fatalf("ApplyInstance: %v", err) |
| 251 | } |
| 252 | for _, host := range []string{"book.example.com", "cart.example.com"} { |
| 253 | if err := reg.Upsert(registry.Route{ |
| 254 | Host: host, Kind: "instance", App: "titogo", Account: "bab", Status: "ready", |
| 255 | Backing: registry.BackingUnit, |
| 256 | }); err != nil { |
| 257 | t.Fatalf("Upsert %s: %v", host, err) |
| 258 | } |
| 259 | } |
| 260 | acks := map[string]ConfigAck{} |
| 261 | if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, |
| 262 | func(a ConfigAck) { acks[a.Host] = a }); err != nil { |
| 263 | t.Fatalf("Sync: %v", err) |
| 264 | } |
| 265 | book, ok := acks["book.example.com"] |
| 266 | if !ok { |
| 267 | t.Fatal("no ack for the overlaid route") |
| 268 | } |
| 269 | if book.Schema != ConfigAckSchema { |
| 270 | t.Fatalf("Schema = %d, want %d — absence is how an older agent is recognised", book.Schema, ConfigAckSchema) |
| 271 | } |
| 272 | if book.InstanceConfigETag != want { |
| 273 | t.Fatalf("InstanceConfigETag = %q, want the etag the write returned %q", book.InstanceConfigETag, want) |
| 274 | } |
| 275 | cart, ok := acks["cart.example.com"] |
| 276 | if !ok { |
| 277 | t.Fatal("no ack for the sibling") |
| 278 | } |
| 279 | if cart.Schema != ConfigAckSchema || cart.InstanceConfigETag != "" { |
| 280 | t.Fatalf("sibling ack = %+v, want the schema and an empty etag: it has no overlay to render", cart) |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | // The same statement from the exec half, which is the half an instance |
| 285 | // sender's box actually runs (design §6.4's delivery check). |
| 286 | func TestSyncExecConfigAckNamesTheInstanceOverlayItRendered(t *testing.T) { |
| 287 | ctx, s, reg, dd, privB64, _ := execKit(t) |
| 288 | priv, err := ParsePrivateKey(privB64, "test") |
| 289 | if err != nil { |
| 290 | t.Fatalf("ParsePrivateKey: %v", err) |
| 291 | } |
| 292 | if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil { |
| 293 | t.Fatalf("SetEnv: %v", err) |
| 294 | } |
| 295 | _, want, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com", |
| 296 | map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil) |
| 297 | if err != nil { |
| 298 | t.Fatalf("ApplyInstance: %v", err) |
| 299 | } |
| 300 | if err := reg.Upsert(registry.Route{ |
| 301 | Host: "book.example.com", Kind: "instance", App: "titogo", Account: "bab", |
| 302 | Status: "ready", Socket: "/run/carlos/book.sock", |
| 303 | }); err != nil { |
| 304 | t.Fatalf("Upsert: %v", err) |
| 305 | } |
| 306 | var got []ConfigAck |
| 307 | if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", |
| 308 | func(a ConfigAck) { got = append(got, a) }); err != nil { |
| 309 | t.Fatalf("SyncExec: %v", err) |
| 310 | } |
| 311 | if len(got) != 1 { |
| 312 | t.Fatalf("acks = %+v, want exactly one", got) |
| 313 | } |
| 314 | if got[0].Schema != ConfigAckSchema || got[0].InstanceConfigETag != want { |
| 315 | t.Fatalf("ack = %+v, want schema %d and etag %q", got[0], ConfigAckSchema, want) |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | // The four answers, and they are four rather than two on purpose. "This |
| 320 | // agent is too old to render the layer" and "this agent rendered an older |
| 321 | // version of it" call for different sentences from whoever asks: one is a |
| 322 | // box to roll, the other is a pass to wait for. |
| 323 | func TestInstanceConfigDeliveryVerdicts(t *testing.T) { |
| 324 | cases := []struct { |
| 325 | name string |
| 326 | ack *ConfigAck |
| 327 | want string |
| 328 | got string |
| 329 | }{ |
| 330 | {"not yet acked", nil, "etag-1", InstanceConfigNotYetDelivered}, |
| 331 | {"agent older than the layer", &ConfigAck{Schema: 0, InstanceConfigETag: ""}, "etag-1", InstanceConfigUnknownAgent}, |
| 332 | {"rendered this overlay", &ConfigAck{Schema: ConfigAckSchema, InstanceConfigETag: "etag-1"}, "etag-1", InstanceConfigDelivered}, |
| 333 | {"rendered an older one", &ConfigAck{Schema: ConfigAckSchema, InstanceConfigETag: "etag-0"}, "etag-1", InstanceConfigStale}, |
| 334 | {"no overlay, and none expected", &ConfigAck{Schema: ConfigAckSchema}, "", InstanceConfigDelivered}, |
| 335 | {"no overlay, but one was written", &ConfigAck{Schema: ConfigAckSchema}, "etag-1", InstanceConfigStale}, |
| 336 | } |
| 337 | for _, c := range cases { |
| 338 | if got := InstanceConfigDelivery(c.ack, c.want); got != c.got { |
| 339 | t.Errorf("%s: InstanceConfigDelivery = %q, want %q", c.name, got, c.got) |
| 340 | } |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | // One route can have acks from more than one box across a migration, so the |
| 345 | // reader takes the freshest and ignores every other host's. |
| 346 | func TestLatestConfigAckPicksTheFreshestForTheHost(t *testing.T) { |
| 347 | ctx := context.Background() |
| 348 | s := release.NewDirStore(t.TempDir()) |
| 349 | older := ConfigAck{Schema: ConfigAckSchema, Box: "box-1", Host: "book.example.com", Shape: "unit", |
| 350 | At: time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-0"} |
| 351 | newer := ConfigAck{Schema: ConfigAckSchema, Box: "box-2", Host: "book.example.com", Shape: "unit", |
| 352 | At: time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-1"} |
| 353 | other := ConfigAck{Schema: ConfigAckSchema, Box: "box-2", Host: "cart.example.com", Shape: "unit", |
| 354 | At: time.Date(2026, 9, 15, 13, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-9"} |
| 355 | for _, a := range []ConfigAck{older, newer, other} { |
| 356 | if err := WriteConfigAck(ctx, s, a); err != nil { |
| 357 | t.Fatalf("WriteConfigAck %s/%s: %v", a.Box, a.Host, err) |
| 358 | } |
| 359 | } |
| 360 | got, err := LatestConfigAck(ctx, s, "book.example.com") |
| 361 | if err != nil || got == nil { |
| 362 | t.Fatalf("LatestConfigAck = %+v %v", got, err) |
| 363 | } |
| 364 | if got.Box != "box-2" || got.InstanceConfigETag != "etag-1" { |
| 365 | t.Fatalf("LatestConfigAck = %+v, want box-2's etag-1", got) |
| 366 | } |
| 367 | if v := InstanceConfigDelivery(got, "etag-1"); v != InstanceConfigDelivered { |
| 368 | t.Fatalf("delivery = %q, want %q", v, InstanceConfigDelivered) |
| 369 | } |
| 370 | |
| 371 | none, err := LatestConfigAck(ctx, s, "desk.example.com") |
| 372 | if err != nil || none != nil { |
| 373 | t.Fatalf("LatestConfigAck for a host with no ack = %+v %v, want (nil, nil)", none, err) |
| 374 | } |
| 375 | if v := InstanceConfigDelivery(none, "etag-1"); v != InstanceConfigNotYetDelivered { |
| 376 | t.Fatalf("delivery = %q, want %q", v, InstanceConfigNotYetDelivered) |
| 377 | } |
| 378 | } |
| 379 | |