package appconfig import ( "context" "crypto/ecdh" "errors" "strings" "testing" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/relay" "github.com/carlosframework/platform/internal/release" ) // syncRelayTestKeys generates and parses a config-crypto keypair, the same // shape TestSyncWritesFileAndRestartsOnChange (sync_test.go) uses. func syncRelayTestKeys(t *testing.T) (*ecdh.PrivateKey, *ecdh.PublicKey) { t.Helper() privB64, pubB64, err := GenerateKey() if err != nil { t.Fatalf("GenerateKey: %v", err) } priv, err := ParsePrivateKey(privB64, "test") if err != nil { t.Fatalf("ParsePrivateKey: %v", err) } pub, err := ParsePublicKey(pubB64, "test") if err != nil { t.Fatalf("ParsePublicKey: %v", err) } return priv, pub } // sealRelayConfig builds CARLOS_RELAY's value with relay.BuildConfig (the // same helper relayConfigValue, internal/console/relayops.go, calls) and // seals it into acct/app's config bundle, returning the suffixes // BuildConfig issued so tests can compare against what a sync pass's ack // names back. 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 { t.Helper() value, suffixes, err := relay.BuildConfig(acct, app, rec, masters, func(region string) []string { return []string{"turn:" + region + ".relay.example.com:3478"} }) if err != nil { t.Fatalf("relay.BuildConfig: %v", err) } envelope, err := Seal(pub, Binding{Account: acct, App: app}, []byte(value)) if err != nil { t.Fatalf("Seal: %v", err) } if err := SetSecret(ctx, s, acct, app, map[string]string{"CARLOS_RELAY": envelope}); err != nil { t.Fatalf("SetSecret: %v", err) } return suffixes } func TestSyncWritesConfigAckNamingRelaySuffixes(t *testing.T) { s := release.NewDirStore(t.TempDir()) ctx := context.Background() reg := testRegistry(t) envDir := t.TempDir() priv, pub := syncRelayTestKeys(t) rec := &relay.Record{Enabled: true, Incarnation: "inc00001", Regions: []string{"eu-west-1"}} masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("0123456789abcdef0123456789abcdef")}} wantSuffixes := sealRelayConfig(t, ctx, s, pub, "abc", "meet", rec, masters) if wantSuffixes["eu-west-1"] == "" { t.Fatalf("sealRelayConfig issued no suffix for eu-west-1: %v", wantSuffixes) } if err := reg.Upsert(registry.Route{ Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready", Backing: registry.BackingUnit, }); err != nil { t.Fatalf("Upsert: %v", err) } var acks []ConfigAck ack := func(a ConfigAck) { acks = append(acks, a) } restart := func(host string) error { return nil } if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil { t.Fatalf("Sync: %v", err) } if len(acks) != 1 { t.Fatalf("acks = %d, want 1: %+v", len(acks), acks) } got := acks[0] if got.Host != "meet.example.com" { t.Fatalf("ack.Host = %q, want meet.example.com", got.Host) } if got.Shape != "unit" { t.Fatalf("ack.Shape = %q, want unit", got.Shape) } if !got.Restarted { t.Fatalf("ack.Restarted = false, want true") } if got.RestartError != "" { t.Fatalf("ack.RestartError = %q, want empty", got.RestartError) } if got.RelaySuffixes["eu-west-1"] != wantSuffixes["eu-west-1"] { t.Fatalf("ack.RelaySuffixes[eu-west-1] = %q, want %q", got.RelaySuffixes["eu-west-1"], wantSuffixes["eu-west-1"]) } // The identity is account:app:incarnation:kid (spec §5) — a kid alone // would not distinguish a re-enable, so the whole thing must be there. if !strings.Contains(got.RelaySuffixes["eu-west-1"], "abc:meet:inc00001:k1") { t.Fatalf("ack.RelaySuffixes[eu-west-1] = %q, want it to name abc:meet:inc00001:k1", got.RelaySuffixes["eu-west-1"]) } } func TestSyncAckReportsRestartFailure(t *testing.T) { s := release.NewDirStore(t.TempDir()) ctx := context.Background() reg := testRegistry(t) envDir := t.TempDir() priv, pub := syncRelayTestKeys(t) rec := &relay.Record{Enabled: true, Incarnation: "inc00002", Regions: []string{"eu-west-1"}} masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("0123456789abcdef0123456789abcdef")}} sealRelayConfig(t, ctx, s, pub, "abc", "meet", rec, masters) if err := reg.Upsert(registry.Route{ Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready", Backing: registry.BackingUnit, }); err != nil { t.Fatalf("Upsert: %v", err) } var acks []ConfigAck ack := func(a ConfigAck) { acks = append(acks, a) } restartErr := errors.New("systemctl try-restart carlos-app@meet.example.com: exit status 1") restart := func(host string) error { return restartErr } if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil { t.Fatalf("Sync: %v", err) } if len(acks) != 1 { t.Fatalf("acks = %d, want 1: %+v", len(acks), acks) } got := acks[0] if got.Restarted { t.Fatalf("ack.Restarted = true, want false on a failed restart") } if got.RestartError != restartErr.Error() { t.Fatalf("ack.RestartError = %q, want %q", got.RestartError, restartErr.Error()) } } func TestSyncAckWithoutRelayHasNilSuffixes(t *testing.T) { s := release.NewDirStore(t.TempDir()) ctx := context.Background() reg := testRegistry(t) envDir := t.TempDir() priv, _ := syncRelayTestKeys(t) if err := SetEnv(ctx, s, "abc", "meet", map[string]string{"LOG_LEVEL": "debug"}); err != nil { t.Fatalf("SetEnv: %v", err) } if err := reg.Upsert(registry.Route{ Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready", Backing: registry.BackingUnit, }); err != nil { t.Fatalf("Upsert: %v", err) } var acks []ConfigAck ack := func(a ConfigAck) { acks = append(acks, a) } restart := func(host string) error { return nil } if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil { t.Fatalf("Sync: %v", err) } if len(acks) != 1 { t.Fatalf("acks = %d, want 1: %+v", len(acks), acks) } if acks[0].RelaySuffixes != nil { t.Fatalf("ack.RelaySuffixes = %+v, want nil (no CARLOS_RELAY entry)", acks[0].RelaySuffixes) } } // TestSyncAckHibernatingRouteReportsNoRestart is spec §5's "hibernating, // delivered on wake" case: try-restart on an inactive unit exits 0 having // restarted nothing (sync.go's own long-standing comment on the restart // call), so a hibernating route's ack must not read as an ordinary // Restarted:true adoption — it has to say the config landed but nothing // woke to run it. func TestSyncAckHibernatingRouteReportsNoRestart(t *testing.T) { s := release.NewDirStore(t.TempDir()) ctx := context.Background() reg := testRegistry(t) envDir := t.TempDir() priv, pub := syncRelayTestKeys(t) rec := &relay.Record{Enabled: true, Incarnation: "inc00005", Regions: []string{"eu-west-1"}} masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("0123456789abcdef0123456789abcdef")}} sealRelayConfig(t, ctx, s, pub, "abc", "meet", rec, masters) if err := reg.Upsert(registry.Route{ Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready", Backing: registry.BackingUnit, Hibernate: true, }); err != nil { t.Fatalf("Upsert: %v", err) } var acks []ConfigAck ack := func(a ConfigAck) { acks = append(acks, a) } // try-restart on a hibernating (inactive) unit exits 0 having done // nothing — the no-op restart callers everywhere in this package rely // on returning nil for. restart := func(host string) error { return nil } if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil { t.Fatalf("Sync: %v", err) } if len(acks) != 1 { t.Fatalf("acks = %d, want 1: %+v", len(acks), acks) } got := acks[0] if !got.Hibernating { t.Fatalf("ack.Hibernating = false, want true") } if got.Restarted { t.Fatalf("ack.Restarted = true, want false — the route was asleep, nothing actually restarted") } if got.RestartError != "" { t.Fatalf("ack.RestartError = %q, want empty", got.RestartError) } } // The ack says which version of the instance layer the agent rendered // (design §6.5). The console holds the etag its own write returned and // compares the two, which is decidable in a way a map of delivered key // names is not: an old agent and a new agent rendering an overlay that sets // nothing look identical by key, and not at all by etag. func TestConfigAckNamesTheInstanceOverlayItRendered(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) reg := testRegistry(t) envDir := t.TempDir() priv, _ := syncRelayTestKeys(t) if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil { t.Fatalf("SetEnv: %v", err) } _, want, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com", map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil) if err != nil { t.Fatalf("ApplyInstance: %v", err) } for _, host := range []string{"book.example.com", "cart.example.com"} { if err := reg.Upsert(registry.Route{ Host: host, Kind: "instance", App: "titogo", Account: "bab", Status: "ready", Backing: registry.BackingUnit, }); err != nil { t.Fatalf("Upsert %s: %v", host, err) } } acks := map[string]ConfigAck{} if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, func(a ConfigAck) { acks[a.Host] = a }); err != nil { t.Fatalf("Sync: %v", err) } book, ok := acks["book.example.com"] if !ok { t.Fatal("no ack for the overlaid route") } if book.Schema != ConfigAckSchema { t.Fatalf("Schema = %d, want %d — absence is how an older agent is recognised", book.Schema, ConfigAckSchema) } if book.InstanceConfigETag != want { t.Fatalf("InstanceConfigETag = %q, want the etag the write returned %q", book.InstanceConfigETag, want) } cart, ok := acks["cart.example.com"] if !ok { t.Fatal("no ack for the sibling") } if cart.Schema != ConfigAckSchema || cart.InstanceConfigETag != "" { t.Fatalf("sibling ack = %+v, want the schema and an empty etag: it has no overlay to render", cart) } } // The same statement from the exec half, which is the half an instance // sender's box actually runs (design §6.4's delivery check). func TestSyncExecConfigAckNamesTheInstanceOverlayItRendered(t *testing.T) { ctx, s, reg, dd, privB64, _ := execKit(t) priv, err := ParsePrivateKey(privB64, "test") if err != nil { t.Fatalf("ParsePrivateKey: %v", err) } if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil { t.Fatalf("SetEnv: %v", err) } _, want, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com", map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil) if err != nil { t.Fatalf("ApplyInstance: %v", err) } if err := reg.Upsert(registry.Route{ Host: "book.example.com", Kind: "instance", App: "titogo", Account: "bab", Status: "ready", Socket: "/run/carlos/book.sock", }); err != nil { t.Fatalf("Upsert: %v", err) } var got []ConfigAck if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", func(a ConfigAck) { got = append(got, a) }); err != nil { t.Fatalf("SyncExec: %v", err) } if len(got) != 1 { t.Fatalf("acks = %+v, want exactly one", got) } if got[0].Schema != ConfigAckSchema || got[0].InstanceConfigETag != want { t.Fatalf("ack = %+v, want schema %d and etag %q", got[0], ConfigAckSchema, want) } } // The four answers, and they are four rather than two on purpose. "This // agent is too old to render the layer" and "this agent rendered an older // version of it" call for different sentences from whoever asks: one is a // box to roll, the other is a pass to wait for. func TestInstanceConfigDeliveryVerdicts(t *testing.T) { cases := []struct { name string ack *ConfigAck want string got string }{ {"not yet acked", nil, "etag-1", InstanceConfigNotYetDelivered}, {"agent older than the layer", &ConfigAck{Schema: 0, InstanceConfigETag: ""}, "etag-1", InstanceConfigUnknownAgent}, {"rendered this overlay", &ConfigAck{Schema: ConfigAckSchema, InstanceConfigETag: "etag-1"}, "etag-1", InstanceConfigDelivered}, {"rendered an older one", &ConfigAck{Schema: ConfigAckSchema, InstanceConfigETag: "etag-0"}, "etag-1", InstanceConfigStale}, {"no overlay, and none expected", &ConfigAck{Schema: ConfigAckSchema}, "", InstanceConfigDelivered}, {"no overlay, but one was written", &ConfigAck{Schema: ConfigAckSchema}, "etag-1", InstanceConfigStale}, } for _, c := range cases { if got := InstanceConfigDelivery(c.ack, c.want); got != c.got { t.Errorf("%s: InstanceConfigDelivery = %q, want %q", c.name, got, c.got) } } } // One route can have acks from more than one box across a migration, so the // reader takes the freshest and ignores every other host's. func TestLatestConfigAckPicksTheFreshestForTheHost(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) older := ConfigAck{Schema: ConfigAckSchema, Box: "box-1", Host: "book.example.com", Shape: "unit", At: time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-0"} newer := ConfigAck{Schema: ConfigAckSchema, Box: "box-2", Host: "book.example.com", Shape: "unit", At: time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-1"} other := ConfigAck{Schema: ConfigAckSchema, Box: "box-2", Host: "cart.example.com", Shape: "unit", At: time.Date(2026, 9, 15, 13, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-9"} for _, a := range []ConfigAck{older, newer, other} { if err := WriteConfigAck(ctx, s, a); err != nil { t.Fatalf("WriteConfigAck %s/%s: %v", a.Box, a.Host, err) } } got, err := LatestConfigAck(ctx, s, "book.example.com") if err != nil || got == nil { t.Fatalf("LatestConfigAck = %+v %v", got, err) } if got.Box != "box-2" || got.InstanceConfigETag != "etag-1" { t.Fatalf("LatestConfigAck = %+v, want box-2's etag-1", got) } if v := InstanceConfigDelivery(got, "etag-1"); v != InstanceConfigDelivered { t.Fatalf("delivery = %q, want %q", v, InstanceConfigDelivered) } none, err := LatestConfigAck(ctx, s, "desk.example.com") if err != nil || none != nil { t.Fatalf("LatestConfigAck for a host with no ack = %+v %v, want (nil, nil)", none, err) } if v := InstanceConfigDelivery(none, "etag-1"); v != InstanceConfigNotYetDelivered { t.Fatalf("delivery = %q, want %q", v, InstanceConfigNotYetDelivered) } }