appconfig: per-route config acknowledgement naming rendered relay identities (spec §5)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
14 files changed,
+813
−119
apimode.go+11 −0internal/appconfig/configack.go+173 −0internal/appconfig/configack_test.go+180 −0internal/appconfig/sync.go+49 −8internal/appconfig/sync_test.go+28 −28internal/appconfig/syncexec.go+26 −3internal/appconfig/syncexec_test.go+42 −42internal/console/apirelay.go+87 −25internal/console/apirelay_test.go+97 −2internal/console/relayops.go+36 −0internal/host/agent.go+23 −2internal/relay/config_appconfig_test.go+34 −0internal/relay/config_test.go+7 −5relay_test.go+20 −4
diff --git a/apimode.go b/apimode.go| index 8e4c850df..8f1a667ab 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -1944,6 +1944,17 @@ type apiRelayRoute struct { |
| Delivery string `json:"delivery"` |
| AckedAt string `json:"acked_at,omitempty"` |
| Cutover bool `json:"cutover"` |
| + // ConfigAckAt and ConfigAckShape echo config sync's own per-route |
| + // acknowledgement (spec §5, internal/appconfig/configack.go) when one |
| + // names the identities currently issued — distinct from AckedAt/Cutover |
| + // above, which are a MEMBER's own `carlos relay adopted`. Neither this |
| + // CLI's printRelayStatus nor anything else here reads these fields |
| + // today: Delivery already carries the full sentence either state earns |
| + // (see internal/console/apirelay.go's relayMatchingAck); they exist so |
| + // a future consumer of the wire reply is not left inventing the ack's |
| + // own timestamp out of Delivery's prose. |
| + ConfigAckAt string `json:"config_acked_at,omitempty"` |
| + ConfigAckShape string `json:"config_ack_shape,omitempty"` |
| } |
| type apiRelayRegion struct { |
diff --git a/internal/appconfig/configack.go b/internal/appconfig/configack.go| new file mode 100644 |
| index 000000000..eb094d0af |
| --- /dev/null |
| +++ b/internal/appconfig/configack.go |
| @@ -0,0 +1,173 @@ |
| +// Config adoption, observed per route (relay spec §5). Config sync writes |
| +// an additive acknowledgement after it converges a route: the shape it |
| +// delivered into, whether a restart happened and succeeded, and — for a |
| +// route whose rendered CARLOS_RELAY carries relay config — the identities |
| +// it just wrote. Nothing reads the ack today but `carlos relay status` |
| +// (internal/console/apirelay.go); it is the first per-route statement of |
| +// "this is what the process has" the platform keeps. |
| +// |
| +// The ack names IDENTITIES, not kids: RelaySuffixes carries each region's |
| +// full username_suffix (account:app:incarnation:kid), parsed from the |
| +// rendered CARLOS_RELAY value with relay.ParseConfig. A kid alone would not |
| +// do — a disable and re-enable between two host syncs changes the |
| +// incarnation and the key while keeping the kid, and an ack naming only |
| +// kids would report the old credentials as adopted. |
| +// |
| +// internal/appconfig importing internal/relay is one-directional and safe: |
| +// internal/relay does not import internal/appconfig. |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/relay" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ConfigAck is one route's config-adoption acknowledgement, written after |
| +// config sync converges its env/config file — see ConfigAckKey for where. |
| +type ConfigAck struct { |
| + Box string `json:"box"` |
| + Host string `json:"host"` |
| + // Shape is one of "unit", "socket-exec" or "tcp-exec-file" — spec §5's |
| + // three delivery shapes. |
| + Shape string `json:"shape"` |
| + At time.Time `json:"at"` |
| + // Restarted and RestartError describe the restart/bounce this sync pass |
| + // attempted for the route, if it could attempt one at all: a unit route |
| + // that try-restarted cleanly sets Restarted true; one whose try-restart |
| + // failed sets RestartError instead. syncExecHost (the exec-file and |
| + // socket-exec shapes) always leaves both zero — its bounce is |
| + // best-effort and fire-and-forget (bounceLogged swallows the error), |
| + // and a TCP-exec-file route's process belongs to an external supervisor |
| + // this platform cannot restart at all; its cutover is a member |
| + // acknowledgement instead (carlos relay adopted). |
| + Restarted bool `json:"restarted"` |
| + RestartError string `json:"restart_error,omitempty"` |
| + // Hibernating is set when the route was asleep at sync time, so its |
| + // restart (a systemd try-restart on an inactive unit) was a no-op by |
| + // construction — the config landed but nothing woke to run it. `carlos |
| + // relay status` reports such a route as "hibernating, delivered on |
| + // wake" rather than claiming an adoption that has not happened yet. |
| + Hibernating bool `json:"hibernating,omitempty"` |
| + // RelaySuffixes is the username_suffix (account:app:incarnation:kid) |
| + // this pass rendered into CARLOS_RELAY, per region — nil if the route's |
| + // manifest carries no CARLOS_RELAY entry at all. |
| + RelaySuffixes map[string]string `json:"relay_suffixes,omitempty"` |
| +} |
| + |
| +// ConfigAckKey is where a route's config-adoption ack lives: additive, one |
| +// object per (box, route host), rewritten (never appended to) on every sync |
| +// that changes what was delivered. |
| +func ConfigAckKey(box, host string) string { |
| + return "control/config-ack/" + box + "/" + host + ".json" |
| +} |
| + |
| +// WriteConfigAck writes ack, get-then-put with the CAS etag of whatever is |
| +// there now — "" (create) only when nothing is. An ack is rewritten on |
| +// every sync that changes delivery, unlike release.Store.Put's create-only |
| +// contract, so this can't just Put with "": the object almost always |
| +// already exists. Retries once on release.ErrCASConflict, the same shape as |
| +// relay.WriteHeartbeat (internal/relay/authority.go) — a conflict here can |
| +// only be this route's own previous ack, since ConfigAckKey is unique per |
| +// (box, host) and nothing else writes it. |
| +func WriteConfigAck(ctx context.Context, s release.Store, ack ConfigAck) error { |
| + body, err := json.Marshal(ack) |
| + if err != nil { |
| + return err |
| + } |
| + key := ConfigAckKey(ack.Box, ack.Host) |
| + var lastErr error |
| + for attempt := 0; attempt < 2; attempt++ { |
| + etag := "" |
| + _, curEtag, err := s.Get(ctx, key) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + // no existing record: create-only Put below. |
| + case err != nil: |
| + return err |
| + default: |
| + etag = curEtag |
| + } |
| + if _, err := s.Put(ctx, key, body, etag); err != nil { |
| + lastErr = err |
| + if errors.Is(err, release.ErrCASConflict) { |
| + continue |
| + } |
| + return err |
| + } |
| + return nil |
| + } |
| + return lastErr |
| +} |
| + |
| +// carlosRelayVar is the well-known var name relay enable's config write |
| +// (spec §5 step 5) uses — appconfig.Apply, same as any other app var. |
| +const carlosRelayVar = "CARLOS_RELAY" |
| + |
| +// relaySuffixesFromRelayVar decrypts m's CARLOS_RELAY var, if it has one, |
| +// and parses it with relay.ParseConfig into a region -> username_suffix |
| +// map. Reads the decrypted manifest var directly — the same "env" vs |
| +// "secret" decoding RenderEnv applies to every var — never the rendered |
| +// EnvironmentFile bytes: CARLOS_RELAY is exactly as likely to be a sealed |
| +// secret entry as any other var, and re-parsing rendered KEY=value lines |
| +// would just repeat, worse, the decryption RenderEnv already performs. |
| +// |
| +// Returns nil, nil when the manifest carries no CARLOS_RELAY entry at all |
| +// — an ordinary route the relay was never enabled for, not an error. |
| +func relaySuffixesFromRelayVar(m *Manifest, priv *ecdh.PrivateKey) (map[string]string, error) { |
| + v, ok := m.Vars[carlosRelayVar] |
| + if !ok { |
| + return nil, nil |
| + } |
| + var val string |
| + switch v.Kind { |
| + case "env": |
| + val = v.Value |
| + case "secret": |
| + if priv == nil { |
| + return nil, fmt.Errorf("var %s: secret entry but no decryption key was supplied", carlosRelayVar) |
| + } |
| + plain, err := Open(priv, v.Envelope) |
| + if err != nil { |
| + return nil, fmt.Errorf("decrypt %s: %w", carlosRelayVar, err) |
| + } |
| + val = string(plain) |
| + default: |
| + return nil, fmt.Errorf("var %s: unknown kind %q", carlosRelayVar, v.Kind) |
| + } |
| + entries, err := relay.ParseConfig(val) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: %w", carlosRelayVar, err) |
| + } |
| + suffixes := make(map[string]string, len(entries)) |
| + for _, e := range entries { |
| + suffixes[e.Region] = e.UsernameSuffix |
| + } |
| + return suffixes, nil |
| +} |
| + |
| +// shapeOf names which of spec §5's three delivery shapes rt is: "unit" for |
| +// a carlos-app@<host> unit tenant, "socket-exec" for an activator-spawned |
| +// route the platform bounces over a unix socket, "tcp-exec-file" for a |
| +// TCP-address route belonging to a process this platform does not own (the |
| +// config lands as a file; adoption is a member acknowledgement instead of |
| +// a restart). Shared by syncHost and syncExecHost so the two never name a |
| +// shape differently for the same route. |
| +func shapeOf(rt registry.Route) string { |
| + switch { |
| + case rt.Backing == registry.BackingUnit: |
| + return "unit" |
| + case rt.Socket != "": |
| + return "socket-exec" |
| + default: |
| + return "tcp-exec-file" |
| + } |
| +} |
diff --git a/internal/appconfig/configack_test.go b/internal/appconfig/configack_test.go| new file mode 100644 |
| index 000000000..cdc681ad3 |
| --- /dev/null |
| +++ b/internal/appconfig/configack_test.go |
| @@ -0,0 +1,180 @@ |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "errors" |
| + "strings" |
| + "testing" |
| + |
| + "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, []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) |
| + } |
| +} |
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go| index 4fc477344..0fe6d4d6c 100644 |
| --- a/internal/appconfig/sync.go |
| +++ b/internal/appconfig/sync.go |
| @@ -17,6 +17,7 @@ import ( |
| "path/filepath" |
| "sort" |
| "strings" |
| + "time" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -39,7 +40,13 @@ const DefaultEnvDir = "/etc/carlos/apps" |
| // manifests (pipelines design §4a): keying on (acct, app) alone would render |
| // whichever route was seen first into both files. Distinct environments cost |
| // one extra resolve each, still one pass over the routes. |
| -func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) error { |
| +// |
| +// ack, if non-nil, is called once per unit-backed route this pass actually |
| +// converges (wrote a changed file and attempted a restart) with that |
| +// route's config-adoption acknowledgement (spec §5, configack.go) — nil |
| +// means no acks, the shape every test and the exec-only loops that never |
| +// touch /etc/carlos/apps use. |
| +func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, ack func(ConfigAck)) error { |
| routes, err := reg.List() |
| if err != nil { |
| return err |
| @@ -165,7 +172,7 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec |
| fmt.Printf(" %s: exec-backed — config delivered by the agent's in-process loop to /data/<host>.config.env, not this pass\n", rt.Host) |
| continue |
| } |
| - syncHost(rt, m, r.defaultMissing, priv, envDir, restart) |
| + syncHost(rt, m, r.defaultMissing, priv, envDir, restart, ack) |
| } |
| // An empty route list is never authority to delete anything: |
| // registry.Open silently creates an empty database when handed a wrong |
| @@ -328,7 +335,12 @@ func UnitConfigApps(routes []registry.Route) [][2]string { |
| // |
| // defaultMissing says the merged view is a bound environment's bundle ALONE |
| // because the app has no config.json — see the guard below. |
| -func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) { |
| +// |
| +// ack, if non-nil, is called once this route's file has actually changed |
| +// and a restart was attempted — never on the "up to date" early return, |
| +// since nothing was delivered on that pass and the ack already on file (if |
| +// any) from a previous pass still describes what the route is running. |
| +func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, ack func(ConfigAck)) { |
| // The host is joined into a root-written path; a registry row carrying |
| // a separator must never place a file outside envDir. Same trust level |
| // as release's live/<host> symlinks, but this one is one line to pin. |
| @@ -431,11 +443,40 @@ func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.Pr |
| fmt.Printf(" %s: rename %s: %v\n", rt.Host, path, err) |
| return |
| } |
| - // No rt.Hibernate special case on purpose: try-restart is a no-op on an |
| - // inactive unit, so a hibernated route's file converges without waking it. |
| - if err := restart(rt.Host); err != nil { |
| + // The config-adoption ack (spec §5): built from what THIS pass actually |
| + // rendered and did, not re-derived later from the file on disk — the |
| + // route's shape, the relay identities (if any) it just wrote, and |
| + // whether the restart below woke a real process or found a hibernating |
| + // one with nothing to wake. |
| + a := ConfigAck{Host: rt.Host, Shape: shapeOf(rt), At: time.Now()} |
| + if suffixes, err := relaySuffixesFromRelayVar(m, priv); err != nil { |
| + // A CARLOS_RELAY entry that fails to decode is loud on its own — |
| + // RenderEnv already refused this render if the failure were a |
| + // decrypt error shared by every var, so this is specific to the |
| + // relay var (a corrupt or foreign-shaped value) and must not stop |
| + // the ack the rest of the route earned. |
| + fmt.Printf(" %s: config updated, but CARLOS_RELAY could not be read for the config-ack: %v\n", rt.Host, err) |
| + } else { |
| + a.RelaySuffixes = suffixes |
| + } |
| + // No rt.Hibernate special case in the restart CALL itself: try-restart |
| + // is a no-op on an inactive unit, so a hibernated route's file converges |
| + // without waking it. The ack still has to say so, though — an inactive |
| + // unit's try-restart returns success with nothing actually restarted, |
| + // and reporting that as an ordinary "restarted" would tell a member |
| + // their sleeping route already runs a key it will only pick up on wake. |
| + switch err := restart(rt.Host); { |
| + case err != nil: |
| fmt.Printf(" %s: config updated but restart failed: %v\n", rt.Host, err) |
| - return |
| + a.RestartError = err.Error() |
| + case rt.Hibernate: |
| + a.Hibernating = true |
| + fmt.Printf(" %s: config updated, hibernating — delivered on wake\n", rt.Host) |
| + default: |
| + a.Restarted = true |
| + fmt.Printf(" %s: config updated, restarted\n", rt.Host) |
| + } |
| + if ack != nil { |
| + ack(a) |
| } |
| - fmt.Printf(" %s: config updated, restarted\n", rt.Host) |
| } |
diff --git a/internal/appconfig/sync_test.go b/internal/appconfig/sync_test.go| index c331df0b5..2f4c10734 100644 |
| --- a/internal/appconfig/sync_test.go |
| +++ b/internal/appconfig/sync_test.go |
| @@ -56,7 +56,7 @@ func TestSyncWritesFileAndRestartsOnChange(t *testing.T) { |
| var restarted []string |
| restart := func(host string) error { restarted = append(restarted, host); return nil } |
| - if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| @@ -74,7 +74,7 @@ func TestSyncWritesFileAndRestartsOnChange(t *testing.T) { |
| // A second pass with nothing changed must not write or restart again. |
| restarted = nil |
| - if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil { |
| t.Fatalf("Sync (second pass): %v", err) |
| } |
| if len(restarted) != 0 { |
| @@ -102,7 +102,7 @@ func TestSyncSkipsRoutesWithNoConfigEverSet(t *testing.T) { |
| } |
| var restarted []string |
| restart := func(host string) error { restarted = append(restarted, host); return nil } |
| - if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| if _, err := os.Stat(filepath.Join(envDir, "bare.example.com.env")); !os.IsNotExist(err) { |
| @@ -145,7 +145,7 @@ func TestSyncLeavesHandStagedFileAloneWhenNoManifestExists(t *testing.T) { |
| if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error { |
| restarted = append(restarted, h) |
| return nil |
| - }); err != nil { |
| + }, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| @@ -190,7 +190,7 @@ func TestSyncConvergesToAgentOwnedOnlyAfterEveryVarIsUnset(t *testing.T) { |
| } |
| var restarted []string |
| restart := func(h string) error { restarted = append(restarted, h); return nil } |
| - if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| path := filepath.Join(envDir, "keymail.example.com.env") |
| @@ -202,7 +202,7 @@ func TestSyncConvergesToAgentOwnedOnlyAfterEveryVarIsUnset(t *testing.T) { |
| t.Fatalf("Unset: %v", err) |
| } |
| restarted = nil |
| - if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil { |
| t.Fatalf("Sync (after unset): %v", err) |
| } |
| got, err := os.ReadFile(path) |
| @@ -258,7 +258,7 @@ func TestSyncReapsEnvFilesForHostsWithNoRoute(t *testing.T) { |
| } |
| } |
| - if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| @@ -299,7 +299,7 @@ func TestSyncEmptyRegistryLeavesEnvFilesAlone(t *testing.T) { |
| t.Fatalf("stage env file: %v", err) |
| } |
| - if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| @@ -341,7 +341,7 @@ func TestSyncSkipsExecBackedRoutes(t *testing.T) { |
| if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error { |
| restarted = append(restarted, h) |
| return nil |
| - }); err != nil { |
| + }, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| if _, err := os.Stat(filepath.Join(envDir, "execshape.example.com.env")); !os.IsNotExist(err) { |
| @@ -380,7 +380,7 @@ func TestSyncReapsStaleTmpFiles(t *testing.T) { |
| } |
| } |
| - if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| @@ -400,7 +400,7 @@ func TestSyncHostRefusesNonBareHostname(t *testing.T) { |
| } |
| m := &Manifest{Schema: 1, App: "evil", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}} |
| for _, host := range []string{"../escape", "a/b", "", ".", ".."} { |
| - syncHost(registry.Route{Host: host}, m, false, nil, envDir, func(string) error { return nil }) |
| + syncHost(registry.Route{Host: host}, m, false, nil, envDir, func(string) error { return nil }, nil) |
| } |
| if _, err := os.Stat(filepath.Join(envDir, "..", "escape.env")); !os.IsNotExist(err) { |
| t.Fatalf("a path-escaping host wrote outside envDir, stat err = %v", err) |
| @@ -434,7 +434,7 @@ func TestSyncSurvivesAMissingEnvDir(t *testing.T) { |
| }); err != nil { |
| t.Fatalf("Upsert: %v", err) |
| } |
| - if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil { |
| t.Fatalf("Sync with a missing env dir: %v", err) |
| } |
| } |
| @@ -488,7 +488,7 @@ func TestSyncRendersEachRoutesBoundEnvironment(t *testing.T) { |
| var restarted []string |
| restart := func(h string) error { restarted = append(restarted, h); return nil } |
| - if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| @@ -521,7 +521,7 @@ func TestSyncRendersEachRoutesBoundEnvironment(t *testing.T) { |
| before[host] = fi |
| } |
| restarted = nil |
| - if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil { |
| t.Fatalf("Sync (second pass): %v", err) |
| } |
| if len(restarted) != 0 { |
| @@ -585,7 +585,7 @@ func TestSyncLeavesEnvFileUntouchedWhenBoundEnvironmentIsMissing(t *testing.T) { |
| if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error { |
| restarted = append(restarted, h) |
| return nil |
| - }); err != nil { |
| + }, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| @@ -649,7 +649,7 @@ func TestSyncDoesNotReapEnvFileWhenEnvironmentIsDeleted(t *testing.T) { |
| if err := os.WriteFile(path, []byte("KEEP=1\n"), 0o600); err != nil { |
| t.Fatalf("stage env file: %v", err) |
| } |
| - if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| got, err := os.ReadFile(path) |
| @@ -712,7 +712,7 @@ func TestSyncRefusesToReplaceStagedFileWhenAppHasNoDefaultBundle(t *testing.T) { |
| if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error { |
| restarted = append(restarted, h) |
| return nil |
| - }); err != nil { |
| + }, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| @@ -778,7 +778,7 @@ func TestSyncEnvironmentOnlyAppKeepsConvergingAfterItsOwnFirstWrite(t *testing.T |
| } |
| var restarted []string |
| restart := func(h string) error { restarted = append(restarted, h); return nil } |
| - if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil { |
| t.Fatalf("Sync (pass 1): %v", err) |
| } |
| path := filepath.Join(envDir, "fresh.example.com.env") |
| @@ -794,7 +794,7 @@ func TestSyncEnvironmentOnlyAppKeepsConvergingAfterItsOwnFirstWrite(t *testing.T |
| t.Fatalf("SaveEnvironment (edit): %v", err) |
| } |
| restarted = nil |
| - if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil { |
| t.Fatalf("Sync (pass 2): %v", err) |
| } |
| got, err := os.ReadFile(path) |
| @@ -845,7 +845,7 @@ func TestSyncRefusesToReplaceAnUnparseableFileWhenAppHasNoDefaultBundle(t *testi |
| if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error { |
| restarted = append(restarted, h) |
| return nil |
| - }); err != nil { |
| + }, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| got, err := os.ReadFile(path) |
| @@ -891,14 +891,14 @@ func TestSyncEnvOnlyKeyRemovalIsRefusedUntilADefaultBundleExists(t *testing.T) { |
| }); err != nil { |
| t.Fatalf("Upsert: %v", err) |
| } |
| - if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil { |
| t.Fatalf("Sync (pass 1): %v", err) |
| } |
| path := filepath.Join(envDir, "shrink.example.com.env") |
| if _, err := UnsetEnvironment(ctx, s, "bab", "keymail", "demo", []string{"GONE"}); err != nil { |
| t.Fatalf("UnsetEnvironment: %v", err) |
| } |
| - if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil { |
| t.Fatalf("Sync (pass 2): %v", err) |
| } |
| if got, _ := os.ReadFile(path); string(got) != "GONE=2\nKEEP=1\nCARLOS_PURGE=header\n" { |
| @@ -910,7 +910,7 @@ func TestSyncEnvOnlyKeyRemovalIsRefusedUntilADefaultBundleExists(t *testing.T) { |
| if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"KEEP": "1"}); err != nil { |
| t.Fatalf("SetEnv: %v", err) |
| } |
| - if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil { |
| t.Fatalf("Sync (pass 3): %v", err) |
| } |
| if got, _ := os.ReadFile(path); string(got) != "KEEP=1\nCARLOS_PURGE=header\n" { |
| @@ -961,7 +961,7 @@ func TestSyncMergedRenderIsAuthoritativeOnceADefaultBundleExists(t *testing.T) { |
| if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error { |
| restarted = append(restarted, h) |
| return nil |
| - }); err != nil { |
| + }, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| @@ -1011,7 +1011,7 @@ func TestSyncEnvironmentOnlyAppWithNoDefaultBundle(t *testing.T) { |
| if err := os.WriteFile(filepath.Join(envDir, "emptyfile.example.com.env"), nil, 0o600); err != nil { |
| t.Fatalf("stage empty env file: %v", err) |
| } |
| - if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| for _, host := range []string{"demo.example.com", "emptyfile.example.com"} { |
| @@ -1054,7 +1054,7 @@ func TestSyncHostConvergesAgentOwnedVarWithoutRewrite(t *testing.T) { |
| var restarted int |
| restart := func(string) error { restarted++; return nil } |
| - syncHost(rt, m, false, nil, envDir, restart) |
| + syncHost(rt, m, false, nil, envDir, restart, nil) |
| path := filepath.Join(envDir, "steady.example.com.env") |
| got, err := os.ReadFile(path) |
| if err != nil { |
| @@ -1072,7 +1072,7 @@ func TestSyncHostConvergesAgentOwnedVarWithoutRewrite(t *testing.T) { |
| } |
| // Second, identical pass. |
| - syncHost(rt, m, false, nil, envDir, restart) |
| + syncHost(rt, m, false, nil, envDir, restart, nil) |
| if restarted != 1 { |
| t.Fatalf("restarted = %d after second identical sync, want still 1 (no re-restart)", restarted) |
| } |
| @@ -1119,7 +1119,7 @@ func TestSyncMaterializesEveryRouteOfAMultiRouteApp(t *testing.T) { |
| t.Fatalf("Upsert %s: %v", host, err) |
| } |
| } |
| - if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil { |
| t.Fatalf("Sync: %v", err) |
| } |
| for _, host := range []string{"stable.keymail.example.com", "canary.keymail.example.com"} { |
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go| index 0f0f8e5b6..32eca0b6d 100644 |
| --- a/internal/appconfig/syncexec.go |
| +++ b/internal/appconfig/syncexec.go |
| @@ -42,6 +42,7 @@ import ( |
| "path/filepath" |
| "sort" |
| "strings" |
| + "time" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -61,7 +62,12 @@ const configEnvSuffix = ".config.env" |
| // (pipelines design §4a): two routes of ONE app bound to different |
| // environments resolve to different manifests, and keying on (acct, app) |
| // alone would render whichever route was seen first into both files. |
| -func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, appsDomain string) error { |
| +// |
| +// ack, if non-nil, is called once per route this pass actually converges |
| +// (wrote a changed or removed file) with that route's config-adoption |
| +// acknowledgement (spec §5, configack.go) — nil means no acks, same |
| +// contract as Sync's. |
| +func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, appsDomain string, ack func(ConfigAck)) error { |
| routes, err := reg.List() |
| if err != nil { |
| return err |
| @@ -413,7 +419,7 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv |
| hostBounce = nil |
| header = execAddrConfigHeader |
| } |
| - syncExecHost(rt, r.m, Key(k.acct, k.app), priv, dataDir, hostBounce, header) |
| + syncExecHost(rt, r.m, Key(k.acct, k.app), priv, dataDir, hostBounce, header, ack) |
| } |
| // Stale-secrets notice (issue #18 design ruling, ratified 2026-08-03 |
| // review): an addr-shaped route whose app is DEFINITIVELY not opted in |
| @@ -844,7 +850,15 @@ const execAddrConfigHeader = `# Written by carlos (SyncExec, issue #18). Carlos |
| // unified) — folded into the SAME diff/write/rename path as the vars |
| // themselves, so a header-only change (e.g. its text edited in a future |
| // release) converges exactly like a var change would, once. |
| -func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, header string) { |
| +// ack, if non-nil, is called once this route's file has actually changed |
| +// (written or removed) with its config-adoption acknowledgement (spec §5). |
| +// Restarted is always false and RestartError always empty here — unlike |
| +// syncHost's unit path, this shape's bounce is best-effort and |
| +// fire-and-forget (bounceLogged swallows its error), and a tcp-exec-file |
| +// route's process belongs to an external supervisor this platform never |
| +// restarts at all; that shape's cutover is a member acknowledgement |
| +// (`carlos relay adopted`) instead. |
| +func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, header string, ack func(ConfigAck)) { |
| if rt.Host == "" || rt.Host == "." || rt.Host == ".." || rt.Host != filepath.Base(rt.Host) { |
| fmt.Printf(" %q: not a bare hostname, refusing to derive a config file path from it\n", rt.Host) |
| return |
| @@ -910,6 +924,15 @@ func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.Private |
| } |
| fmt.Printf(" %s: config updated\n", rt.Host) |
| bounceLogged(rt.Host, bounce) |
| + if ack != nil { |
| + a := ConfigAck{Host: rt.Host, Shape: shapeOf(rt), At: time.Now()} |
| + if suffixes, err := relaySuffixesFromRelayVar(m, priv); err != nil { |
| + fmt.Printf(" %s: config updated, but CARLOS_RELAY could not be read for the config-ack: %v\n", rt.Host, err) |
| + } else { |
| + a.RelaySuffixes = suffixes |
| + } |
| + ack(a) |
| + } |
| } |
| // bounceLogged: bounce is best-effort — a failure is logged, never |
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go| index 372e83818..e97709589 100644 |
| --- a/internal/appconfig/syncexec_test.go |
| +++ b/internal/appconfig/syncexec_test.go |
| @@ -94,7 +94,7 @@ func TestSyncExecWritesConfigFileAndBouncesOnChange(t *testing.T) { |
| } |
| var bounced []string |
| bounce := func(h string) error { bounced = append(bounced, h); return nil } |
| - if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| got, err := os.ReadFile(filepath.Join(dd, "inst.example.com.config.env")) |
| @@ -112,7 +112,7 @@ func TestSyncExecWritesConfigFileAndBouncesOnChange(t *testing.T) { |
| } |
| // Second pass, nothing changed: no bounce. |
| bounced = nil |
| - if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil { |
| t.Fatalf("SyncExec (second): %v", err) |
| } |
| if len(bounced) != 0 { |
| @@ -140,7 +140,7 @@ func TestSyncExecRemovesFileWhenManifestDeleted(t *testing.T) { |
| if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error { |
| bounced = append(bounced, h) |
| return nil |
| - }, ""); err != nil { |
| + }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| if _, err := os.Stat(path); !os.IsNotExist(err) { |
| @@ -185,7 +185,7 @@ func TestSyncExecMassRemovalGuardRefusesWhenEveryGroupResolvesAbsent(t *testing. |
| if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error { |
| bounced = append(bounced, h) |
| return nil |
| - }, ""); err != nil { |
| + }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| if _, err := os.Stat(pathA); err != nil { |
| @@ -230,7 +230,7 @@ func TestSyncExecMassRemovalGuardDoesNotBlockNormalConvergence(t *testing.T) { |
| if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error { |
| bounced = append(bounced, h) |
| return nil |
| - }, ""); err != nil { |
| + }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| if _, err := os.Stat(pathA); err != nil { |
| @@ -282,7 +282,7 @@ func TestSyncExecRendersEachRoutesBoundEnvironment(t *testing.T) { |
| t.Fatalf("Upsert %s: %v", rt.Host, err) |
| } |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| want := map[string]string{ |
| @@ -329,7 +329,7 @@ func TestSyncExecLeavesFileUntouchedWhenBoundEnvironmentIsMissing(t *testing.T) |
| if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error { |
| bounced = append(bounced, h) |
| return nil |
| - }, ""); err != nil { |
| + }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| got, err := os.ReadFile(path) |
| @@ -392,7 +392,7 @@ func TestSyncExecOverlayOnlyAppDropsStaleKeys(t *testing.T) { |
| if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error { |
| bounced = append(bounced, h) |
| return nil |
| - }, ""); err != nil { |
| + }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| got, err := os.ReadFile(path) |
| @@ -440,7 +440,7 @@ func TestSyncExecMassRemovalGuardIgnoresErroredGroups(t *testing.T) { |
| if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error { |
| bounced = append(bounced, h) |
| return nil |
| - }, ""); err != nil { |
| + }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| for _, h := range []string{"a.example.com", "b.example.com", "c.example.com"} { |
| @@ -476,7 +476,7 @@ func TestSyncExecNeverTouchesInstanceEnvOrOutOfScopeRoutes(t *testing.T) { |
| t.Fatalf("Upsert %s: %v", rt.Host, err) |
| } |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| if got, _ := os.ReadFile(own); string(got) != "CARLOS_ADMIN_TOKEN=tok\n" { |
| @@ -516,7 +516,7 @@ func TestSyncExecReapsOnlyItsOwnNamespace(t *testing.T) { |
| t.Fatalf("stage %s: %v", name, err) |
| } |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| for name, keep := range files { |
| @@ -564,7 +564,7 @@ func TestSyncExecReapsOnlyItsOwnNamespaceWithAddrShapeInTheMix(t *testing.T) { |
| if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error { |
| bounced = append(bounced, h) |
| return nil |
| - }, ""); err != nil { |
| + }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| for name, keep := range files { |
| @@ -592,7 +592,7 @@ func TestSyncExecEmptyRegistryRemovesNothing(t *testing.T) { |
| if err := os.WriteFile(path, []byte("X=1\n"), 0o600); err != nil { |
| t.Fatalf("stage: %v", err) |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| if _, err := os.Stat(path); err != nil { |
| @@ -605,7 +605,7 @@ func TestSyncExecRefusesNonBareHostname(t *testing.T) { |
| pk, _ := ParsePrivateKey(priv, "test") |
| m := &Manifest{Schema: 1, App: "eleven", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}} |
| for _, host := range []string{"../escape", "a/b", "", ".", ".."} { |
| - syncExecHost(registry.Route{Host: host}, m, "apps/bab/eleven/config.json", pk, dd, func(string) error { return nil }, "") |
| + syncExecHost(registry.Route{Host: host}, m, "apps/bab/eleven/config.json", pk, dd, func(string) error { return nil }, "", nil) |
| } |
| ents, err := os.ReadDir(dd) |
| if err != nil { |
| @@ -640,7 +640,7 @@ func TestSyncExecOptedInAddrRouteMaterializesFile(t *testing.T) { |
| }); err != nil { |
| t.Fatalf("Upsert: %v", err) |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env")) |
| @@ -665,7 +665,7 @@ func TestSyncExecOptedInAddrRouteMaterializesFile(t *testing.T) { |
| t.Fatal(err) |
| } |
| time.Sleep(10 * time.Millisecond) // ensure a rewrite would move mtime |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec (second pass): %v", err) |
| } |
| after, err := os.Stat(path) |
| @@ -697,7 +697,7 @@ func TestSyncExecUnoptedAddrRouteIsSkipped(t *testing.T) { |
| }); err != nil { |
| t.Fatalf("Upsert: %v", err) |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| if _, err := os.Stat(filepath.Join(dd, "demo.go.tito.io.config.env")); !os.IsNotExist(err) { |
| @@ -741,7 +741,7 @@ func TestSyncExecUnrecognizedExecDeliveryValueIgnoredNotFatal(t *testing.T) { |
| } |
| var syncErr error |
| out := captureStdout(t, func() { |
| - syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "") |
| + syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil) |
| }) |
| if syncErr != nil { |
| t.Fatalf("SyncExec: %v", syncErr) |
| @@ -790,7 +790,7 @@ func TestSyncExecOptedInAddrRouteFetchFailureLogsAndLeavesFileAsIs(t *testing.T) |
| } |
| // First pass materializes normally — this is the file a transient |
| // blip must leave exactly as it is. |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec (initial): %v", err) |
| } |
| path := filepath.Join(dd, "demo.go.tito.io.config.env") |
| @@ -805,7 +805,7 @@ func TestSyncExecOptedInAddrRouteFetchFailureLogsAndLeavesFileAsIs(t *testing.T) |
| bounce := func(h string) error { bounced = append(bounced, h); return nil } |
| var syncErr error |
| out := captureStdout(t, func() { |
| - syncErr = SyncExec(ctx, reg, failing, pk, dd, bounce, "") |
| + syncErr = SyncExec(ctx, reg, failing, pk, dd, bounce, "", nil) |
| }) |
| if syncErr != nil { |
| t.Fatalf("SyncExec (blip): %v", syncErr) |
| @@ -873,7 +873,7 @@ func TestSyncExecOptedInAddrRouteIsNeverBounced(t *testing.T) { |
| bounced = append(bounced, h) |
| return nil |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil { |
| t.Fatalf("SyncExec (initial write): %v", err) |
| } |
| if len(bounced) != 0 { |
| @@ -884,7 +884,7 @@ func TestSyncExecOptedInAddrRouteIsNeverBounced(t *testing.T) { |
| if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"TITOGO_DEMO_BANNER": "2"}); err != nil { |
| t.Fatalf("SetEnv (change): %v", err) |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil { |
| t.Fatalf("SyncExec (changed content): %v", err) |
| } |
| if len(bounced) != 0 { |
| @@ -933,7 +933,7 @@ func TestSyncExecSocketRouteStillBouncesAlongsideOptedInAddrRoute(t *testing.T) |
| bounced = append(bounced, h) |
| return nil |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| if !slices.Contains(bounced, "inst.example.com") { |
| @@ -979,7 +979,7 @@ func TestSyncExecOptedInAddrRouteHonorsBoundEnvironment(t *testing.T) { |
| }); err != nil { |
| t.Fatalf("Upsert: %v", err) |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env")) |
| @@ -1018,7 +1018,7 @@ func TestSyncExecOptedInAddrAppWithNoVarsConvergesToEmptyFile(t *testing.T) { |
| }); err != nil { |
| t.Fatalf("Upsert: %v", err) |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env")) |
| @@ -1073,7 +1073,7 @@ func TestSyncExecMassRemovalGuardUnaffectedByOptedInAddrAppWithContent(t *testin |
| if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error { |
| bounced = append(bounced, h) |
| return nil |
| - }, ""); err != nil { |
| + }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env")) |
| @@ -1128,7 +1128,7 @@ func TestSyncExecOptOutLeavesStaleFileButWarnsEveryPass(t *testing.T) { |
| }); err != nil { |
| t.Fatalf("Upsert: %v", err) |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec (opted in): %v", err) |
| } |
| path := filepath.Join(dd, "demo.go.tito.io.config.env") |
| @@ -1144,7 +1144,7 @@ func TestSyncExecOptOutLeavesStaleFileButWarnsEveryPass(t *testing.T) { |
| bounce := func(h string) error { bounced = append(bounced, h); return nil } |
| var syncErr error |
| out := captureStdout(t, func() { |
| - syncErr = SyncExec(ctx, reg, s, pk, dd, bounce, "") |
| + syncErr = SyncExec(ctx, reg, s, pk, dd, bounce, "", nil) |
| }) |
| if syncErr != nil { |
| t.Fatalf("SyncExec (opted out): %v", syncErr) |
| @@ -1170,7 +1170,7 @@ func TestSyncExecOptOutLeavesStaleFileButWarnsEveryPass(t *testing.T) { |
| // changed must repeat it, since nothing about the risk (stale |
| // plaintext secrets, forever) has gone away. |
| out2 := captureStdout(t, func() { |
| - syncErr = SyncExec(ctx, reg, s, pk, dd, bounce, "") |
| + syncErr = SyncExec(ctx, reg, s, pk, dd, bounce, "", nil) |
| }) |
| if syncErr != nil { |
| t.Fatalf("SyncExec (opted out, second pass): %v", syncErr) |
| @@ -1205,7 +1205,7 @@ func TestSyncExecNoOptOutWarningWhenNeverOptedIn(t *testing.T) { |
| } |
| var syncErr error |
| out := captureStdout(t, func() { |
| - syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "") |
| + syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil) |
| }) |
| if syncErr != nil { |
| t.Fatalf("SyncExec: %v", syncErr) |
| @@ -1245,7 +1245,7 @@ func TestSyncExecAddrFileCarriesHeaderSocketFileDoesNot(t *testing.T) { |
| t.Fatalf("Upsert %s: %v", rt.Host, err) |
| } |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| addrGot, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env")) |
| @@ -1299,7 +1299,7 @@ func TestSyncExecConvergesToEmptyFileWhenAllVarsUnset(t *testing.T) { |
| var bounced []string |
| bounce := func(h string) error { bounced = append(bounced, h); return nil } |
| - if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil { |
| t.Fatalf("SyncExec (set): %v", err) |
| } |
| got, err := os.ReadFile(path) |
| @@ -1316,7 +1316,7 @@ func TestSyncExecConvergesToEmptyFileWhenAllVarsUnset(t *testing.T) { |
| t.Fatalf("Unset (all vars): %v", err) |
| } |
| bounced = nil |
| - if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil { |
| t.Fatalf("SyncExec (unset): %v", err) |
| } |
| got, err = os.ReadFile(path) |
| @@ -1353,7 +1353,7 @@ func TestReapSparesLiveRoutesInstanceEnvWithConfigSuffix(t *testing.T) { |
| t.Fatalf("Upsert: %v", err) |
| } |
| out := captureStdout(t, func() { |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| }) |
| @@ -1416,7 +1416,7 @@ func TestSyncExecRefusesConfigNamespaceCollision(t *testing.T) { |
| var bounced []string |
| bounce := func(h string) error { bounced = append(bounced, h); return nil } |
| out = captureStdout(t, func() { |
| - if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| }) |
| @@ -1487,7 +1487,7 @@ func TestSyncExecFetchFailureLeavesOtherGroupsConverging(t *testing.T) { |
| failing := &failingKeyStore{Store: s, failKey: Key("bab", "appa"), err: blipErr} |
| var bounced []string |
| bounce := func(h string) error { bounced = append(bounced, h); return nil } |
| - if err := SyncExec(ctx, reg, failing, pk, dd, bounce, ""); err != nil { |
| + if err := SyncExec(ctx, reg, failing, pk, dd, bounce, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| got, err := os.ReadFile(stalePath) |
| @@ -1708,7 +1708,7 @@ func TestSyncExecOptInResolvesPerEnvironment(t *testing.T) { |
| t.Fatalf("Upsert %s: %v", rt.Host, err) |
| } |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| assertConfigFile(t, dd, "connect.go.tito.io", tc.wantRelayFile) |
| @@ -1757,7 +1757,7 @@ func TestSyncExecEnvScopedOptInDeliversTheMergedBundle(t *testing.T) { |
| }); err != nil { |
| t.Fatalf("Upsert: %v", err) |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| got, err := os.ReadFile(filepath.Join(dd, "connect.go.tito.io"+configEnvSuffix)) |
| @@ -1802,7 +1802,7 @@ func TestSyncExecEnvBundleFetchErrorLeavesFileAlone(t *testing.T) { |
| }); err != nil { |
| t.Fatalf("Upsert: %v", err) |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| path := filepath.Join(dd, "connect.go.tito.io"+configEnvSuffix) |
| @@ -1814,7 +1814,7 @@ func TestSyncExecEnvBundleFetchErrorLeavesFileAlone(t *testing.T) { |
| blip := &failingKeyStore{Store: s, failKey: EnvironmentKey("bab", "titogo", "relay"), |
| err: errors.New("503 SlowDown")} |
| out := captureStdout(t, func() { |
| - if err := SyncExec(ctx, reg, blip, pk, dd, func(string) error { return nil }, ""); err != nil { |
| + if err := SyncExec(ctx, reg, blip, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| t.Fatalf("SyncExec (blip): %v", err) |
| } |
| }) |
| @@ -1866,7 +1866,7 @@ func TestSyncExecHoldsConfigWhenHostNamesADifferentAccount(t *testing.T) { |
| if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error { |
| bounced = append(bounced, h) |
| return nil |
| - }, "oncarlos.test"); err != nil { |
| + }, "oncarlos.test", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| if _, err := os.Stat(path); err != nil { |
| @@ -1889,7 +1889,7 @@ func TestSyncExecHoldsConfigWhenHostNamesADifferentAccount(t *testing.T) { |
| if err := os.WriteFile(agreed, []byte("OLD=1\n"), 0o600); err != nil { |
| t.Fatalf("stage: %v", err) |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "oncarlos.test"); err != nil { |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "oncarlos.test", nil); err != nil { |
| t.Fatalf("SyncExec: %v", err) |
| } |
| if _, err := os.Stat(agreed); !os.IsNotExist(err) { |
diff --git a/internal/console/apirelay.go b/internal/console/apirelay.go| index c3a2854a3..b3a941747 100644 |
| --- a/internal/console/apirelay.go |
| +++ b/internal/console/apirelay.go |
| @@ -22,16 +22,28 @@ |
| // that this deployment runs a relay a caller might rotate — the same |
| // reasoning apiSystemOwner's family already applies to `carlos system`. |
| // |
| -// DELIVERY IS DELIBERATELY THIN FOR NOW. Spec §5 describes five delivery |
| -// states a route can be in, most of them read from a per-route config-ack |
| -// (`control/config-ack/<box>/<route host>.json`) that Task 12 has not written |
| -// yet. This file reports only what a route's SHAPE, its config bundle and the |
| -// record's own Cutovers can say today: that the key was written, that a route |
| -// this platform cannot restart is waiting on the member, that a route with no |
| -// config delivery will never receive one at all, and that a member's |
| -// acknowledgement either does or no longer does name the identities issued |
| -// now. It marks the place Task 12 fills in with a TODO rather than inventing |
| -// the ack's shape. |
| +// DELIVERY READS THE PER-ROUTE CONFIG-ACK. Spec §5 describes five delivery |
| +// states a route can be in; most of them come from a per-route config-ack |
| +// (`control/config-ack/<box>/<route host>.json`, internal/appconfig/ |
| +// configack.go) config sync writes after it converges a route. There is no |
| +// per-host box list to narrow that read by (relayHeartbeats' own doc |
| +// comment says the same for control/relay-ack/<box>.json), so relayReply |
| +// reads every ack that exists (relayConfigAcks, relayops.go) and matches by |
| +// Host. An ack counts only when its RelaySuffixes equal exactly what the |
| +// record currently has issued — the same freshness rule relayCutoverFor's |
| +// maps.Equal already applies to a member's `carlos relay adopted`: an ack |
| +// that named an OLDER incarnation's identities must not read as "this route |
| +// runs what was just issued", so a stale ack falls back to "written, |
| +// awaiting delivery" rather than reporting an adoption that never happened |
| +// under the current keys. |
| +// |
| +// syncExecHost's ack (the socket-exec and tcp-exec-file shapes) always |
| +// leaves Restarted false and RestartError empty — its bounce is |
| +// best-effort and fire-and-forget, and a tcp-exec-file route's process is |
| +// never restarted by this platform at all — so "adopted at <time>, |
| +// restarted" is reachable only for a unit-backed route today; a |
| +// socket-exec route with a matching ack stays "written, awaiting delivery" |
| +// until its own restart signal exists to report. |
| // |
| // Spec: docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md §2, §5. |
| package console |
| @@ -46,6 +58,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/relay" |
| ) |
| @@ -133,6 +146,16 @@ type apiRelayRoute struct { |
| // `carlos relay adopted --host` for this route (relay.RouteCutover). |
| AckedAt string `json:"acked_at,omitempty"` |
| Cutover bool `json:"cutover"` |
| + // ConfigAckAt and ConfigAckShape echo config sync's own per-route |
| + // acknowledgement (control/config-ack/<box>/<route host>.json) when one |
| + // exists naming exactly the identities this record currently has |
| + // issued — empty until then, and left empty for a stale ack (one |
| + // naming an older incarnation's identities) exactly like AckedAt above. |
| + // Distinct from AckedAt/Cutover: those describe a MEMBER's `carlos |
| + // relay adopted`, this describes what the PLATFORM'S OWN config sync |
| + // last rendered and, where it could, restarted. |
| + ConfigAckAt string `json:"config_acked_at,omitempty"` |
| + ConfigAckShape string `json:"config_ack_shape,omitempty"` |
| } |
| // apiRelayRegion is one region's health as this app's status sees it, built |
| @@ -263,6 +286,29 @@ func relayCutoverFor(rec *relay.Record, host string) *relay.RouteCutover { |
| return nil |
| } |
| +// relayMatchingAck finds the freshest config-ack among acks naming host |
| +// whose RelaySuffixes equal issued exactly — nil if none does. The equality |
| +// check is the same freshness rule relayCutoverFor's caller applies to a |
| +// member's Cutover (maps.Equal against rec.IssuedSuffixes): an ack written |
| +// before a rotation names the identities that rotation retired, and |
| +// reporting it as current would tell a member their route runs a key it |
| +// has never actually seen. "Freshest" (latest At) only matters if more than |
| +// one box's ack somehow matches, which config sync's own per-(box,host) key |
| +// makes rare in practice but not impossible across a route migration. |
| +func relayMatchingAck(acks []appconfig.ConfigAck, host string, issued map[string]string) *appconfig.ConfigAck { |
| + var best *appconfig.ConfigAck |
| + for i := range acks { |
| + a := &acks[i] |
| + if a.Host != host || !maps.Equal(a.RelaySuffixes, issued) { |
| + continue |
| + } |
| + if best == nil || a.At.After(best.At) { |
| + best = a |
| + } |
| + } |
| + return best |
| +} |
| + |
| // relayReply assembles apiRelayReply from a record that has already been |
| // read or just been written. rec is never nil here — the callers that could |
| // see one (handleAPIRelayGet) answer their own 404 first. |
| @@ -276,6 +322,14 @@ func (s *Server) relayReply(ctx context.Context, acct *Account, app string, rec |
| if err != nil { |
| return apiRelayReply{}, err |
| } |
| + // Read once for the whole reply, not once per route: there is no |
| + // per-host box list to narrow this by (see the file's own doc comment), |
| + // so every route below filters this same slice by Host instead of each |
| + // paying for its own List+Get pass over every box's acks. |
| + acks, err := s.relayConfigAcks(ctx) |
| + if err != nil { |
| + return apiRelayReply{}, err |
| + } |
| for _, rt := range routes { |
| if rt.Kind == "static" { |
| // A static route runs no process at all — relayDeliverable |
| @@ -312,13 +366,29 @@ func (s *Server) relayReply(ctx context.Context, acct *Account, app string, rec |
| "opt it into exec_delivery file or move it to a unit" |
| default: |
| row.Delivery = "written, awaiting delivery" |
| - if shape == relayShapeFile { |
| - // The ONE shape whose process this platform cannot bounce. |
| - // The console cannot know the file has landed until Task 12's |
| - // ack exists, but it can say whose move it is — and leaving |
| - // that out is how a route sits un-adopted for a month and |
| - // blocks a retirement nobody can explain. |
| - row.Delivery = "file delivered, awaiting `carlos relay adopted --host " + rt.Host + "` after you restart it" |
| + // A config-ack counts only when it names EXACTLY the identities |
| + // this record has issued now (relayMatchingAck) — the same |
| + // freshness rule the member-Cutover comparison just below makes |
| + // for `carlos relay adopted`. No match at all (never synced yet, |
| + // or a stale ack left over from before a rotation) leaves the |
| + // route at the base "written, awaiting delivery" rather than |
| + // reporting an adoption under keys the route may never have run. |
| + if ca := relayMatchingAck(acks, rt.Host, rec.IssuedSuffixes); ca != nil { |
| + row.ConfigAckAt = ca.At.UTC().Format(time.RFC3339) |
| + row.ConfigAckShape = ca.Shape |
| + switch { |
| + case shape == relayShapeFile: |
| + // The ONE shape whose process this platform cannot |
| + // bounce: the ack proves the FILE landed, but adoption |
| + // itself is still the member's move (below). |
| + row.Delivery = "file delivered, awaiting `carlos relay adopted --host " + rt.Host + "` after you restart it" |
| + case ca.RestartError != "": |
| + row.Delivery = "restart failed: " + ca.RestartError |
| + case ca.Hibernating: |
| + row.Delivery = "hibernating, delivered on wake" |
| + case ca.Restarted: |
| + row.Delivery = "adopted at " + row.ConfigAckAt + ", restarted" |
| + } |
| } |
| if cut := relayCutoverFor(rec, rt.Host); cut != nil { |
| row.AckedAt = cut.At.UTC().Format(time.RFC3339) |
| @@ -336,14 +406,6 @@ func (s *Server) relayReply(ctx context.Context, acct *Account, app string, rec |
| } |
| } |
| } |
| - // TODO(task 12): once internal/appconfig/sync.go writes |
| - // control/config-ack/<box>/<route host>.json, read it here and |
| - // report the fuller set of states spec §5 describes for a unit or |
| - // socket-exec route ("adopted at <time>, restarted", "restart |
| - // failed", "hibernating, delivered on wake") instead of the states |
| - // above, which is all the console can see without it — including |
| - // whether the FILE half of a tcp-exec-file route's sentence has |
| - // actually happened yet. |
| out.Routes = append(out.Routes, row) |
| } |
diff --git a/internal/console/apirelay_test.go b/internal/console/apirelay_test.go| index 105a816dc..b39fc9557 100644 |
| --- a/internal/console/apirelay_test.go |
| +++ b/internal/console/apirelay_test.go |
| @@ -442,11 +442,29 @@ func TestAPIRelayStatusNamesEachRouteShapeAndWhoseMoveItIs(t *testing.T) { |
| } |
| wantRoute(t, rows, "a.titogo.oncarlos.com", "unit", "written, awaiting delivery") |
| wantRoute(t, rows, "b.titogo.oncarlos.com", "socket-exec", "written, awaiting delivery") |
| - wantRoute(t, rows, "c.titogo.oncarlos.com", "tcp-exec-file", |
| - "carlos relay adopted --host c.titogo.oncarlos.com") |
| + // No box has synced yet, so no config-ack exists — c reads exactly like |
| + // a and b until one does (Task 12: a tcp-exec-file route's "file |
| + // delivered, awaiting adopted" sentence is earned by an ack, not |
| + // assumed from its shape alone). |
| + wantRoute(t, rows, "c.titogo.oncarlos.com", "tcp-exec-file", "written, awaiting delivery") |
| wantRoute(t, rows, "d.titogo.oncarlos.com", "no-delivery", |
| "no config delivery", "exec_delivery file") |
| + // Now a box's config sync delivers the file and writes its ack |
| + // (appconfig.WriteConfigAck, seeded here exactly as syncExecHost would) |
| + // — only then does c's row say whose move it is. |
| + issued := relayRecord(t, fx, "titogo").IssuedSuffixes |
| + if err := appconfig.WriteConfigAck(ctx, fx.rel, appconfig.ConfigAck{ |
| + Box: "box1", Host: "c.titogo.oncarlos.com", Shape: "tcp-exec-file", At: time.Now(), |
| + RelaySuffixes: cloneSuffixes(issued), |
| + }); err != nil { |
| + t.Fatalf("seed ack c: %v", err) |
| + } |
| + _, out = getJSON(t, relayURL(fx, "titogo"), token) |
| + rows = relayRouteRows(t, out) |
| + wantRoute(t, rows, "c.titogo.oncarlos.com", "tcp-exec-file", |
| + "carlos relay adopted --host c.titogo.oncarlos.com") |
| + |
| // The undeliverable route cannot be acknowledged away. |
| status, out = postJSON(t, relayURL(fx, "titogo")+"/adopted", |
| map[string]any{"host": "d.titogo.oncarlos.com"}, token) |
| @@ -526,3 +544,80 @@ func TestAPIRelayRotateStagingRefusalsAreClientErrors(t *testing.T) { |
| t.Fatalf("error = %q, want the no-live-relay sentence", msg) |
| } |
| } |
| + |
| +// cloneSuffixes copies a suffix map so a test can hand one ack the record's |
| +// real IssuedSuffixes and another a value that merely LOOKS like it without |
| +// aliasing the record's own map. |
| +func cloneSuffixes(m map[string]string) map[string]string { |
| + out := make(map[string]string, len(m)) |
| + for k, v := range m { |
| + out[k] = v |
| + } |
| + return out |
| +} |
| + |
| +// TestAPIRelayStatusReadsConfigAcks is Task 12's console half: three |
| +// unit-backed routes, one config-ack each, seeded exactly as config sync |
| +// itself writes them (control/config-ack/<box>/<host>.json, |
| +// appconfig.WriteConfigAck) rather than through any sync machinery — the |
| +// door's whole job here is reading and matching what's already in the |
| +// bucket, not producing it. An ack naming the issued identities and a |
| +// successful restart reads as adopted; one naming them with a restart |
| +// error reads as failed; one naming identities from BEFORE this app's |
| +// enable (a stale ack, the shape a rotation or a re-enable would leave |
| +// behind) must not read as adopted at all — see relayMatchingAck's own |
| +// doc comment for why. |
| +func TestAPIRelayStatusReadsConfigAcks(t *testing.T) { |
| + fx, _ := relayFixture(t, "code-relay-config-acks") |
| + ctx := context.Background() |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-relay-config-acks-2") |
| + |
| + fx.addRoute(t, registry.Route{Host: "a.titogo.oncarlos.com", App: "titogo", |
| + Kind: "service", Unit: "titogo-a.service", Status: "ready"}) |
| + fx.addRoute(t, registry.Route{Host: "b.titogo.oncarlos.com", App: "titogo", |
| + Kind: "service", Unit: "titogo-b.service", Status: "ready"}) |
| + fx.addRoute(t, registry.Route{Host: "c.titogo.oncarlos.com", App: "titogo", |
| + Kind: "service", Unit: "titogo-c.service", Status: "ready"}) |
| + |
| + if status, out := postJSON(t, relayURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("enable = %d %v, want 200", status, out) |
| + } |
| + issued := relayRecord(t, fx, "titogo").IssuedSuffixes |
| + if issued["eu-west-1"] == "" { |
| + t.Fatalf("enable issued nothing for eu-west-1: %v", issued) |
| + } |
| + |
| + now := time.Now() |
| + if err := appconfig.WriteConfigAck(ctx, fx.rel, appconfig.ConfigAck{ |
| + Box: "box1", Host: "a.titogo.oncarlos.com", Shape: "unit", At: now, |
| + Restarted: true, RelaySuffixes: cloneSuffixes(issued), |
| + }); err != nil { |
| + t.Fatalf("seed ack a: %v", err) |
| + } |
| + if err := appconfig.WriteConfigAck(ctx, fx.rel, appconfig.ConfigAck{ |
| + Box: "box1", Host: "b.titogo.oncarlos.com", Shape: "unit", At: now, |
| + RestartError: "systemctl try-restart carlos-app@b.titogo.oncarlos.com: exit status 1", |
| + RelaySuffixes: cloneSuffixes(issued), |
| + }); err != nil { |
| + t.Fatalf("seed ack b: %v", err) |
| + } |
| + if err := appconfig.WriteConfigAck(ctx, fx.rel, appconfig.ConfigAck{ |
| + Box: "box1", Host: "c.titogo.oncarlos.com", Shape: "unit", At: now, |
| + Restarted: true, |
| + RelaySuffixes: map[string]string{"eu-west-1": "abc:titogo:stale0000000:k0"}, |
| + }); err != nil { |
| + t.Fatalf("seed ack c: %v", err) |
| + } |
| + |
| + _, out := getJSON(t, relayURL(fx, "titogo"), token) |
| + rows := relayRouteRows(t, out) |
| + wantRoute(t, rows, "a.titogo.oncarlos.com", "unit", "adopted at", "restarted") |
| + wantRoute(t, rows, "b.titogo.oncarlos.com", "unit", "restart failed", |
| + "systemctl try-restart carlos-app@b.titogo.oncarlos.com: exit status 1") |
| + if got := str(t, rows["c.titogo.oncarlos.com"], "delivery"); strings.Contains(got, "adopted") { |
| + t.Fatalf("c delivery = %q, a stale-suffix ack must not read as adopted", got) |
| + } |
| + if got := str(t, rows["c.titogo.oncarlos.com"], "delivery"); got != "written, awaiting delivery" { |
| + t.Fatalf("c delivery = %q, want the base written-awaiting-delivery sentence", got) |
| + } |
| +} |
diff --git a/internal/console/relayops.go b/internal/console/relayops.go| index ad0916ad3..57c199aa5 100644 |
| --- a/internal/console/relayops.go |
| +++ b/internal/console/relayops.go |
| @@ -67,6 +67,10 @@ const ( |
| relayIssuerKey = "control/relay-issuer.json" |
| // relayAckPrefix is where each armed relay writes its heartbeat. |
| relayAckPrefix = "control/relay-ack/" |
| + // configAckPrefix is where each box's config sync writes a per-route |
| + // config-adoption acknowledgement (spec §5, internal/appconfig/ |
| + // configack.go): configAckPrefix + "<box>/<route host>.json". |
| + configAckPrefix = "control/config-ack/" |
| // The lease's two numbers, from spec §5: sixty seconds of validity, |
| // renewed every twenty. Three renewals inside one TTL means a single |
| @@ -1158,6 +1162,38 @@ func (s *Server) relayHeartbeats(ctx context.Context) ([]relay.Heartbeat, error) |
| return out, nil |
| } |
| +// relayConfigAcks reads every per-route config-adoption acknowledgement |
| +// under configAckPrefix, across every box — there is no per-host box list |
| +// to narrow the read by, the same limit relayHeartbeats accepts for |
| +// control/relay-ack/, so relayReply reads all of them and filters by Host |
| +// itself (relayMatchingAck, apirelay.go). |
| +func (s *Server) relayConfigAcks(ctx context.Context) ([]appconfig.ConfigAck, error) { |
| + keys, err := s.cfg.Releases.List(ctx, configAckPrefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []appconfig.ConfigAck |
| + for _, key := range keys { |
| + if !strings.HasSuffix(key, ".json") { |
| + continue |
| + } |
| + body, _, err := s.cfg.Releases.Get(ctx, key) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + return nil, err |
| + } |
| + var a appconfig.ConfigAck |
| + if err := json.Unmarshal(body, &a); err != nil { |
| + log.Printf("console: relay config ack %s: %v", key, err) |
| + continue |
| + } |
| + out = append(out, a) |
| + } |
| + return out, nil |
| +} |
| + |
| // relayRotate moves a region's issuer pointer to the master the operator |
| // staged, then re-issues every app enabled in that region (spec §5, step 4). |
| // |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index a877cb113..da1151d8c 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -677,7 +677,28 @@ var configSyncMu sync.Mutex |
| func syncExecSerialized(ctx context.Context, reg *registry.Registry, store release.Store, priv *ecdh.PrivateKey, dd string, svc *activator.Service) error { |
| configSyncMu.Lock() |
| defer configSyncMu.Unlock() |
| - return appconfig.SyncExec(ctx, reg, store, priv, dd, bounce(svc), os.Getenv("PLATFORM_APPS_DOMAIN")) |
| + return appconfig.SyncExec(ctx, reg, store, priv, dd, bounce(svc), os.Getenv("PLATFORM_APPS_DOMAIN"), configAckFunc(ctx, store, "agent")) |
| +} |
| + |
| +// configAckFunc builds the callback config sync's two passes (the root |
| +// pass's appconfig.Sync and this agent's own appconfig.SyncExec, called |
| +// through syncExecSerialized above) invoke for a route they just converged |
| +// (relay spec §5, internal/appconfig/configack.go): it stamps this box's |
| +// label onto the ack — BoxLabel (metrics_loop.go) is the same identity |
| +// every other per-box artifact this platform writes uses, so a config-ack |
| +// and this box's heartbeat/metrics rows agree about which box they belong |
| +// to — and writes it. A write failure is logged, never returned: an ack is |
| +// best-effort telemetry for `carlos relay status`, not something config |
| +// delivery itself depends on, and failing the whole sync pass over it would |
| +// make a bucket blip on the ack object cost every OTHER route its config. |
| +func configAckFunc(ctx context.Context, store release.Store, logPrefix string) func(appconfig.ConfigAck) { |
| + box := BoxLabel() |
| + return func(a appconfig.ConfigAck) { |
| + a.Box = box |
| + if err := appconfig.WriteConfigAck(ctx, store, a); err != nil { |
| + log.Printf("%s: config ack %s: %v", logPrefix, a.Host, err) |
| + } |
| + } |
| } |
| // reconcileMu serializes the two provisioning loops' Reconcile passes |
| @@ -2954,7 +2975,7 @@ func RunAdopt(args []string) { |
| secretsPriv, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY") |
| if err != nil { |
| log.Printf("adopt: config sync disabled: %v", err) |
| - } else if err := appconfig.Sync(ctx, reg, store, secretsPriv, appconfig.DefaultEnvDir, restart); err != nil { |
| + } else if err := appconfig.Sync(ctx, reg, store, secretsPriv, appconfig.DefaultEnvDir, restart, configAckFunc(ctx, store, "adopt")); err != nil { |
| log.Printf("adopt: config sync: %v", err) |
| } |
| } |
diff --git a/internal/relay/config_appconfig_test.go b/internal/relay/config_appconfig_test.go| new file mode 100644 |
| index 000000000..b94fce28b |
| --- /dev/null |
| +++ b/internal/relay/config_appconfig_test.go |
| @@ -0,0 +1,34 @@ |
| +package relay_test |
| + |
| +// This file, alone in the package, is relay_test (an EXTERNAL test |
| +// package) rather than relay: it asserts that BuildConfig's value is safe |
| +// to materialize into an EnvironmentFile, which means importing |
| +// internal/appconfig for ValidVarValue — and internal/appconfig imports |
| +// internal/relay for real since Task 12 (configack.go, relaySuffixesFromRelayVar). |
| +// An INTERNAL relay test importing appconfig would close relay -> |
| +// appconfig -> relay, which go vet refuses; this package, built separately |
| +// from both, does not. |
| + |
| +import ( |
| + "crypto/sha256" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/relay" |
| +) |
| + |
| +func TestBuildConfigValueIsEnvSafe(t *testing.T) { |
| + m := sha256.Sum256([]byte("carlos-relay-test-master")) |
| + r := &relay.Record{Enabled: true, Incarnation: "0123456789abcdef", Regions: []string{"eu-west-1"}} |
| + urls := func(region string) []string { |
| + h := "relay." + region + ".oncarlos.com:3478" |
| + return []string{"stun:" + h, "turn:" + h + "?transport=udp", "turn:" + h + "?transport=tcp"} |
| + } |
| + val, _, err := relay.BuildConfig("abc", "meet", r, []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: m[:]}}, urls) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := appconfig.ValidVarValue(val); err != nil { |
| + t.Fatalf("BuildConfig's value is not env-safe: %v", err) |
| + } |
| +} |
diff --git a/internal/relay/config_test.go b/internal/relay/config_test.go| index 3e0745d59..8965b0a86 100644 |
| --- a/internal/relay/config_test.go |
| +++ b/internal/relay/config_test.go |
| @@ -3,10 +3,15 @@ package relay |
| import ( |
| "crypto/sha256" |
| "testing" |
| - |
| - "github.com/carlosframework/platform/internal/appconfig" |
| ) |
| +// The env-safety assertion this test used to make inline (appconfig. |
| +// ValidVarValue(val)) moved to config_appconfig_test.go, package |
| +// relay_test, once Task 12 gave internal/appconfig a real import of |
| +// internal/relay (configack.go): an INTERNAL test file here (package |
| +// relay, this file) importing appconfig would close relay -> appconfig -> |
| +// relay, which go vet refuses. An external test package can import both |
| +// without cycling, because it isn't part of either package's own build. |
| func TestBuildConfigMatchesContract(t *testing.T) { |
| m := sha256.Sum256([]byte("carlos-relay-test-master")) |
| r := &Record{Enabled: true, Incarnation: "0123456789abcdef", Regions: []string{"eu-west-1"}} |
| @@ -18,9 +23,6 @@ func TestBuildConfigMatchesContract(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if err := appconfig.ValidVarValue(val); err != nil { |
| - t.Fatalf("not env-safe: %v", err) |
| - } |
| entries, err := ParseConfig(val) |
| if err != nil || len(entries) != 1 { |
| t.Fatalf("parse = %+v %v", entries, err) |
diff --git a/relay_test.go b/relay_test.go| index 58cf8343d..8fc848383 100644 |
| --- a/relay_test.go |
| +++ b/relay_test.go |
| @@ -251,6 +251,16 @@ func TestPrintRelayStatus(t *testing.T) { |
| Delivery: "no config delivery: relay will not reach this route; opt it into exec_delivery file or move it to a unit"}, |
| {Host: "e.meet", Shape: "tcp-exec-file", Cutover: true, |
| AckedAt: "2026-09-07T10:00:00Z", Delivery: "adopted by member at 2026-09-07T10:00:00Z"}, |
| + // f, g, h: the three states Task 12's config-ack adds for a |
| + // unit or socket-exec route (internal/console/apirelay.go's |
| + // relayMatchingAck) — printRelayStatus computes none of this, |
| + // it only prints whatever Delivery sentence it was handed, so |
| + // these pin that the sentences flow through unmangled. |
| + {Host: "f.meet", Shape: "unit", ConfigAckAt: "2026-09-07T10:05:00Z", |
| + Delivery: "adopted at 2026-09-07T10:05:00Z, restarted"}, |
| + {Host: "g.meet", Shape: "unit", |
| + Delivery: "restart failed: systemctl try-restart carlos-app@g.meet: exit status 1"}, |
| + {Host: "h.meet", Shape: "unit", Delivery: "hibernating, delivered on wake"}, |
| } |
| }, |
| want: []string{ |
| @@ -259,6 +269,9 @@ func TestPrintRelayStatus(t *testing.T) { |
| "route c.meet (tcp-exec-file): file delivered, awaiting `carlos relay adopted --host c.meet`", |
| "route d.meet (no-delivery): no config delivery", |
| "route e.meet (tcp-exec-file): adopted by member at 2026-09-07T10:00:00Z", |
| + "route f.meet (unit): adopted at 2026-09-07T10:05:00Z, restarted", |
| + "route g.meet (unit): restart failed: systemctl try-restart carlos-app@g.meet: exit status 1", |
| + "route h.meet (unit): hibernating, delivered on wake", |
| }, |
| // The lesson restart.go paid for: never say a box has acted. |
| wantNot: []string{"no routes yet"}, |
| @@ -294,10 +307,13 @@ func TestPrintRelayStatus(t *testing.T) { |
| } |
| // TestPrintRelayStatusNeverClaimsDelivery is the output-honesty rule of this |
| -// whole file in one assertion: the console can say a key was WRITTEN and it |
| -// can repeat a member's own acknowledgement, but nothing here may claim a |
| -// running process has picked anything up. Only Task 12's per-route ack can |
| -// say that, and until it exists no sentence may imply it. |
| +// whole file in one assertion: given only "written, awaiting delivery" — no |
| +// config-ack, the state before Task 12's per-route ack (internal/appconfig/ |
| +// configack.go) exists for a route — printRelayStatus must not claim a |
| +// running process has picked anything up. It is a pure function of |
| +// res.Routes[i].Delivery either way: what earns the fuller "adopted at |
| +// <time>, restarted" sentence is relayReply's own config-ack matching |
| +// (internal/console/apirelay.go), not anything this function decides. |
| func TestPrintRelayStatusNeverClaimsDelivery(t *testing.T) { |
| var buf bytes.Buffer |
| printRelayStatus(&buf, apiRelayReply{ |