appconfig: the config ack carries its schema and the instance overlay it rendered (plan 4, task 9)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 files changed,
+133
−12
internal/appconfig/configack.go+25 −0internal/appconfig/configack_test.go+87 −0internal/appconfig/sync.go+8 −4internal/appconfig/sync_test.go+3 −3internal/appconfig/syncexec.go+9 −4internal/appconfig/syncexec_test.go+1 −1
diff --git a/internal/appconfig/configack.go b/internal/appconfig/configack.go| index eb094d0af..b4f43d488 100644 |
| --- a/internal/appconfig/configack.go |
| +++ b/internal/appconfig/configack.go |
| @@ -31,11 +31,27 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +// ConfigAckSchema is the ack schema THIS binary writes. It exists so a |
| +// reader can tell an agent that renders the instance config layer (senders |
| +// design §6.2) from one that does not, and the only honest way to do that |
| +// is a positive statement: an ack written by an older agent decodes with |
| +// Schema 0, because the field was not there to write. |
| +// |
| +// It is not the manifest schema and is not tied to it. Bump it when an |
| +// agent starts stating something a reader may need to depend on. |
| +const ConfigAckSchema = 1 |
| + |
| // 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"` |
| + // Schema is ConfigAckSchema as written by the agent that produced this |
| + // ack, and 0 for every ack written before this field existed. A reader |
| + // asking "does this box render the instance config layer" reads this and |
| + // nothing else: absence of an ack, or an ack at schema 0, both mean the |
| + // question cannot be answered from the box's own statements yet. |
| + Schema int `json:"schema"` |
| // Shape is one of "unit", "socket-exec" or "tcp-exec-file" — spec §5's |
| // three delivery shapes. |
| Shape string `json:"shape"` |
| @@ -61,6 +77,15 @@ type ConfigAck struct { |
| // 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"` |
| + // InstanceConfigETag is the etag of the instance overlay |
| + // (apps/<acct>/<app>/instances-config/<host>.json) this pass rendered |
| + // into the file, empty when the route has no overlay at all. The writer |
| + // keeps the etag its own Put returned and compares the two |
| + // (InstanceConfigDelivery): equal is delivered, different is stale. A |
| + // map of delivered var names could not make that distinction — an agent |
| + // too old to read the layer and a new agent rendering an overlay whose |
| + // vars the reader does not recognise produce the same map. |
| + InstanceConfigETag string `json:"instance_config_etag,omitempty"` |
| } |
| // ConfigAckKey is where a route's config-adoption ack lives: additive, one |
diff --git a/internal/appconfig/configack_test.go b/internal/appconfig/configack_test.go| index 9a8a419b5..00f64f60c 100644 |
| --- a/internal/appconfig/configack_test.go |
| +++ b/internal/appconfig/configack_test.go |
| @@ -227,3 +227,90 @@ func TestSyncAckHibernatingRouteReportsNoRestart(t *testing.T) { |
| 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) |
| + } |
| +} |
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go| index e473a89fb..ca6a5a727 100644 |
| --- a/internal/appconfig/sync.go |
| +++ b/internal/appconfig/sync.go |
| @@ -181,7 +181,7 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec |
| // with no default bundle used to resolve to "never configured" and |
| // stop here, but with an overlay it now resolves to something (senders |
| // design §6.2), so the skip has to see the overlay first. |
| - inst, _, err := overlayFor(k.acct, k.app, rt.Host) |
| + inst, instETag, err := overlayFor(k.acct, k.app, rt.Host) |
| if err != nil { |
| var listErr *instanceOverlayListErr |
| if errors.As(err, &listErr) { |
| @@ -238,7 +238,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, defaultMissing, priv, envDir, restart, ack) |
| + syncHost(rt, m, defaultMissing, priv, envDir, restart, instETag, ack) |
| } |
| // An empty route list is never authority to delete anything: |
| // registry.Open silently creates an empty database when handed a wrong |
| @@ -405,11 +405,15 @@ 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. |
| // |
| +// instETag is the etag of the instance overlay this route's merge already |
| +// folded in (empty when the route has no overlay), carried through to the |
| +// ack unchanged — see ConfigAck.InstanceConfigETag. |
| +// |
| // 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)) { |
| +func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, instETag string, 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. |
| @@ -525,7 +529,7 @@ func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.Pr |
| // 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()} |
| + a := ConfigAck{Schema: ConfigAckSchema, Host: rt.Host, Shape: shapeOf(rt), At: time.Now(), InstanceConfigETag: instETag} |
| 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 |
diff --git a/internal/appconfig/sync_test.go b/internal/appconfig/sync_test.go| index 2f4c10734..5a0d4263e 100644 |
| --- a/internal/appconfig/sync_test.go |
| +++ b/internal/appconfig/sync_test.go |
| @@ -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 }, 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) |
| @@ -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, nil) |
| + 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, nil) |
| + 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) |
| } |
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go| index 7618d719a..c3a2a2dd4 100644 |
| --- a/internal/appconfig/syncexec.go |
| +++ b/internal/appconfig/syncexec.go |
| @@ -433,7 +433,7 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv |
| // stop a REMOVAL too: deciding a file is orphaned while unable to see |
| // whether this instance has an overlay would delete a live tenant's |
| // delivered secrets on a listing blip. |
| - inst, _, instErr := overlayFor(k.acct, k.app, rt.Host) |
| + inst, instETag, instErr := overlayFor(k.acct, k.app, rt.Host) |
| if instErr != nil { |
| var listErr *instanceOverlayListErr |
| if errors.As(instErr, &listErr) { |
| @@ -551,7 +551,7 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv |
| if m != nil || inst != nil { |
| m = applyInstanceOverlay(k.app, m, inst) |
| } |
| - syncExecHost(rt, m, Key(k.acct, k.app), priv, dataDir, hostBounce, header, ack) |
| + syncExecHost(rt, m, Key(k.acct, k.app), priv, dataDir, hostBounce, header, instETag, ack) |
| } |
| // Stale-secrets notice (issue #18 design ruling, ratified 2026-08-03 |
| // review): an addr-shaped route whose app is DEFINITIVELY not opted in |
| @@ -1056,6 +1056,11 @@ 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. |
| +// |
| +// instETag is the etag of the instance overlay this route's merge already |
| +// folded in (empty when the route has no overlay), carried through to the |
| +// ack unchanged — see ConfigAck.InstanceConfigETag. |
| +// |
| // ack, if non-nil, is called once this route's file has actually changed |
| // (written, never on removal — see SyncExec's own doc comment) with its |
| // config-adoption acknowledgement (spec §5). Restarted/RestartError come |
| @@ -1065,7 +1070,7 @@ const execAddrConfigHeader = `# Written by carlos (SyncExec, issue #18). Carlos |
| // (the tcp-exec-file shape: its process belongs to an external supervisor |
| // this platform never restarts at all, so 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)) { |
| +func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, header, instETag 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 |
| @@ -1163,7 +1168,7 @@ func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.Private |
| fmt.Printf(" %s: config updated\n", rt.Host) |
| bounceErr := bounceLogged(rt.Host, bounce) |
| if ack != nil { |
| - a := ConfigAck{Host: rt.Host, Shape: shapeOf(rt), At: time.Now()} |
| + a := ConfigAck{Schema: ConfigAckSchema, Host: rt.Host, Shape: shapeOf(rt), At: time.Now(), InstanceConfigETag: instETag} |
| 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 { |
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go| index 1ee0bfd15..6769eceee 100644 |
| --- a/internal/appconfig/syncexec_test.go |
| +++ b/internal/appconfig/syncexec_test.go |
| @@ -773,7 +773,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 }, "", 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 { |