carlos / platform Public

Merge branch 'worktree-instance-config' into main

claude agent, operated by paul@keymail.dev eb9ba0fe2d5ca04e0a7f9b9e9e14012078ec4386 parent ce24be0parent 47989a2
14 files changed, +2572 −59
  • README.md +28 −0
  • internal/appconfig/appconfig.go +6 −1
  • internal/appconfig/awaitingrestart_test.go +100 −0
  • internal/appconfig/configack.go +137 −1
  • internal/appconfig/configack_test.go +149 −0
  • internal/appconfig/environments.go +1 −1
  • internal/appconfig/instances.go +348 −0
  • internal/appconfig/instances_test.go +1412 −0
  • internal/appconfig/routefingerprint.go +8 −0
  • internal/appconfig/store.go +34 −19
  • internal/appconfig/sync.go +110 −10
  • internal/appconfig/sync_test.go +3 −3
  • internal/appconfig/syncexec.go +166 −23
  • internal/appconfig/syncexec_test.go +70 −1

CI passed — run details

diff --git a/README.md b/README.md
index e8c1c817a..0386f2718 100644
--- a/README.md
+++ b/README.md
@@ -257,6 +257,34 @@ sender record (design spec `2026-09-15-tenant-email-senders-design.md`).
drops the database's `mail_counts` table. do not set it until the
release after this one has been live for a while.
+### Tenant email senders, plan 4 of 5 (2026-09-22)
+
+This release adds the per-instance config layer (design spec
+`2026-09-15-tenant-email-senders-design.md` §6.2): a third config bundle,
+layered over an app's default and named-environment config, scoped to one
+route's host rather than the whole app.
+
+- a new failure mode comes with this layer. every config sweep now also
+ lists `apps/<acct>/<app>/instances-config/` before it converges that
+ app's routes. s3 throttling, an iam policy change that adds a prefix
+ condition to `s3:ListBucket`, or a transient 503 on that call all look
+ the same from a box: the listing fails, and every route of that app
+ freezes on its last-good config file until the listing succeeds again.
+ this holds for the whole app, not just routes with an overlay of their
+ own, because the listing runs once per app regardless of who uses the
+ layer.
+- grep the box's log for `instance config unresolved` to find it. it
+ prints once per app, naming the account, the app and one route the pass
+ reached. every other route of that app is covered by the same line and
+ the same cause.
+- it clears itself. once the listing succeeds again, the next pass
+ converges normally, with nothing to clean up by hand.
+- the in-repo iam policies grant `s3:ListBucket` on the whole deployment
+ bucket with no prefix condition, so a platform box is unaffected by this
+ on its own account's policy. check a hand-edited or self-hosted
+ deployment's policy before this rolls there, since a narrower grant is
+ exactly what would trip it.
+
## App config & secrets
Per-app config lives in the deployment bucket
diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go
index 139f12ce5..ff5d13439 100644
--- a/internal/appconfig/appconfig.go
+++ b/internal/appconfig/appconfig.go
@@ -7,7 +7,12 @@
// Bucket layout (deployment bucket, account-scoped like every other app key
// internal/release writes):
//
-// apps/<acct>/<app>/config.json CAS-guarded config manifest
+// apps/<acct>/<app>/config.json CAS-guarded config manifest
+// apps/<acct>/<app>/environments/<name>.json named environment overlay
+// apps/<acct>/<app>/instances-config/<host>.json one instance's overlay
+//
+// The three resolve default < environment < instance, per key, and only a
+// caller that names a host gets the third (LoadMergedHost, instances.go).
package appconfig
import (
diff --git a/internal/appconfig/awaitingrestart_test.go b/internal/appconfig/awaitingrestart_test.go
index 501ed1d92..ea3e750d0 100644
--- a/internal/appconfig/awaitingrestart_test.go
+++ b/internal/appconfig/awaitingrestart_test.go
@@ -237,6 +237,106 @@ func TestAwaitingRestartUnknownExecDeliveryIsOff(t *testing.T) {
}
}
+// TestAwaitingRestartInstanceOverlayShadowsADefaultWrite: the third layer
+// shadows a default-bundle write the same way a named bundle does, and for
+// the same reason. The merged manifest does not move, so syncExecHost's
+// bytes.Equal writes nothing and there is no file for anyone to restart over.
+func TestAwaitingRestartInstanceOverlayShadowsADefaultWrite(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ if _, _, err := ApplyInstance(ctx, s, "bab", "relay", "connect.example.com",
+ map[string]Var{"OWN_KEY": {Kind: "env", Value: "instance"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")}
+
+ if got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OWN_KEY"}); !got.Empty() {
+ t.Fatalf("got %+v, want nothing: OWN_KEY is shadowed by this instance's own layer", got)
+ }
+ got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OTHER_KEY"})
+ if len(got.Hosts) != 1 || got.Hosts[0].Host != "connect.example.com" {
+ t.Fatalf("Hosts = %+v, want the route for a key its overlay does not state", got.Hosts)
+ }
+ got = AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OWN_KEY", "OTHER_KEY"})
+ if len(got.Hosts) != 1 {
+ t.Fatalf("Hosts = %+v, want the route when any key is unshadowed", got.Hosts)
+ }
+}
+
+// TestAwaitingRestartInstanceOverlayShadowsANamedBundleWrite is the case
+// that did not exist before the third layer: a write to the route's OWN
+// bound bundle used to be unshadowable, because nothing sat above it.
+// Something does now.
+func TestAwaitingRestartInstanceOverlayShadowsANamedBundleWrite(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ if _, err := ApplyEnvironment(ctx, s, "bab", "relay", "prod",
+ map[string]Var{"K": {Kind: "env", Value: "environment"}}, nil); err != nil {
+ t.Fatalf("ApplyEnvironment: %v", err)
+ }
+ if _, _, err := ApplyInstance(ctx, s, "bab", "relay", "connect.example.com",
+ map[string]Var{"K": {Kind: "env", Value: "instance"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "prod")}
+
+ if got := AwaitingRestart(ctx, s, routes, "bab", "relay", "prod", []string{"K"}); !got.Empty() {
+ t.Fatalf("got %+v, want nothing: K is shadowed by this instance's own layer", got)
+ }
+ if got := AwaitingRestart(ctx, s, routes, "bab", "relay", "prod", []string{"OTHER"}); len(got.Hosts) != 1 {
+ t.Fatalf("Hosts = %+v, want the route for an unshadowed key", got.Hosts)
+ }
+}
+
+// TestAwaitingRestartUnresolvedWhenTheOverlayCannotBeRead: a read this
+// function could not make is not a determination, on this layer as on the
+// other two — the route is named as unresolved rather than reported either
+// way.
+func TestAwaitingRestartUnresolvedWhenTheOverlayCannotBeRead(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ if _, _, err := ApplyInstance(ctx, s, "bab", "relay", "connect.example.com",
+ map[string]Var{"OWN_KEY": {Kind: "env", Value: "instance"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ blip := &failingKeyStore{Store: s, failKey: InstanceConfigKey("bab", "relay", "connect.example.com"),
+ err: errors.New("s3: transient 503 SlowDown")}
+ routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")}
+ got := AwaitingRestart(ctx, blip, routes, "bab", "relay", "", []string{"OWN_KEY"})
+ if len(got.Hosts) != 0 || len(got.Unresolved) != 1 || got.Unresolved[0] != "connect.example.com" {
+ t.Fatalf("got %+v, want the host as unresolved and nothing claimed either way", got)
+ }
+}
+
+// TestAwaitingRestartEmptyKeysNeverConsultsTheOverlay pins that an empty
+// keys (a write with no vars, e.g. flipping exec_delivery on) never asks the
+// instance layer anything: there is nothing for it to shadow, per keys' own
+// doc comment, so a blip reading it must not downgrade a route this call was
+// certain about into Unresolved over a question it was never going to ask.
+func TestAwaitingRestartEmptyKeysNeverConsultsTheOverlay(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ if _, _, err := ApplyInstance(ctx, s, "bab", "relay", "connect.example.com",
+ map[string]Var{"OWN_KEY": {Kind: "env", Value: "instance"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ blip := &failingKeyStore{Store: s, failKey: InstanceConfigKey("bab", "relay", "connect.example.com"),
+ err: errors.New("s3: transient 503 SlowDown")}
+ routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")}
+ got := AwaitingRestart(ctx, blip, routes, "bab", "relay", "", nil)
+ if len(got.Hosts) != 1 || got.Hosts[0].Host != "connect.example.com" || len(got.Unresolved) != 0 {
+ t.Fatalf("got %+v, want the route named in Hosts: an unreadable overlay is irrelevant when keys is empty", got)
+ }
+}
+
// TestAwaitingRestartSorted pins determinism: registry order is not stable,
// and a notice whose lines reshuffle between identical runs is one people
// stop reading.
diff --git a/internal/appconfig/configack.go b/internal/appconfig/configack.go
index eb094d0af..36ccdc14d 100644
--- a/internal/appconfig/configack.go
+++ b/internal/appconfig/configack.go
@@ -23,7 +23,9 @@ import (
"encoding/json"
"errors"
"fmt"
+ "log"
"os"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/registry"
@@ -31,11 +33,43 @@ 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
+
+// instanceConfigETagSchema is the ack schema at which InstanceConfigETag
+// first became a field an agent could write — 1, the schema this plan
+// introduced it at. InstanceConfigDelivery compares against THIS constant,
+// not against ConfigAckSchema: the two happen to be equal today, but they
+// answer different questions, and conflating them is a trap the next schema
+// bump would fall into silently. ConfigAckSchema is "what schema does this
+// BINARY write" and is meant to climb every time the ack states something
+// new; instanceConfigETagSchema is "at what schema did THIS field become
+// trustworthy" and has no reason to climb again unless the field itself is
+// ever redefined. A bump to ConfigAckSchema for some unrelated addition
+// (say, schema 2 adds a field about something else entirely) must not
+// demote every schema-1 ack — which carries a perfectly good
+// InstanceConfigETag — to InstanceConfigUnknownAgent for a question it can
+// already answer.
+const instanceConfigETagSchema = 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,13 +95,115 @@ 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"`
}
+// ConfigAckPrefix is where every route's config-adoption ack lives, one
+// object per (box, route host).
+const ConfigAckPrefix = "control/config-ack/"
+
// 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"
+ return ConfigAckPrefix + box + "/" + host + ".json"
+}
+
+// The four answers to "is this instance running the overlay I wrote", named
+// because three of them are not "no" and a caller that flattened them would
+// tell an operator to roll a box that is merely one pass behind.
+const (
+ // InstanceConfigDelivered: the agent rendered exactly this version of
+ // the overlay. a route with no overlay, whose caller expects none, is
+ // also delivered. there is nothing outstanding.
+ InstanceConfigDelivered = "delivered"
+ // InstanceConfigStale: the agent rendered a different version. one more
+ // pass, or a generation that never got read, is the usual cause.
+ InstanceConfigStale = "stale"
+ // InstanceConfigUnknownAgent: this box's agent predates the instance
+ // config layer, so its ack says nothing about it. not "no". the answer
+ // cannot be had until the box is rolled.
+ InstanceConfigUnknownAgent = "unknown-agent"
+ // InstanceConfigNotYetDelivered: no ack for this host at all. acks are
+ // change-only, so this is the honest answer for a route whose file has
+ // not moved since its agent rolled. it never means "old agent": that
+ // question is the box record's agent version, which step 5 reads
+ // (design §6.5).
+ InstanceConfigNotYetDelivered = "not-yet-delivered"
+)
+
+// InstanceConfigDelivery compares an ack with the etag a write returned and
+// says which of the four states the route is in. want is the etag of the
+// overlay the caller wrote, or "" when the caller wrote none.
+//
+// the comparison is on etags rather than on delivered var names because only
+// etags are decidable: an agent too old to read the layer and a new agent
+// rendering an overlay whose vars the reader does not recognise deliver the
+// same set of names (design §6.5).
+//
+// The schema test below is against instanceConfigETagSchema, deliberately
+// NOT ConfigAckSchema — see that constant's own doc comment for the hazard
+// a future schema bump would otherwise walk into.
+func InstanceConfigDelivery(a *ConfigAck, want string) string {
+ switch {
+ case a == nil:
+ return InstanceConfigNotYetDelivered
+ case a.Schema < instanceConfigETagSchema:
+ return InstanceConfigUnknownAgent
+ case a.InstanceConfigETag == want:
+ return InstanceConfigDelivered
+ default:
+ return InstanceConfigStale
+ }
+}
+
+// LatestConfigAck reads the freshest ack naming host, across every box that
+// has written one, and nil (with no error) when nothing has. a route can
+// legitimately have acks from two boxes at once across a migration, and the
+// freshest is the one describing what is running now.
+//
+// it lists the whole ack prefix, which is the same limit relayConfigAcks
+// accepts (internal/console/relayops.go): there is no per-host box list to
+// narrow a listing by. the Gets are narrowed, though. only keys whose
+// basename is this host are read.
+func LatestConfigAck(ctx context.Context, s release.Store, host string) (*ConfigAck, error) {
+ keys, err := s.List(ctx, ConfigAckPrefix)
+ if err != nil {
+ return nil, err
+ }
+ var best *ConfigAck
+ for _, key := range keys {
+ if !strings.HasSuffix(key, "/"+host+".json") {
+ continue
+ }
+ body, _, err := s.Get(ctx, key)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ return nil, err
+ }
+ var a ConfigAck
+ if err := json.Unmarshal(body, &a); err != nil {
+ // one unreadable object must not hide the box that answered
+ // properly, and there is nothing in it to report either way.
+ log.Printf("config: config ack %s: %v", key, err)
+ continue
+ }
+ if best == nil || a.At.After(best.At) {
+ latest := a
+ best = &latest
+ }
+ }
+ return best, nil
}
// WriteConfigAck writes ack, get-then-put with the CAS etag of whatever is
diff --git a/internal/appconfig/configack_test.go b/internal/appconfig/configack_test.go
index 9a8a419b5..6725777ec 100644
--- a/internal/appconfig/configack_test.go
+++ b/internal/appconfig/configack_test.go
@@ -6,6 +6,7 @@ import (
"errors"
"strings"
"testing"
+ "time"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/relay"
@@ -227,3 +228,151 @@ 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)
+ }
+}
+
+// The four answers, and they are four rather than two on purpose. "This
+// agent is too old to render the layer" and "this agent rendered an older
+// version of it" call for different sentences from whoever asks: one is a
+// box to roll, the other is a pass to wait for.
+func TestInstanceConfigDeliveryVerdicts(t *testing.T) {
+ cases := []struct {
+ name string
+ ack *ConfigAck
+ want string
+ got string
+ }{
+ {"not yet acked", nil, "etag-1", InstanceConfigNotYetDelivered},
+ {"agent older than the layer", &ConfigAck{Schema: 0, InstanceConfigETag: ""}, "etag-1", InstanceConfigUnknownAgent},
+ {"rendered this overlay", &ConfigAck{Schema: ConfigAckSchema, InstanceConfigETag: "etag-1"}, "etag-1", InstanceConfigDelivered},
+ {"rendered an older one", &ConfigAck{Schema: ConfigAckSchema, InstanceConfigETag: "etag-0"}, "etag-1", InstanceConfigStale},
+ {"no overlay, and none expected", &ConfigAck{Schema: ConfigAckSchema}, "", InstanceConfigDelivered},
+ {"no overlay, but one was written", &ConfigAck{Schema: ConfigAckSchema}, "etag-1", InstanceConfigStale},
+ }
+ for _, c := range cases {
+ if got := InstanceConfigDelivery(c.ack, c.want); got != c.got {
+ t.Errorf("%s: InstanceConfigDelivery = %q, want %q", c.name, got, c.got)
+ }
+ }
+}
+
+// One route can have acks from more than one box across a migration, so the
+// reader takes the freshest and ignores every other host's.
+func TestLatestConfigAckPicksTheFreshestForTheHost(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ older := ConfigAck{Schema: ConfigAckSchema, Box: "box-1", Host: "book.example.com", Shape: "unit",
+ At: time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-0"}
+ newer := ConfigAck{Schema: ConfigAckSchema, Box: "box-2", Host: "book.example.com", Shape: "unit",
+ At: time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-1"}
+ other := ConfigAck{Schema: ConfigAckSchema, Box: "box-2", Host: "cart.example.com", Shape: "unit",
+ At: time.Date(2026, 9, 15, 13, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-9"}
+ for _, a := range []ConfigAck{older, newer, other} {
+ if err := WriteConfigAck(ctx, s, a); err != nil {
+ t.Fatalf("WriteConfigAck %s/%s: %v", a.Box, a.Host, err)
+ }
+ }
+ got, err := LatestConfigAck(ctx, s, "book.example.com")
+ if err != nil || got == nil {
+ t.Fatalf("LatestConfigAck = %+v %v", got, err)
+ }
+ if got.Box != "box-2" || got.InstanceConfigETag != "etag-1" {
+ t.Fatalf("LatestConfigAck = %+v, want box-2's etag-1", got)
+ }
+ if v := InstanceConfigDelivery(got, "etag-1"); v != InstanceConfigDelivered {
+ t.Fatalf("delivery = %q, want %q", v, InstanceConfigDelivered)
+ }
+
+ none, err := LatestConfigAck(ctx, s, "desk.example.com")
+ if err != nil || none != nil {
+ t.Fatalf("LatestConfigAck for a host with no ack = %+v %v, want (nil, nil)", none, err)
+ }
+ if v := InstanceConfigDelivery(none, "etag-1"); v != InstanceConfigNotYetDelivered {
+ t.Fatalf("delivery = %q, want %q", v, InstanceConfigNotYetDelivered)
+ }
+}
diff --git a/internal/appconfig/environments.go b/internal/appconfig/environments.go
index c5d376921..b3b4a4719 100644
--- a/internal/appconfig/environments.go
+++ b/internal/appconfig/environments.go
@@ -115,7 +115,7 @@ func applyEnvironment(ctx context.Context, s release.Store, acct, app, envName s
if !ValidEnvironmentName(envName) {
return 0, fmt.Errorf("config: invalid environment name %q", envName)
}
- removed, err := applyAt(ctx, s, EnvironmentKey(acct, app, envName), app, envName, sets, unsets)
+ removed, _, err := applyAt(ctx, s, EnvironmentKey(acct, app, envName), app, envName, sets, unsets)
if err != nil {
return 0, err
}
diff --git a/internal/appconfig/instances.go b/internal/appconfig/instances.go
new file mode 100644
index 000000000..686c4b29e
--- /dev/null
+++ b/internal/appconfig/instances.go
@@ -0,0 +1,348 @@
+// The instance config layer (tenant-email senders design §6.2): a third
+// bundle, one object per route host, resolved after the named environment.
+// Same Manifest shape, same sealed envelopes, same name and value rules as
+// the other two — a per-instance bundle is not a new format, just a third
+// bucket key the same shape is written to.
+//
+// Bucket layout addition:
+//
+// apps/<acct>/<app>/instances-config/<host>.json CAS-guarded, schema-1 manifest
+//
+// It exists so a credential can be scoped to ONE instance. #148's shape is
+// one instance per customer under a single app, and everything the platform
+// delivers today lands in a bundle every instance of the app renders, so a
+// per-customer secret delivered through the default bundle reaches every
+// other customer's process.
+//
+// Manifest.ExecDelivery is NOT read from this layer. That is the ruling at
+// appconfig.go's ExecDelivery doc comment, unchanged: a per-route delivery
+// flag would need a registry column, a record field and reconcile
+// convergence, and two routes resolving through the same bundle could
+// disagree about whether they are delivered. A per-host bundle is exactly
+// the granularity that ruling refused, so resolveExecDelivery is never
+// handed one of these manifests and a field that somehow appears in one is
+// inert.
+package appconfig
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// InstancesConfigPrefix is the list prefix behind "which instances of this
+// app have an overlay" — every instances-config/<host>.json key shares it.
+// Inside apps/<acct>/<app>/ on purpose: release.PurgeApp's prefix sweep
+// reaches it, so a purged app cannot leave an object full of sealed
+// secrets behind.
+func InstancesConfigPrefix(acct, app string) string {
+ return release.AppPrefix(acct, app) + "instances-config/"
+}
+
+// InstanceConfigKey is the bucket key of one instance's overlay. host is a
+// route's Host column verbatim, which is what makes the key derivable from
+// a registry row with nothing to look up.
+func InstanceConfigKey(acct, app, host string) string {
+ return InstancesConfigPrefix(acct, app) + host + ".json"
+}
+
+// instanceHostRe is a bare lowercase hostname: labels of letters, digits
+// and hyphens, joined by dots, never starting or ending with a hyphen or a
+// dot. Case-restricted like ValidEnvironmentName and for the same reason,
+// with one more: this string is also the file name sync derives on the box
+// (sync.go's filepath.Base guard), so one convention keeps a bucket key and
+// a file name from disagreeing about which host they belong to.
+var instanceHostRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$`)
+
+// ValidInstanceHost reports whether host is usable as an instance overlay
+// key. A separator or a traversal is refused at the one gate every caller
+// passes through, because this string is concatenated into a bucket key: a
+// host of "../config" would otherwise name the app's default bundle.
+func ValidInstanceHost(host string) bool {
+ if host == "" || len(host) > 253 || strings.Contains(host, "..") {
+ return false
+ }
+ // Per-label bound as well as the whole-name one, matching DNS and
+ // launchctx/controlname's label rule. Without it a single 200-character
+ // label passes here and is refused everywhere else that handles the same
+ // host, which is a disagreement between two gates rather than one rule.
+ for _, label := range strings.Split(host, ".") {
+ if len(label) > 63 {
+ return false
+ }
+ }
+ return instanceHostRe.MatchString(host)
+}
+
+// LoadInstanceConfig reads one instance's overlay, returning the manifest,
+// the etag it was read at, and whether the object exists at all.
+//
+// The etag is the point of this signature. The delivery ack (design §6.5)
+// carries the etag the agent actually rendered, and the console compares it
+// with the one its own Put returned: that comparison is decidable, where a
+// map of delivered key names cannot tell an old agent apart from a new one
+// rendering a route with no overlay vars.
+//
+// Not-found is (nil, "", false, nil): an instance with no overlay is the
+// normal case and the state every instance is in until something writes one.
+func LoadInstanceConfig(ctx context.Context, s release.Store, acct, app, host string) (*Manifest, string, bool, error) {
+ if !release.ValidAccount(acct) {
+ return nil, "", false, fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return nil, "", false, fmt.Errorf("config: invalid app %q", app)
+ }
+ if !ValidInstanceHost(host) {
+ return nil, "", false, fmt.Errorf("config: invalid instance host %q", host)
+ }
+ return getIfExistsAtETag(ctx, s, InstanceConfigKey(acct, app, host))
+}
+
+// ApplyInstance writes sets and removes unsets from ONE instance's overlay
+// in one CAS write, returning how many of unsets were actually present and
+// THE ETAG IT WROTE AT.
+//
+// That etag is the contract with the box. The console keeps it, the agent
+// reports the etag it rendered in its config ack, and the two are compared
+// (design §6.5, InstanceConfigDelivery). A map of delivered key names would
+// not do: it cannot tell an agent too old to render this layer apart from a
+// new agent rendering a route whose overlay sets nothing it recognises.
+//
+// There is no CLI verb and no console door onto this function, by design
+// (§6.2, "only the platform writes this layer"). It exists so a credential
+// can be scoped to one instance, and the only caller that scopes one is the
+// email walk in step 5.
+//
+// Unset-only against an overlay that does not exist is refused, not turned
+// into a create, for the reason applyAt's own doc comment gives: an empty
+// manifest that EXISTS is a state the sweeps converge a file to, and
+// minting one to hold nothing would deliver an empty file to a live
+// instance.
+func ApplyInstance(ctx context.Context, s release.Store, acct, app, host string, sets map[string]Var, unsets []string) (int, string, error) {
+ if !release.ValidAccount(acct) {
+ return 0, "", fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return 0, "", fmt.Errorf("config: invalid app %q", app)
+ }
+ if !ValidInstanceHost(host) {
+ return 0, "", fmt.Errorf("config: invalid instance host %q", host)
+ }
+ removed, etag, err := applyAt(ctx, s, InstanceConfigKey(acct, app, host), app, "instance "+host, sets, unsets)
+ if err != nil {
+ return 0, "", err
+ }
+ // The generation object is the ONLY signal this layer has.
+ // ConfigRouteFingerprints (routefingerprint.go) digests registry columns
+ // and cannot see a bucket-side change, so an overlay written without
+ // this touch would wait out configSyncLoop's hourly floor. Same
+ // best-effort demotion every other write here makes: the manifest has
+ // landed, so a failed touch is logged, never returned.
+ touchLogged(ctx, s, acct, app)
+ return removed, etag, nil
+}
+
+// DeleteInstanceConfig removes one instance's overlay outright, which
+// converges that route back to its group's merge on the next pass. Absent
+// is success: a retried teardown must converge rather than fail, and
+// release.Store.Delete is already idempotent.
+//
+// The generation is touched even when nothing was removed. Branching on it
+// would mean a Get before every delete to find out, and one redundant touch
+// costs a tiny object write where a missed one costs an hour of a live
+// instance holding config nothing meant it to have.
+func DeleteInstanceConfig(ctx context.Context, s release.Store, acct, app, host string) error {
+ if !release.ValidAccount(acct) {
+ return fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return fmt.Errorf("config: invalid app %q", app)
+ }
+ if !ValidInstanceHost(host) {
+ return fmt.Errorf("config: invalid instance host %q", host)
+ }
+ if err := s.Delete(ctx, InstanceConfigKey(acct, app, host)); err != nil {
+ return err
+ }
+ touchLogged(ctx, s, acct, app)
+ return nil
+}
+
+// LoadMergedHost is LoadMerged plus the instance layer: default <
+// environment < instance, the instance winning per key, and the overlay's
+// etag returned alongside so a caller can check delivery against it later
+// (design §6.2, §6.5). An empty etag means this host has no overlay, which
+// is a different statement from an agent that never rendered one.
+//
+// LoadMerged keeps its signature and its meaning. Everything that asks
+// "what does this APP's environment resolve to" still gets two layers, and
+// only a caller that names a host gets three.
+//
+// The two sync sweeps do NOT call this function, deliberately: they resolve
+// one group base for every route that shares it and would re-fetch
+// config.json per route if they came through here. They reach the same
+// answer through instanceOverlays and applyInstanceOverlay instead, and the
+// equivalence for a VALID host is pinned by a test rather than by hope —
+// TestSyncRendersTheInstanceOverlayForItsOwnRouteOnly compares a sweep's
+// rendered file against this function's own answer for the same host.
+//
+// That equivalence does NOT extend to a host that fails ValidInstanceHost.
+// This function refuses one outright (see the guard below): a hostname a
+// caller cannot even ask about is an error here. The sweeps never make that
+// call at all — instanceOverlayLoader treats such a host as "not named by
+// the listing", which reads as "no overlay", and the route converges on its
+// group's merge same as any other unconfigured instance. A caller that
+// wants the sweep's own answer for a host it cannot yet prove valid (step
+// 5's write path, before ApplyInstance's own ValidInstanceHost gate runs)
+// must not substitute this function for that reason: the two disagree here,
+// on purpose, and only one of them is what a box will actually deliver.
+func LoadMergedHost(ctx context.Context, s release.Store, acct, app, envName, host string) (*Manifest, string, error) {
+ if !ValidInstanceHost(host) {
+ return nil, "", fmt.Errorf("config: invalid instance host %q", host)
+ }
+ base, err := LoadMerged(ctx, s, acct, app, envName)
+ if err != nil {
+ return nil, "", err
+ }
+ inst, etag, found, err := LoadInstanceConfig(ctx, s, acct, app, host)
+ if err != nil {
+ return nil, "", err
+ }
+ if !found {
+ return base, "", nil
+ }
+ return applyInstanceOverlay(app, base, inst), etag, nil
+}
+
+// applyInstanceOverlay lays inst over base, per key. base may be nil (the
+// app has no default bundle and the route is unbound), in which case the
+// merged view is the overlay ALONE — which the callers treat as the
+// wholesale replacement it is, see sync.go's defaultMissing guard.
+//
+// A nil overlay returns base untouched, pointer and all: that is the path
+// every instance without an overlay takes, and rebuilding the manifest
+// there would strip exec_delivery off the default bundle object LoadMerged
+// hands back for an unbound route.
+//
+// The merged view never carries exec_delivery FROM the overlay. See this
+// file's own doc comment: a per-host delivery flag is the granularity the
+// #195 ruling refused, and the merged manifest is what gets rendered into a
+// file, where a delivery flag has no business appearing anyway.
+func applyInstanceOverlay(app string, base, inst *Manifest) *Manifest {
+ if inst == nil {
+ return base
+ }
+ merged := &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}
+ if base != nil {
+ for k, v := range base.Vars {
+ merged.Vars[k] = v
+ }
+ }
+ for k, v := range inst.Vars {
+ merged.Vars[k] = v
+ }
+ return merged
+}
+
+// instanceOverlays lists which of this app's instances have an overlay at
+// all: one listing per (account, app), cached by the caller for a whole
+// pass, so a sweep pays one List per app and then one Get per instance that
+// actually has one. Reading every route's key blind would cost a bucket
+// round trip per instance on a box where nothing has an overlay, which is
+// every box in the fleet the day this ships.
+//
+// A key that does not end in .json, or whose host does not survive
+// ValidInstanceHost, is skipped rather than reported: nothing can have
+// written it through this package, so it is not this app's overlay
+// whatever it is.
+func instanceOverlays(ctx context.Context, s release.Store, acct, app string) (map[string]bool, error) {
+ prefix := InstancesConfigPrefix(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ out := make(map[string]bool, len(keys))
+ for _, k := range keys {
+ host := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
+ if host == strings.TrimPrefix(k, prefix) || !ValidInstanceHost(host) {
+ continue
+ }
+ out[host] = true
+ }
+ return out, nil
+}
+
+// instanceOverlayListErr marks an error as coming from instanceOverlayLoader's
+// cached LISTING (instanceOverlays) rather than from a per-host Get. The
+// listing is the one call every route of an app shares, so every route
+// fails identically and for the same reason when it errors — a caller may
+// dedup on that. A per-host Get or parse failure carries no such guarantee:
+// two routes can have two separately corrupt overlay objects, each failing
+// for its own reason, and folding both into one line would hide the second
+// one's failure entirely. errors.As is how a caller tells the two apart.
+type instanceOverlayListErr struct{ error }
+
+// Unwrap lets errors.Is/errors.As see through the wrapper to the listing
+// failure itself, the same courtesy every %w wrap in this package gives.
+func (e *instanceOverlayListErr) Unwrap() error { return e.error }
+
+// instanceOverlayLoader is one pass's instance-layer reader, shared by both
+// sweeps (Sync and SyncExec) so neither can drift from the other about what
+// a host gets or what it costs. One List per (account, app), cached for the
+// pass including its failure, then a Get only for a host that listing
+// named.
+//
+// A listing FAILURE is returned to the caller wrapped in
+// instanceOverlayListErr, never folded into "no overlay". The two sweeps
+// treat it the way they treat a bundle fetch failure: leave the file
+// exactly as it is, say so, retry next pass. Folding it away would silently
+// deliver the app-wide merge to an instance that is meant to have its own
+// credential, which is the one outcome this layer exists to prevent.
+//
+// A host the listing named but whose object is gone by the time of the Get
+// is simply "no overlay": a delete racing the pass converges the route back
+// to its group's merge, which is what the delete asked for.
+//
+// The index map below has no lock. That's safe only because each call to
+// this function returns a fresh closure over its own map, and both Sync and
+// SyncExec build one loader per pass and drive it from their own single
+// route loop — never two goroutines sharing one closure. A caller that ever
+// shared one instanceOverlayLoader value across concurrently running sweeps
+// would race on this map; don't.
+func instanceOverlayLoader(ctx context.Context, s release.Store) func(acct, app, host string) (*Manifest, string, error) {
+ type appKey struct{ acct, app string }
+ type indexRes struct {
+ hosts map[string]bool
+ err error
+ }
+ index := map[appKey]indexRes{}
+ return func(acct, app, host string) (*Manifest, string, error) {
+ ak := appKey{acct, app}
+ idx, done := index[ak]
+ if !done {
+ hosts, err := instanceOverlays(ctx, s, acct, app)
+ if err != nil {
+ err = &instanceOverlayListErr{fmt.Errorf("%s/%s: instance config listing failed: %w", acct, app, err)}
+ }
+ idx = indexRes{hosts: hosts, err: err}
+ index[ak] = idx
+ }
+ if idx.err != nil {
+ return nil, "", idx.err
+ }
+ if !idx.hosts[host] {
+ return nil, "", nil
+ }
+ m, etag, found, err := LoadInstanceConfig(ctx, s, acct, app, host)
+ if err != nil {
+ return nil, "", fmt.Errorf("%s/%s: instance config for %s: %w", acct, app, host, err)
+ }
+ if !found {
+ return nil, "", nil
+ }
+ return m, etag, nil
+ }
+}
diff --git a/internal/appconfig/instances_test.go b/internal/appconfig/instances_test.go
new file mode 100644
index 000000000..beb862c4d
--- /dev/null
+++ b/internal/appconfig/instances_test.go
@@ -0,0 +1,1412 @@
+package appconfig
+
+import (
+ "bytes"
+ "context"
+ "crypto/ecdh"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// putInstanceRaw writes an instance-layer object without going through the
+// package's own writer, which does not exist until task 2. Tests in this
+// file that only READ the layer use it, so task 1 can be verified on its
+// own.
+func putInstanceRaw(t *testing.T, ctx context.Context, s release.Store, acct, app, host string, m *Manifest) string {
+ t.Helper()
+ body, err := json.MarshalIndent(m, "", " ")
+ if err != nil {
+ t.Fatalf("marshal instance manifest: %v", err)
+ }
+ etag, err := s.Put(ctx, InstanceConfigKey(acct, app, host), body, "")
+ if err != nil {
+ t.Fatalf("put instance manifest: %v", err)
+ }
+ return etag
+}
+
+// The layer sits under the app's own prefix, beside config.json and
+// environments/, so release.PurgeApp's prefix sweep reaches it and a purged
+// app leaves no instance overlay behind holding sealed secrets.
+func TestInstanceConfigKeysSitUnderTheAppsOwnPrefix(t *testing.T) {
+ if got, want := InstancesConfigPrefix("bab", "titogo"), "apps/bab/titogo/instances-config/"; got != want {
+ t.Fatalf("InstancesConfigPrefix = %q, want %q", got, want)
+ }
+ if got, want := InstanceConfigKey("bab", "titogo", "book.customer.example.com"),
+ "apps/bab/titogo/instances-config/book.customer.example.com.json"; got != want {
+ t.Fatalf("InstanceConfigKey = %q, want %q", got, want)
+ }
+}
+
+// A host becomes a bucket key segment here, so the one gate every caller
+// passes through is where a separator, a traversal or an empty name is
+// refused. The shape is a bare lowercase hostname, the same thing a route
+// row's Host column holds and the same thing sync.go's own path guard
+// (rt.Host != filepath.Base(rt.Host)) already insists on before it derives
+// a file name from one.
+func TestValidInstanceHostRefusesAnythingThatIsNotABareHost(t *testing.T) {
+ for _, bad := range []string{
+ "", ".", "..", "a/b", "../etc", "Book.example.com", "book..example.com",
+ "-lead.example.com", "trail-.example.com", "book.example.com/",
+ strings.Repeat("a", 254),
+ } {
+ if ValidInstanceHost(bad) {
+ t.Errorf("ValidInstanceHost(%q) = true", bad)
+ }
+ }
+ for _, good := range []string{"book.customer.example.com", "inst1", "a-b.c-d.example.com", "127-0-0-1.example.com"} {
+ if !ValidInstanceHost(good) {
+ t.Errorf("ValidInstanceHost(%q) = false", good)
+ }
+ }
+}
+
+// The reader answers three ways, and the caller needs all three apart: a
+// manifest and the etag it was read at, "no object" without an error (an
+// instance with no overlay is the normal case, not a fault), and an error
+// for anything else. The etag is what the delivery ack carries (design §6.5),
+// so a read that dropped it would leave the agent unable to say which
+// version of the layer it rendered.
+func TestLoadInstanceConfigReadsTheLayerAndItsETag(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+
+ m, etag, found, err := LoadInstanceConfig(ctx, s, "bab", "titogo", "book.customer.example.com")
+ if err != nil || found || m != nil || etag != "" {
+ t.Fatalf("absent overlay = %+v %q %v %v, want (nil, \"\", false, nil)", m, etag, found, err)
+ }
+
+ want := putInstanceRaw(t, ctx, s, "bab", "titogo", "book.customer.example.com",
+ &Manifest{Schema: 1, App: "titogo", Vars: map[string]Var{"TENANT_SMTP_USER": {Kind: "env", Value: "book"}}})
+ m, etag, found, err = LoadInstanceConfig(ctx, s, "bab", "titogo", "book.customer.example.com")
+ if err != nil || !found || m == nil {
+ t.Fatalf("present overlay = %+v %v %v", m, found, err)
+ }
+ if etag == "" || etag != want {
+ t.Fatalf("etag = %q, want the object's own %q", etag, want)
+ }
+ if m.Vars["TENANT_SMTP_USER"].Value != "book" {
+ t.Fatalf("overlay lost its var: %+v", m.Vars)
+ }
+
+ // The same newer-schema refusal every other bundle read makes: acting on
+ // a manifest whose unknown fields json.Unmarshal has already dropped
+ // would silently discard whatever they meant.
+ putInstanceRaw(t, ctx, s, "bab", "titogo", "newer.example.com", &Manifest{Schema: 2, App: "titogo"})
+ if _, _, _, err := LoadInstanceConfig(ctx, s, "bab", "titogo", "newer.example.com"); err == nil {
+ t.Fatal("a schema-2 overlay was accepted, want a refusal naming the upgrade")
+ }
+
+ // A host that is not a bare hostname never reaches the bucket at all.
+ if _, _, _, err := LoadInstanceConfig(ctx, s, "bab", "titogo", "../config"); err == nil {
+ t.Fatal("LoadInstanceConfig accepted a traversal host")
+ }
+}
+
+// The write door. It returns the etag it wrote at, which is the whole
+// mechanism behind the delivery check (design §6.5): the caller keeps that
+// string and compares it with what an agent's ack says it rendered.
+func TestApplyInstanceWritesTheLayerAndReturnsItsETag(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+
+ removed, etag, err := ApplyInstance(ctx, s, "bab", "titogo", "book.customer.example.com",
+ map[string]Var{"TENANT_SMTP_USER": {Kind: "env", Value: "book"}}, nil)
+ if err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ if removed != 0 {
+ t.Fatalf("removed = %d on a create, want 0", removed)
+ }
+ if etag == "" {
+ t.Fatal("ApplyInstance returned an empty etag, so nothing can check delivery against it")
+ }
+ m, readETag, found, err := LoadInstanceConfig(ctx, s, "bab", "titogo", "book.customer.example.com")
+ if err != nil || !found {
+ t.Fatalf("LoadInstanceConfig after the write = %+v %v %v", m, found, err)
+ }
+ if readETag != etag {
+ t.Fatalf("the reader saw etag %q, the writer returned %q — the delivery check compares these two", readETag, etag)
+ }
+ if m.Schema != 1 || m.App != "titogo" || m.Vars["TENANT_SMTP_USER"].Value != "book" {
+ t.Fatalf("the written overlay is not a schema-1 manifest of this app: %+v", m)
+ }
+
+ // A second write moves the etag, which is what makes a stale ack
+ // detectable at all.
+ _, etag2, err := ApplyInstance(ctx, s, "bab", "titogo", "book.customer.example.com",
+ map[string]Var{"TENANT_SMTP_USER": {Kind: "env", Value: "book2"}}, nil)
+ if err != nil {
+ t.Fatalf("ApplyInstance (second): %v", err)
+ }
+ if etag2 == etag {
+ t.Fatalf("etag did not move on a changed write: %q", etag2)
+ }
+
+ // Unset reports how many of the keys were actually there, the same
+ // contract every other bundle's unset has.
+ removed, _, err = ApplyInstance(ctx, s, "bab", "titogo", "book.customer.example.com", nil,
+ []string{"TENANT_SMTP_USER", "NEVER_SET"})
+ if err != nil || removed != 1 {
+ t.Fatalf("unset = %d %v, want 1 and no error", removed, err)
+ }
+
+ // The var-name and var-value rules are the bundle's, not this layer's:
+ // applyAt refuses before anything reaches the bucket.
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.customer.example.com",
+ map[string]Var{"not a name": {Kind: "env", Value: "x"}}, nil); err == nil {
+ t.Fatal("an invalid var name was accepted into the instance layer")
+ }
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "../config", nil, nil); err == nil {
+ t.Fatal("ApplyInstance accepted a traversal host")
+ }
+}
+
+// An unset against an overlay that does not exist is refused rather than
+// minting an empty one, the same refusal applyAt already makes for the
+// other two bundles and for the same reason: an empty manifest that EXISTS
+// means "configured, then fully unset" by this package's contract, which is
+// a state the sweeps converge a file to.
+func TestApplyInstanceRefusesUnsetOnlyAgainstAMissingOverlay(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.customer.example.com", nil, []string{"K"})
+ if err == nil {
+ t.Fatal("unsetting from a nonexistent overlay was turned into a create")
+ }
+ if !strings.Contains(err.Error(), "book.customer.example.com") {
+ t.Fatalf("the refusal does not name the instance: %v", err)
+ }
+}
+
+// Writing the layer bumps the app's generation object, the way
+// SetExecDeliveryAt does. It has to: ConfigRouteFingerprints digests
+// registry columns only (routefingerprint.go:58) and cannot see a
+// bucket-side change, so without the bump an overlay would wait out the
+// agent's hourly floor with nothing to signal it.
+func TestWritingTheInstanceLayerBumpsTheGeneration(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"LOG_LEVEL": "debug"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ genETag := func() string {
+ t.Helper()
+ _, etag, err := s.Get(ctx, GenKey("bab", "titogo"))
+ if err != nil {
+ t.Fatalf("read generation: %v", err)
+ }
+ return etag
+ }
+ before := genETag()
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.customer.example.com",
+ map[string]Var{"TENANT_SMTP_USER": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ afterWrite := genETag()
+ if afterWrite == before {
+ t.Fatal("writing the instance layer did not bump the generation, so the fast loop would never see it")
+ }
+ if err := DeleteInstanceConfig(ctx, s, "bab", "titogo", "book.customer.example.com"); err != nil {
+ t.Fatalf("DeleteInstanceConfig: %v", err)
+ }
+ if genETag() == afterWrite {
+ t.Fatal("removing the instance layer did not bump the generation, so the route would keep its last merge")
+ }
+ if _, _, found, err := LoadInstanceConfig(ctx, s, "bab", "titogo", "book.customer.example.com"); err != nil || found {
+ t.Fatalf("the overlay survived its delete: found=%v err=%v", found, err)
+ }
+ // Deleting again is a no-op success, so a retried teardown converges.
+ if err := DeleteInstanceConfig(ctx, s, "bab", "titogo", "book.customer.example.com"); err != nil {
+ t.Fatalf("second DeleteInstanceConfig: %v", err)
+ }
+}
+
+// The precedence the design states: default < environment < instance
+// (§6.2). Three layers, each winning per key over the one below it, and the
+// instance layer's etag handed back so the caller can later check what was
+// delivered.
+func TestLoadMergedHostResolvesDefaultThenEnvironmentThenInstance(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{
+ "FROM_DEFAULT": "default", "FROM_ENV": "default", "FROM_INSTANCE": "default"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if _, err := ApplyEnvironment(ctx, s, "bab", "titogo", "prod", map[string]Var{
+ "FROM_ENV": {Kind: "env", Value: "environment"},
+ "FROM_INSTANCE": {Kind: "env", Value: "environment"}}, nil); err != nil {
+ t.Fatalf("ApplyEnvironment: %v", err)
+ }
+ _, want, err := ApplyInstance(ctx, s, "bab", "titogo", "book.customer.example.com", map[string]Var{
+ "FROM_INSTANCE": {Kind: "env", Value: "instance"}}, nil)
+ if err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+
+ m, etag, err := LoadMergedHost(ctx, s, "bab", "titogo", "prod", "book.customer.example.com")
+ if err != nil {
+ t.Fatalf("LoadMergedHost: %v", err)
+ }
+ if etag != want {
+ t.Fatalf("etag = %q, want the overlay's own %q", etag, want)
+ }
+ for k, wantVal := range map[string]string{
+ "FROM_DEFAULT": "default", "FROM_ENV": "environment", "FROM_INSTANCE": "instance"} {
+ if got := m.Vars[k].Value; got != wantVal {
+ t.Errorf("%s = %q, want %q", k, got, wantVal)
+ }
+ }
+
+ // A sibling with no overlay of its own gets the two-layer merge and an
+ // empty etag, which is what says "this host has no overlay" rather than
+ // "this agent did not render one".
+ m2, etag2, err := LoadMergedHost(ctx, s, "bab", "titogo", "prod", "other.customer.example.com")
+ if err != nil {
+ t.Fatalf("LoadMergedHost (sibling): %v", err)
+ }
+ if etag2 != "" {
+ t.Fatalf("a host with no overlay reported etag %q", etag2)
+ }
+ if m2.Vars["FROM_INSTANCE"].Value != "environment" {
+ t.Fatalf("the sibling saw another instance's value: %+v", m2.Vars)
+ }
+}
+
+// With no overlay, LoadMergedHost is LoadMerged, to the field. That matters
+// for the unbound case in particular: LoadMerged returns the default bundle
+// OBJECT there, exec_delivery and all, and a merge that quietly rebuilt it
+// would strip a flag other code reads off the same manifest.
+func TestLoadMergedHostWithNoOverlayIsExactlyLoadMerged(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"K": "v"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if err := SetExecDelivery(ctx, s, "bab", "titogo", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ want, err := LoadMerged(ctx, s, "bab", "titogo", "")
+ if err != nil {
+ t.Fatalf("LoadMerged: %v", err)
+ }
+ got, etag, err := LoadMergedHost(ctx, s, "bab", "titogo", "", "book.customer.example.com")
+ if err != nil || etag != "" {
+ t.Fatalf("LoadMergedHost = %q %v", etag, err)
+ }
+ if got.ExecDelivery != want.ExecDelivery || got.Vars["K"] != want.Vars["K"] || len(got.Vars) != len(want.Vars) {
+ t.Fatalf("LoadMergedHost diverged from LoadMerged with no overlay: %+v vs %+v", got, want)
+ }
+}
+
+// exec_delivery is never read from this layer (the ruling at
+// appconfig.go:66-71). An overlay that carries the field does not put it in
+// the merged view, so nothing downstream can act on it.
+func TestInstanceLayerNeverCarriesExecDelivery(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"K": "v"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ putInstanceRaw(t, ctx, s, "bab", "titogo", "book.customer.example.com", &Manifest{
+ Schema: 1, App: "titogo", Vars: map[string]Var{"OWN": {Kind: "env", Value: "1"}},
+ ExecDelivery: ExecDeliveryFile})
+ m, _, err := LoadMergedHost(ctx, s, "bab", "titogo", "", "book.customer.example.com")
+ if err != nil {
+ t.Fatalf("LoadMergedHost: %v", err)
+ }
+ if m.ExecDelivery != "" {
+ t.Fatalf("exec_delivery = %q: the instance layer must never decide delivery", m.ExecDelivery)
+ }
+ if m.Vars["OWN"].Value != "1" {
+ t.Fatalf("the overlay's vars were dropped along with its flag: %+v", m.Vars)
+ }
+}
+
+// A route bound to a bundle that does not exist is still an ERROR, overlay
+// or no overlay. The binding asserted the bundle exists; an overlay does not
+// make that assertion true, and answering with a partial merge would hand a
+// live route a file built from a binding nobody honoured.
+func TestLoadMergedHostStillRefusesAMissingBoundBundle(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"K": "v"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.customer.example.com",
+ map[string]Var{"OWN": {Kind: "env", Value: "1"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ if _, _, err := LoadMergedHost(ctx, s, "bab", "titogo", "gone", "book.customer.example.com"); err == nil {
+ t.Fatal("a missing bound bundle resolved anyway because the host had an overlay")
+ }
+}
+
+// The index the sweeps use: one listing per app answers "which of this
+// app's instances have an overlay at all", so a pass costs a Get only for
+// the instances that do.
+func TestInstanceOverlaysListsOnlyThisAppsHosts(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ have, err := instanceOverlays(ctx, s, "bab", "titogo")
+ if err != nil || len(have) != 0 {
+ t.Fatalf("empty app = %v %v, want an empty set and no error", have, err)
+ }
+ for _, host := range []string{"book.customer.example.com", "cart.customer.example.com"} {
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", host,
+ map[string]Var{"OWN": {Kind: "env", Value: host}}, nil); err != nil {
+ t.Fatalf("ApplyInstance %s: %v", host, err)
+ }
+ }
+ if _, _, err := ApplyInstance(ctx, s, "bab", "eleven", "other.example.com",
+ map[string]Var{"OWN": {Kind: "env", Value: "x"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance (other app): %v", err)
+ }
+ have, err = instanceOverlays(ctx, s, "bab", "titogo")
+ if err != nil {
+ t.Fatalf("instanceOverlays: %v", err)
+ }
+ if len(have) != 2 || !have["book.customer.example.com"] || !have["cart.customer.example.com"] {
+ t.Fatalf("instanceOverlays = %v, want just this app's two hosts", have)
+ }
+ if have["other.example.com"] {
+ t.Fatal("another app's overlay leaked into this app's index")
+ }
+}
+
+// countingStore counts the reads and writes a pass makes, per key, so a
+// test can pin the COST of a feature and not just its output. The instance
+// layer's promise is one extra listing per app and one extra Get per
+// instance that actually has an overlay, which is a claim only a counter
+// can keep honest.
+type countingStore struct {
+ release.Store
+ mu sync.Mutex
+ gets map[string]int
+ lists map[string]int
+ puts map[string]int
+}
+
+func newCountingStore(s release.Store) *countingStore {
+ return &countingStore{Store: s, gets: map[string]int{}, lists: map[string]int{}, puts: map[string]int{}}
+}
+
+func (c *countingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ c.mu.Lock()
+ c.gets[key]++
+ c.mu.Unlock()
+ return c.Store.Get(ctx, key)
+}
+
+func (c *countingStore) List(ctx context.Context, prefix string) ([]string, error) {
+ c.mu.Lock()
+ c.lists[prefix]++
+ c.mu.Unlock()
+ return c.Store.List(ctx, prefix)
+}
+
+func (c *countingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ c.mu.Lock()
+ c.puts[key]++
+ c.mu.Unlock()
+ return c.Store.Put(ctx, key, body, ifMatch)
+}
+
+func (c *countingStore) n(m map[string]int, k string) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return m[k]
+}
+
+// total sums every count in m, so a test can assert "this pass wrote
+// nothing to the release store at all" without naming every key it might
+// have touched.
+func (c *countingStore) total(m map[string]int) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ n := 0
+ for _, v := range m {
+ n += v
+ }
+ return n
+}
+
+// syncInstanceKit is the unit-path fixture these tests share: a store, a
+// registry, an env dir and a parsed key pair.
+func syncInstanceKit(t *testing.T) (context.Context, release.Store, *registry.Registry, string, *ecdh.PrivateKey) {
+ t.Helper()
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := testRegistry(t)
+ priv, _, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ privKey, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ return ctx, s, reg, t.TempDir(), privKey
+}
+
+func unitRoute(t *testing.T, reg *registry.Registry, host, app, acct, env string) {
+ t.Helper()
+ if err := reg.Upsert(registry.Route{
+ Host: host, Kind: "instance", App: app, Account: acct, Status: "ready",
+ Environment: env, Backing: registry.BackingUnit,
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+}
+
+// The delivery this layer exists for: one instance of an app gets a value
+// its siblings never see. The sibling's file is the app merge, untouched.
+func TestSyncRendersTheInstanceOverlayForItsOwnRouteOnly(t *testing.T) {
+ ctx, s, reg, envDir, priv := syncInstanceKit(t)
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes", "TENANT": "none"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
+ unitRoute(t, reg, "cart.example.com", "titogo", "bab", "")
+
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ book, err := os.ReadFile(filepath.Join(envDir, "book.example.com.env"))
+ if err != nil {
+ t.Fatalf("read book env: %v", err)
+ }
+ if !bytes.Contains(book, []byte("TENANT=book\n")) || !bytes.Contains(book, []byte("APP_WIDE=yes\n")) {
+ t.Fatalf("the overlaid route's file is wrong:\n%s", book)
+ }
+ cart, err := os.ReadFile(filepath.Join(envDir, "cart.example.com.env"))
+ if err != nil {
+ t.Fatalf("read cart env: %v", err)
+ }
+ if bytes.Contains(cart, []byte("book")) || !bytes.Contains(cart, []byte("TENANT=none\n")) {
+ t.Fatalf("a sibling saw another instance's config:\n%s", cart)
+ }
+
+ // The sweep and the single-shot API must agree about what a host gets.
+ // The sweeps resolve through the group cache and never call
+ // LoadMergedHost, so this is the only thing holding the two together.
+ want, _, err := LoadMergedHost(ctx, s, "bab", "titogo", "", "book.example.com")
+ if err != nil {
+ t.Fatalf("LoadMergedHost: %v", err)
+ }
+ rendered, err := RenderEnv(want, priv)
+ if err != nil {
+ t.Fatalf("RenderEnv: %v", err)
+ }
+ if !bytes.Contains(book, rendered) {
+ t.Fatalf("the pass and LoadMergedHost disagree:\npass:\n%s\nLoadMergedHost:\n%s", book, rendered)
+ }
+}
+
+// The cost. One listing per app, a Get for the instance that has an
+// overlay, and NOTHING for the instance that does not.
+func TestSyncCostsOneListingPerAppAndOneGetPerOverlay(t *testing.T) {
+ ctx, base, reg, envDir, priv := syncInstanceKit(t)
+ if err := SetEnv(ctx, base, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if _, _, err := ApplyInstance(ctx, base, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
+ unitRoute(t, reg, "cart.example.com", "titogo", "bab", "")
+ unitRoute(t, reg, "desk.example.com", "titogo", "bab", "")
+
+ s := newCountingStore(base)
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ if got := s.n(s.lists, InstancesConfigPrefix("bab", "titogo")); got != 1 {
+ t.Fatalf("instance overlay listings = %d, want exactly 1 for three routes of one app", got)
+ }
+ if got := s.n(s.gets, InstanceConfigKey("bab", "titogo", "book.example.com")); got != 1 {
+ t.Fatalf("overlay Gets for book = %d, want 1", got)
+ }
+ for _, host := range []string{"cart.example.com", "desk.example.com"} {
+ if got := s.n(s.gets, InstanceConfigKey("bab", "titogo", host)); got != 0 {
+ t.Fatalf("overlay Gets for %s = %d, want none: the listing already said it has no overlay", host, got)
+ }
+ }
+ // A convergence pass reads the release store, it never writes to it —
+ // every write this test triggers (SetEnv, ApplyInstance) went through
+ // the unwrapped base store during setup, before s ever saw a call.
+ if got := s.total(s.puts); got != 0 {
+ t.Fatalf("Sync put %d object(s) to the release store, want 0: this pass only writes local env files", got)
+ }
+}
+
+// The unit pass never delivers an exec-backed route's config at all
+// (TestSyncSkipsExecBackedRoutes) — SyncExec does, and reports its own
+// overlay failures on it correctly. Final review, plan 4: the unit pass
+// used to fetch the overlay for EVERY syncable route regardless of Backing
+// and only checked Backing 53 lines later, so it paid a Get for exactly the
+// exec-backed, overlay-bearing routes step 5 targets, and threw the result
+// away. This pins the fixed cost: zero Gets against that key from the unit
+// pass, whatever the route's overlay actually holds.
+func TestSyncPaysNoGetForAnExecBackedRoutesOverlay(t *testing.T) {
+ ctx, base, reg, envDir, priv := syncInstanceKit(t)
+ if err := SetEnv(ctx, base, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if _, _, err := ApplyInstance(ctx, base, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ // Exec-backed: Backing left at its zero value, the fleet default (same
+ // shape as TestSyncSkipsExecBackedRoutes).
+ if err := reg.Upsert(registry.Route{
+ Host: "book.example.com", Kind: "instance", App: "titogo", Account: "bab", Status: "ready",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ s := newCountingStore(base)
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ if got := s.n(s.gets, InstanceConfigKey("bab", "titogo", "book.example.com")); got != 0 {
+ t.Fatalf("unit pass Get'd an exec-backed route's overlay %d time(s), want 0: SyncExec owns delivering it, not Sync", got)
+ }
+ if _, err := os.Stat(filepath.Join(envDir, "book.example.com.env")); !os.IsNotExist(err) {
+ t.Fatalf("expected no env file for an exec-backed route, stat err = %v", err)
+ }
+}
+
+// A Get failure against an exec-backed route's overlay must be invisible to
+// the unit pass: gating the fetch on Backing == BackingUnit (rather than
+// merely discarding its result) means the failure never happens here at
+// all, so the pass cannot print the misleading "env file left as-is" claim
+// about a file it never writes for this route. SyncExec, exercised
+// separately, is the one that reports a failure on this exact key.
+func TestSyncPrintsNothingForAnExecBackedRoutesOverlayGetFailure(t *testing.T) {
+ ctx, base, reg, envDir, priv := syncInstanceKit(t)
+ if err := SetEnv(ctx, base, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if _, _, err := ApplyInstance(ctx, base, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); 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",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ failing := &failingKeyStore{Store: base, failKey: InstanceConfigKey("bab", "titogo", "book.example.com"),
+ err: errors.New("s3: transient 503 SlowDown")}
+ out := captureStdout(t, func() {
+ if err := Sync(ctx, reg, failing, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ })
+ if strings.Contains(out, "unresolved") {
+ t.Fatalf("the unit pass printed about an exec-backed route's overlay, which it never delivers or Gets:\n%s", out)
+ }
+ if !strings.Contains(out, "exec-backed") {
+ t.Fatalf("expected the ordinary exec-backed notice (this route still has a default bundle), got:\n%s", out)
+ }
+ if _, err := os.Stat(filepath.Join(envDir, "book.example.com.env")); !os.IsNotExist(err) {
+ t.Fatalf("expected no env file for an exec-backed route, stat err = %v", err)
+ }
+}
+
+// Two independently corrupt overlays are two independent failures, not one.
+// The listing itself succeeds (both hosts are named), so the "once per app"
+// dedup for a failed LISTING must not swallow the second route's Get
+// failure — that would leave one of two stale credential files invisible,
+// which is exactly what this layer exists to surface.
+//
+// book converges with a valid overlay first, so the case below covers the
+// sharper claim too: an already-delivered route's file survives untouched
+// when its overlay later becomes unreadable, not just that a route that
+// never had a file stays fileless.
+func TestSyncReportsEachRoutesOverlayGetFailureSeparately(t *testing.T) {
+ ctx, s, reg, envDir, priv := syncInstanceKit(t)
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ // Only book is on the box for the baseline pass, so cart genuinely never
+ // has a file at any point — registering it before the baseline would
+ // let it converge on the shared APP_WIDE bundle too, which would blur
+ // the two cases this test is pinning apart.
+ unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
+
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync (baseline): %v", err)
+ }
+ delivered, err := os.ReadFile(filepath.Join(envDir, "book.example.com.env"))
+ if err != nil {
+ t.Fatalf("read book's file after the baseline pass: %v", err)
+ }
+ if !bytes.Contains(delivered, []byte("TENANT=book\n")) {
+ t.Fatalf("the baseline overlay did not land: %q", delivered)
+ }
+ unitRoute(t, reg, "cart.example.com", "titogo", "bab", "")
+
+ // Schema 2 is refused by getIfExistsAtETag ("newer than this binary
+ // understands"), which is a Get-time failure specific to the object
+ // read, not a listing failure — instanceOverlays only reads keys, never
+ // their bodies, so the listing itself succeeds for both hosts. Deleting
+ // book's valid overlay first, then putting the corrupt one raw (Put with
+ // ifMatch "" is create-only), is what turns an already-delivered route's
+ // overlay unreadable, mid-flight.
+ if err := DeleteInstanceConfig(ctx, s, "bab", "titogo", "book.example.com"); err != nil {
+ t.Fatalf("DeleteInstanceConfig: %v", err)
+ }
+ putInstanceRaw(t, ctx, s, "bab", "titogo", "book.example.com", &Manifest{Schema: 2, App: "titogo"})
+ putInstanceRaw(t, ctx, s, "bab", "titogo", "cart.example.com", &Manifest{Schema: 2, App: "titogo"})
+
+ out := captureStdout(t, func() {
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ })
+ for _, host := range []string{"book.example.com", "cart.example.com"} {
+ if !strings.Contains(out, host) {
+ t.Fatalf("no line for %s's overlay failure, one route's stale file went unreported:\n%s", host, out)
+ }
+ }
+ if n := strings.Count(out, "instance config unresolved"); n != 2 {
+ t.Fatalf("instance config unresolved lines = %d, want 2 (one per corrupt overlay), got:\n%s", n, out)
+ }
+ // cart never had a file before this pass, and a Get failure must leave
+ // that exactly as it is: no env file materializes from a resolved group
+ // merge that an unresolved overlay was supposed to sit on top of.
+ if _, err := os.Stat(filepath.Join(envDir, "cart.example.com.env")); !os.IsNotExist(err) {
+ t.Fatalf("cart.example.com.env exists after an unresolved overlay: %v", err)
+ }
+ // book already had a delivered file, and its Get failure must leave that
+ // file exactly as it was: an overlay going unreadable is not licence to
+ // touch a route that already converged on it.
+ cur, err := os.ReadFile(filepath.Join(envDir, "book.example.com.env"))
+ if err != nil || !bytes.Equal(cur, delivered) {
+ t.Fatalf("book's already-delivered file changed after its overlay became unreadable: %q, want %q (err %v)", cur, delivered, err)
+ }
+}
+
+// A listing that fails is not a determination. The route keeps its file and
+// the pass says so, the same contract a bundle fetch failure already has.
+func TestSyncLeavesTheFileAloneWhenTheOverlayListingFails(t *testing.T) {
+ ctx, base, reg, envDir, priv := syncInstanceKit(t)
+ if err := SetEnv(ctx, base, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
+ path := filepath.Join(envDir, "book.example.com.env")
+ if err := os.WriteFile(path, []byte("APP_WIDE=stale\n"), 0o600); err != nil {
+ t.Fatalf("stage file: %v", err)
+ }
+ failing := &failingListStore{Store: base, failPrefix: InstancesConfigPrefix("bab", "titogo"),
+ err: errors.New("s3: transient 503 SlowDown")}
+ out := captureStdout(t, func() {
+ if err := Sync(ctx, reg, failing, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ })
+ cur, err := os.ReadFile(path)
+ if err != nil || string(cur) != "APP_WIDE=stale\n" {
+ t.Fatalf("the file moved on a listing failure: %q %v", cur, err)
+ }
+ if !strings.Contains(out, "book.example.com") || !strings.Contains(out, "left as-is") {
+ t.Fatalf("the pass was silent about an unresolved instance layer:\n%s", out)
+ }
+}
+
+// The dedup sentinel itself, pinned across TWO routes of one app sharing
+// one failed LISTING (review finding, plan 4 task 7 fix round 1): every
+// other test naming a listing failure uses a single route, so a mutation
+// that stopped telling a listing failure apart from a per-host one —
+// errors.As(...) always false, or instanceOverlayListErr's wrapper dropped
+// entirely — would still leave every one of them green. This asserts the
+// dedup's actual effect: one line, not two, with one host anchor naming
+// whichever route the pass happened to reach first.
+func TestSyncDedupsTheOverlayListingFailureAcrossTwoRoutesOfOneApp(t *testing.T) {
+ ctx, base, reg, envDir, priv := syncInstanceKit(t)
+ if err := SetEnv(ctx, base, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
+ unitRoute(t, reg, "cart.example.com", "titogo", "bab", "")
+ for _, host := range []string{"book.example.com", "cart.example.com"} {
+ if err := os.WriteFile(filepath.Join(envDir, host+".env"), []byte("APP_WIDE=stale\n"), 0o600); err != nil {
+ t.Fatalf("stage %s: %v", host, err)
+ }
+ }
+ failing := &failingListStore{Store: base, failPrefix: InstancesConfigPrefix("bab", "titogo"),
+ err: errors.New("s3: transient 503 SlowDown")}
+ out := captureStdout(t, func() {
+ if err := Sync(ctx, reg, failing, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ })
+ if n := strings.Count(out, "instance config unresolved"); n != 1 {
+ t.Fatalf("instance config unresolved lines = %d, want exactly 1 for one shared LISTING failure across two routes, got:\n%s", n, out)
+ }
+ if n := strings.Count(out, "(seen at "); n != 1 {
+ t.Fatalf("(seen at ...) host anchors = %d, want exactly 1, got:\n%s", n, out)
+ }
+ for _, host := range []string{"book.example.com", "cart.example.com"} {
+ cur, err := os.ReadFile(filepath.Join(envDir, host+".env"))
+ if err != nil || string(cur) != "APP_WIDE=stale\n" {
+ t.Fatalf("%s moved on a listing failure: %q %v", host, cur, err)
+ }
+ }
+}
+
+// The exec mirror: SyncExec had NO listing-failure test at all before this
+// (its said-once-per-app contract, stated in the doc comment above the
+// overlayFor loop, was pinned nowhere). Same two-route shape as the Sync
+// test above, same three mutations it exists to catch.
+func TestSyncExecDedupsTheOverlayListingFailureAcrossTwoRoutesOfOneApp(t *testing.T) {
+ ctx, base, reg, dd, privB64, _ := execKit(t)
+ priv, err := ParsePrivateKey(privB64, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SetEnv(ctx, base, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil {
+ t.Fatalf("SetEnv: %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",
+ Socket: "/run/carlos/" + host + ".sock",
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+ if err := os.WriteFile(filepath.Join(dd, host+".config.env"), []byte("OLD=1\n"), 0o600); err != nil {
+ t.Fatalf("stage %s: %v", host, err)
+ }
+ }
+ failing := &failingListStore{Store: base, failPrefix: InstancesConfigPrefix("bab", "titogo"),
+ err: errors.New("s3: transient 503 SlowDown")}
+ out := captureStdout(t, func() {
+ if err := SyncExec(ctx, reg, failing, priv, dd, func(string) error { return nil }, "", nil); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ })
+ if n := strings.Count(out, "instance config unresolved"); n != 1 {
+ t.Fatalf("instance config unresolved lines = %d, want exactly 1 for one shared LISTING failure across two routes, got:\n%s", n, out)
+ }
+ if n := strings.Count(out, "(seen at "); n != 1 {
+ t.Fatalf("(seen at ...) host anchors = %d, want exactly 1, got:\n%s", n, out)
+ }
+ for _, host := range []string{"book.example.com", "cart.example.com"} {
+ cur, err := os.ReadFile(filepath.Join(dd, host+".config.env"))
+ if err != nil || string(cur) != "OLD=1\n" {
+ t.Fatalf("%s moved on a listing failure: %q %v", host, cur, err)
+ }
+ }
+}
+
+// failingListStore makes List fail for exactly one prefix, the listing
+// shape of failingKeyStore (syncexec_test.go).
+type failingListStore struct {
+ release.Store
+ failPrefix string
+ err error
+}
+
+func (f *failingListStore) List(ctx context.Context, prefix string) ([]string, error) {
+ if prefix == f.failPrefix {
+ return nil, f.err
+ }
+ return f.Store.List(ctx, prefix)
+}
+
+// The CRITICAL 1 guard's second door. An app with no config.json, a route
+// bound to nothing, and an overlay: the merged view is that overlay ALONE,
+// which is a wholesale replacement of whatever a hand-staged file holds.
+// Refuse it, loudly, every pass, and name the way out.
+func TestSyncRefusesToDropHandStagedKeysForAnOverlayAlone(t *testing.T) {
+ ctx, s, reg, envDir, priv := syncInstanceKit(t)
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
+ path := filepath.Join(envDir, "book.example.com.env")
+ staged := "HAND_STAGED=1\nTENANT=old\n"
+ if err := os.WriteFile(path, []byte(staged), 0o600); err != nil {
+ t.Fatalf("stage file: %v", err)
+ }
+
+ var restarted []string
+ out := captureStdout(t, func() {
+ if err := Sync(ctx, reg, s, priv, envDir, func(h string) error {
+ restarted = append(restarted, h)
+ return nil
+ }, nil); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ })
+ cur, err := os.ReadFile(path)
+ if err != nil || string(cur) != staged {
+ t.Fatalf("a hand-staged file was replaced by an overlay-alone render: %q %v", cur, err)
+ }
+ if len(restarted) != 0 {
+ t.Fatalf("restarted %v for a route this pass refused", restarted)
+ }
+ if !strings.Contains(out, "HAND_STAGED") || !strings.Contains(out, "book.example.com") {
+ t.Fatalf("the refusal does not name the route and the key it would have dropped:\n%s", out)
+ }
+}
+
+// The same shape, self-healing: nothing is lost, so the route converges.
+// An app with no default bundle and an instance overlay is a legitimate
+// state (step 5 creates exactly that for a fresh instance), and this guard
+// must not freeze it forever.
+func TestSyncConvergesAnOverlayAloneWhenNothingIsLost(t *testing.T) {
+ ctx, s, reg, envDir, priv := syncInstanceKit(t)
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ cur, err := os.ReadFile(filepath.Join(envDir, "book.example.com.env"))
+ if err != nil {
+ t.Fatalf("read env file: %v", err)
+ }
+ if !bytes.Contains(cur, []byte("TENANT=book\n")) {
+ t.Fatalf("the overlay-alone render did not land:\n%s", cur)
+ }
+}
+
+// And the case that must NOT change: no default bundle, no overlay, no
+// binding is still "never configured at all", and a never-configured app's
+// hand-staged file is not this pass's to touch.
+func TestSyncStillLeavesUnconfiguredAppsAlone(t *testing.T) {
+ ctx, s, reg, envDir, priv := syncInstanceKit(t)
+ unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
+ path := filepath.Join(envDir, "book.example.com.env")
+ staged := "HAND_STAGED=1\n"
+ if err := os.WriteFile(path, []byte(staged), 0o600); err != nil {
+ t.Fatalf("stage file: %v", err)
+ }
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ cur, err := os.ReadFile(path)
+ if err != nil || string(cur) != staged {
+ t.Fatalf("an unconfigured app's file moved: %q %v", cur, err)
+ }
+}
+
+// The exec half of the same delivery: one activator-spawned instance gets
+// its own value, its sibling does not, and the listing is paid for once.
+func TestSyncExecRendersTheInstanceOverlayForItsOwnRouteOnly(t *testing.T) {
+ ctx, base, reg, dd, privB64, _ := execKit(t)
+ priv, err := ParsePrivateKey(privB64, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SetEnv(ctx, base, "bab", "titogo", map[string]string{"APP_WIDE": "yes", "TENANT": "none"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if _, _, err := ApplyInstance(ctx, base, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); 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",
+ Socket: "/run/carlos/" + host + ".sock",
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+ }
+
+ s := newCountingStore(base)
+ if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", nil); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ book, err := os.ReadFile(filepath.Join(dd, "book.example.com.config.env"))
+ if err != nil {
+ t.Fatalf("read book config: %v", err)
+ }
+ if !bytes.Contains(book, []byte("TENANT=book\n")) || !bytes.Contains(book, []byte("APP_WIDE=yes\n")) {
+ t.Fatalf("the overlaid route's config file is wrong:\n%s", book)
+ }
+ cart, err := os.ReadFile(filepath.Join(dd, "cart.example.com.config.env"))
+ if err != nil {
+ t.Fatalf("read cart config: %v", err)
+ }
+ if bytes.Contains(cart, []byte("book")) || !bytes.Contains(cart, []byte("TENANT=none\n")) {
+ t.Fatalf("a sibling saw another instance's config:\n%s", cart)
+ }
+ if got := s.n(s.lists, InstancesConfigPrefix("bab", "titogo")); got != 1 {
+ t.Fatalf("instance overlay listings = %d, want exactly 1 for two routes of one app", got)
+ }
+ if got := s.n(s.gets, InstanceConfigKey("bab", "titogo", "cart.example.com")); got != 0 {
+ t.Fatalf("read an overlay for a host the listing did not name (%d times)", got)
+ }
+}
+
+// exec_delivery is a BUNDLE-level flag and the instance layer is not one of
+// the bundles it is read from (the ruling at appconfig.go:66-71). A TCP-addr
+// route whose app has never opted in gets no file, whatever its overlay
+// says — a per-host delivery flag is exactly the granularity that ruling
+// refused, and honouring one here would be the config-that-lies hazard it
+// names.
+func TestSyncExecNeverReadsExecDeliveryFromTheInstanceLayer(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)
+ }
+ putInstanceRaw(t, ctx, s, "bab", "titogo", "router.example.com", &Manifest{
+ Schema: 1, App: "titogo", Vars: map[string]Var{"TENANT": {Kind: "env", Value: "book"}},
+ ExecDelivery: ExecDeliveryFile})
+ if err := reg.Upsert(registry.Route{
+ Host: "router.example.com", Kind: "instance", App: "titogo", Account: "bab",
+ Status: "ready", Addr: "127.0.0.1:8080",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", nil); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(dd, "router.example.com.config.env")); !os.IsNotExist(err) {
+ t.Fatalf("an instance overlay opted a route into file delivery: %v", err)
+ }
+
+ // Opt the APP in, and the same route gets the same overlay merged in the
+ // ordinary way — the flag's door is the bundle, the overlay's payload is
+ // vars.
+ if err := SetExecDelivery(ctx, s, "bab", "titogo", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", nil); err != nil {
+ t.Fatalf("SyncExec (opted in): %v", err)
+ }
+ cur, err := os.ReadFile(filepath.Join(dd, "router.example.com.config.env"))
+ if err != nil {
+ t.Fatalf("read router config: %v", err)
+ }
+ if !bytes.Contains(cur, []byte("TENANT=book\n")) {
+ t.Fatalf("the overlay's vars did not land once the app opted in:\n%s", cur)
+ }
+}
+
+// The exec mirror of TestSyncReportsEachRoutesOverlayGetFailureSeparately:
+// two independently corrupt overlays are two independent failures, not one.
+// The listing itself succeeds (both hosts are named), so the "once per app"
+// dedup for a failed LISTING must not swallow the second route's Get
+// failure — that would leave one of two stale config files invisible,
+// which is exactly what this layer exists to surface.
+func TestSyncExecReportsEachRoutesOverlayGetFailureSeparately(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)
+ }
+ // Schema 2 is refused by getIfExistsAtETag ("newer than this binary
+ // understands"), which is a Get-time failure specific to the object
+ // read, not a listing failure — instanceOverlays only reads keys, never
+ // their bodies, so the listing itself succeeds for both hosts.
+ putInstanceRaw(t, ctx, s, "bab", "titogo", "book.example.com", &Manifest{Schema: 2, App: "titogo"})
+ putInstanceRaw(t, ctx, s, "bab", "titogo", "cart.example.com", &Manifest{Schema: 2, App: "titogo"})
+ 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",
+ Socket: "/run/carlos/" + host + ".sock",
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+ }
+
+ out := captureStdout(t, func() {
+ if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", nil); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ })
+ for _, host := range []string{"book.example.com", "cart.example.com"} {
+ if !strings.Contains(out, host) {
+ t.Fatalf("no line for %s's overlay failure, one route's stale file went unreported:\n%s", host, out)
+ }
+ }
+ if n := strings.Count(out, "instance config unresolved"); n != 2 {
+ t.Fatalf("instance config unresolved lines = %d, want 2 (one per corrupt overlay), got:\n%s", n, out)
+ }
+ // Neither route had a file before this pass, and a Get failure must
+ // leave that exactly as it is: no config file materializes from a
+ // resolved group merge that an unresolved overlay was supposed to sit
+ // on top of.
+ for _, host := range []string{"book.example.com", "cart.example.com"} {
+ if _, err := os.Stat(filepath.Join(dd, host+".config.env")); !os.IsNotExist(err) {
+ t.Fatalf("%s.config.env exists after an unresolved overlay: %v", host, err)
+ }
+ }
+}
+
+// An app with no default bundle at all, and one instance with an overlay.
+// The overlay is the whole configuration of that route, so its file is
+// written, not removed — while its sibling, which has nothing anywhere, is
+// reaped exactly as it is today.
+func TestSyncExecConvergesAnOverlayAloneInsteadOfRemoving(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 := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); 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",
+ Socket: "/run/carlos/" + host + ".sock",
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+ if err := os.WriteFile(filepath.Join(dd, host+".config.env"), []byte("OLD=1\n"), 0o600); err != nil {
+ t.Fatalf("stage %s: %v", host, err)
+ }
+ }
+ if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", nil); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ book, err := os.ReadFile(filepath.Join(dd, "book.example.com.config.env"))
+ if err != nil {
+ t.Fatalf("the overlaid route's file was removed: %v", err)
+ }
+ if !bytes.Contains(book, []byte("TENANT=book\n")) || bytes.Contains(book, []byte("OLD=1")) {
+ t.Fatalf("the overlay-alone render did not converge:\n%s", book)
+ }
+ if _, err := os.Stat(filepath.Join(dd, "cart.example.com.config.env")); !os.IsNotExist(err) {
+ t.Fatalf("a route with no config anywhere kept its file: %v", err)
+ }
+}
+
+// The mass-removal guard, with an overlay in the middle of it. Two apps
+// whose manifests all read absent still freeze every removal on the box,
+// and the one route that has an overlay still converges: writing a file a
+// bundle asked for is not a removal, and the guard exists to stop
+// deletions, not delivery.
+func TestSyncExecMassRemovalGuardHoldsRemovalsButNotOverlays(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 := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ routes := map[string]string{
+ "book.example.com": "titogo",
+ "cart.example.com": "titogo",
+ "desk.example.com": "eleven",
+ }
+ for host, app := range routes {
+ if err := reg.Upsert(registry.Route{
+ Host: host, Kind: "instance", App: app, Account: "bab", Status: "ready",
+ Socket: "/run/carlos/" + host + ".sock",
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+ if err := os.WriteFile(filepath.Join(dd, host+".config.env"), []byte("OLD=1\n"), 0o600); err != nil {
+ t.Fatalf("stage %s: %v", host, err)
+ }
+ }
+ out := captureStdout(t, func() {
+ if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", nil); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ })
+ if !strings.Contains(out, "refusing to remove any config files this pass") {
+ t.Fatalf("the mass-removal guard did not fire:\n%s", out)
+ }
+ for _, host := range []string{"cart.example.com", "desk.example.com"} {
+ cur, err := os.ReadFile(filepath.Join(dd, host+".config.env"))
+ if err != nil || string(cur) != "OLD=1\n" {
+ t.Fatalf("%s lost its file while the guard was up: %q %v", host, cur, err)
+ }
+ }
+ book, err := os.ReadFile(filepath.Join(dd, "book.example.com.config.env"))
+ if err != nil {
+ t.Fatalf("the overlaid route was frozen by a guard about removals: %v", err)
+ }
+ if !bytes.Contains(book, []byte("TENANT=book\n")) {
+ t.Fatalf("the overlay did not converge under the guard:\n%s", book)
+ }
+}
+
+// The layer's only convergence signal is the generation object. The route
+// fingerprint cannot see a bucket write and must not start paying to try:
+// this pins both halves of that at once.
+func TestWritingAnOverlayMovesTheGenerationAndNotTheFingerprint(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ routes := []registry.Route{{
+ Host: "book.example.com", Kind: "instance", App: "titogo", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/book.sock",
+ }}
+ execBefore, unitBefore := ConfigRouteFingerprints(routes)
+ _, genBefore, err := s.Get(ctx, GenKey("bab", "titogo"))
+ if err != nil {
+ t.Fatalf("read generation: %v", err)
+ }
+
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+
+ execAfter, unitAfter := ConfigRouteFingerprints(routes)
+ if execAfter != execBefore || unitAfter != unitBefore {
+ t.Fatal("the route fingerprint moved for a bucket-side write, which would cost a read per route per tick")
+ }
+ _, genAfter, err := s.Get(ctx, GenKey("bab", "titogo"))
+ if err != nil {
+ t.Fatalf("read generation: %v", err)
+ }
+ if genAfter == genBefore {
+ t.Fatal("the generation did not move, so the fast loop has nothing to notice and delivery waits out the hourly floor")
+ }
+}
+
+// One pass over a box holding every shape this layer comes in: an instance
+// with an overlay over its app's bundle, a sibling with none, and an
+// instance whose app has no bundle at all so its overlay stands alone.
+// Then the overlay is removed and the route converges back, with the ack
+// saying so.
+func TestOnePassOverEveryInstanceLayerShape(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", "TENANT": "none"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ _, bookETag, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil)
+ if err != nil {
+ t.Fatalf("ApplyInstance (book): %v", err)
+ }
+ _, deskETag, err := ApplyInstance(ctx, s, "bab", "solo", "desk.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "desk"}}, nil)
+ if err != nil {
+ t.Fatalf("ApplyInstance (desk): %v", err)
+ }
+ for host, app := range map[string]string{
+ "book.example.com": "titogo", "cart.example.com": "titogo", "desk.example.com": "solo"} {
+ if err := reg.Upsert(registry.Route{
+ Host: host, Kind: "instance", App: app, Account: "bab", Status: "ready",
+ Socket: "/run/carlos/" + host + ".sock",
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+ }
+
+ acks := map[string]ConfigAck{}
+ collect := func(a ConfigAck) { acks[a.Host] = a }
+ if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", collect); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ read := func(host string) string {
+ t.Helper()
+ body, err := os.ReadFile(filepath.Join(dd, host+".config.env"))
+ if err != nil {
+ t.Fatalf("read %s: %v", host, err)
+ }
+ return string(body)
+ }
+ if got := read("book.example.com"); !strings.Contains(got, "TENANT=book\n") || !strings.Contains(got, "APP_WIDE=yes\n") {
+ t.Fatalf("book: %q", got)
+ }
+ if got := read("cart.example.com"); !strings.Contains(got, "TENANT=none\n") {
+ t.Fatalf("cart saw another instance's config: %q", got)
+ }
+ if got := read("desk.example.com"); got != "TENANT=desk\n" {
+ t.Fatalf("desk, whose app has no bundle, should render its overlay alone: %q", got)
+ }
+ for host, want := range map[string]string{
+ "book.example.com": bookETag, "cart.example.com": "", "desk.example.com": deskETag} {
+ a, ok := acks[host]
+ if !ok {
+ t.Fatalf("no ack for %s", host)
+ }
+ if a.Schema != ConfigAckSchema || a.InstanceConfigETag != want {
+ t.Fatalf("%s ack = %+v, want schema %d and etag %q", host, a, ConfigAckSchema, want)
+ }
+ if v := InstanceConfigDelivery(&a, want); v != InstanceConfigDelivered {
+ t.Fatalf("%s delivery = %q, want %q", host, v, InstanceConfigDelivered)
+ }
+ }
+
+ // Remove one overlay: that route converges back to its app's merge, and
+ // its ack stops naming an overlay, which is what tells a reader the
+ // removal actually reached the box.
+ if err := DeleteInstanceConfig(ctx, s, "bab", "titogo", "book.example.com"); err != nil {
+ t.Fatalf("DeleteInstanceConfig: %v", err)
+ }
+ acks = map[string]ConfigAck{}
+ if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", collect); err != nil {
+ t.Fatalf("SyncExec (after delete): %v", err)
+ }
+ if got := read("book.example.com"); !strings.Contains(got, "TENANT=none\n") {
+ t.Fatalf("book did not converge back to its app's merge: %q", got)
+ }
+ if a := acks["book.example.com"]; a.InstanceConfigETag != "" || a.Schema != ConfigAckSchema {
+ t.Fatalf("book ack after the delete = %+v, want the schema and no overlay", a)
+ }
+ if got := read("desk.example.com"); got != "TENANT=desk\n" {
+ t.Fatalf("desk was disturbed by a sibling app's delete: %q", got)
+ }
+}
+
+// The unit-path half of the removal claim. TestOnePassOverEveryInstanceLayerShape
+// pins the exec sweep's removal convergence; nothing else in the package
+// runs Sync (root's unit path) after a delete, so without this the claim
+// that a route "goes back to what the bundles alone say" once its overlay
+// is removed is only ever true for SyncExec.
+func TestSyncConvergesBackToTheBundleAfterAnOverlayIsDeleted(t *testing.T) {
+ ctx, s, reg, envDir, priv := syncInstanceKit(t)
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes", "TENANT": "none"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
+
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ before, err := os.ReadFile(filepath.Join(envDir, "book.example.com.env"))
+ if err != nil {
+ t.Fatalf("read env file before the delete: %v", err)
+ }
+ if !bytes.Contains(before, []byte("TENANT=book\n")) {
+ t.Fatalf("the overlay did not land before the delete: %q", before)
+ }
+
+ if err := DeleteInstanceConfig(ctx, s, "bab", "titogo", "book.example.com"); err != nil {
+ t.Fatalf("DeleteInstanceConfig: %v", err)
+ }
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync (after delete): %v", err)
+ }
+ after, err := os.ReadFile(filepath.Join(envDir, "book.example.com.env"))
+ if err != nil {
+ t.Fatalf("read env file after the delete: %v", err)
+ }
+ if bytes.Contains(after, []byte("TENANT=book\n")) || !bytes.Contains(after, []byte("TENANT=none\n")) {
+ t.Fatalf("the route did not converge back to the bundle alone after the overlay was deleted: %q", after)
+ }
+}
+
+// Every other test in this file passes a no-op restart callback, so a
+// regression that made resolving one route's overlay spuriously mark a
+// different route as changed would still pass the whole suite. This one
+// records which hosts restart is actually called for. The first pass
+// converges both routes from nothing and restarts both once, which is
+// ordinary first-convergence behaviour and not the claim under test; that
+// recording is discarded before the pass that matters, where only book's
+// overlay changes and cart's rendered bytes do not move at all.
+func TestSyncDoesNotRestartAnUnchangedSiblingWhileAnOverlayIsResolved(t *testing.T) {
+ ctx, s, reg, envDir, priv := syncInstanceKit(t)
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes", "TENANT": "none"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
+ unitRoute(t, reg, "cart.example.com", "titogo", "bab", "")
+
+ var restarted []string
+ record := func(host string) error {
+ restarted = append(restarted, host)
+ return nil
+ }
+ if err := Sync(ctx, reg, s, priv, envDir, record, nil); err != nil {
+ t.Fatalf("Sync (baseline): %v", err)
+ }
+ restarted = nil
+
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ if err := Sync(ctx, reg, s, priv, envDir, record, nil); err != nil {
+ t.Fatalf("Sync (overlay pass): %v", err)
+ }
+ if len(restarted) != 1 || restarted[0] != "book.example.com" {
+ t.Fatalf("restarted = %v, want exactly [book.example.com]: cart has no overlay and its rendered file did not change, so it must not restart", restarted)
+ }
+}
+
+// The exec mirror of the test above, over bounce rather than restart.
+func TestSyncExecDoesNotBounceAnUnchangedSiblingWhileAnOverlayIsResolved(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", "TENANT": "none"}); err != nil {
+ t.Fatalf("SetEnv: %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",
+ Socket: "/run/carlos/" + host + ".sock",
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+ }
+
+ var bounced []string
+ record := func(host string) error {
+ bounced = append(bounced, host)
+ return nil
+ }
+ if err := SyncExec(ctx, reg, s, priv, dd, record, "", nil); err != nil {
+ t.Fatalf("SyncExec (baseline): %v", err)
+ }
+ bounced = nil
+
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ if err := SyncExec(ctx, reg, s, priv, dd, record, "", nil); err != nil {
+ t.Fatalf("SyncExec (overlay pass): %v", err)
+ }
+ if len(bounced) != 1 || bounced[0] != "book.example.com" {
+ t.Fatalf("bounced = %v, want exactly [book.example.com]: cart has no overlay and its rendered file did not change, so it must not bounce", bounced)
+ }
+}
diff --git a/internal/appconfig/routefingerprint.go b/internal/appconfig/routefingerprint.go
index 10cd89e27..45550090b 100644
--- a/internal/appconfig/routefingerprint.go
+++ b/internal/appconfig/routefingerprint.go
@@ -51,6 +51,14 @@ import (
// app's generation object (SetExecDeliveryAt), which the loop already
// watches.
//
+// The INSTANCE CONFIG LAYER is deliberately not here either, and for the
+// same reason with a sharper edge: apps/<acct>/<app>/instances-config/
+// <host>.json is a bucket object, so seeing a change to one would cost a
+// listing per app per tick at best and a read per route at worst. It does
+// not need to be seen here — writing or deleting an overlay bumps the app's
+// generation object (ApplyInstance, DeleteInstanceConfig), which this loop
+// already watches, and that is the layer's whole signal.
+//
// Two digests, not one, because the two halves are delivered by different
// processes: the exec half by the agent itself (SyncExec), the unit half
// by a root pass the agent can only REQUEST. A shared digest would ask for
diff --git a/internal/appconfig/store.go b/internal/appconfig/store.go
index de97f2217..b78f5597d 100644
--- a/internal/appconfig/store.go
+++ b/internal/appconfig/store.go
@@ -48,28 +48,39 @@ func getIfExists(ctx context.Context, s release.Store, acct, app string) (*Manif
// getIfExistsAt is getIfExists generalized to any manifest key — Key(acct,
// app) for the default bundle, EnvironmentKey(acct, app, name) for a named
-// one (see environments.go). Same shape either way: a config.json and a
-// named environment bundle are the identical manifest format at sibling
-// bucket paths, so one read path serves both.
+// one (see environments.go), InstanceConfigKey(acct, app, host) for an
+// instance overlay (instances.go). Same shape either way: all three are the
+// identical manifest format at sibling bucket paths, so one read path
+// serves them.
func getIfExistsAt(ctx context.Context, s release.Store, key string) (*Manifest, bool, error) {
- raw, _, err := s.Get(ctx, key)
+ m, _, found, err := getIfExistsAtETag(ctx, s, key)
+ return m, found, err
+}
+
+// getIfExistsAtETag is getIfExistsAt plus the object's etag, which the
+// instance layer's reader needs and the other two bundles' readers do not:
+// the delivery ack names the etag of the overlay the agent rendered
+// (senders design §6.5), and an etag dropped here could not be recovered
+// later without a second read racing the first.
+func getIfExistsAtETag(ctx context.Context, s release.Store, key string) (*Manifest, string, bool, error) {
+ raw, etag, err := s.Get(ctx, key)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
- return nil, false, nil
+ return nil, "", false, nil
}
- return nil, false, err
+ return nil, "", false, err
}
var m Manifest
if err := json.Unmarshal(raw, &m); err != nil {
- return nil, false, fmt.Errorf("config %s: %w", key, err)
+ return nil, "", false, fmt.Errorf("config %s: %w", key, err)
}
// json.Unmarshal has already dropped any fields this binary doesn't
// know about, so acting on a newer-schema manifest would silently
// discard whatever those fields meant. Refuse instead.
if m.Schema > 1 {
- return nil, false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", key, m.Schema)
+ return nil, "", false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", key, m.Schema)
}
- return &m, true, nil
+ return &m, etag, true, nil
}
// getForWriteAt is Get plus the two bits a caller needs to write back: the
@@ -119,7 +130,7 @@ func apply(ctx context.Context, s release.Store, acct, app, bundle string, sets
if !release.ValidApp(app) {
return 0, fmt.Errorf("config: invalid app %q", app)
}
- removed, err := applyAt(ctx, s, Key(acct, app), app, bundle, sets, unsets)
+ removed, _, err := applyAt(ctx, s, Key(acct, app), app, bundle, sets, unsets)
if err != nil {
return 0, err
}
@@ -143,10 +154,10 @@ func apply(ctx context.Context, s release.Store, acct, app, bundle string, sets
// environment bundles that then satisfied `carlos route -environment`'s
// existence rail. Sets still create, which is how a bundle is meant to come
// into being.
-func applyAt(ctx context.Context, s release.Store, key, app, bundle string, sets map[string]Var, unsets []string) (int, error) {
+func applyAt(ctx context.Context, s release.Store, key, app, bundle string, sets map[string]Var, unsets []string) (int, string, error) {
for k, v := range sets {
if !ValidVarName(k) {
- return 0, fmt.Errorf("config: invalid var name %q", k)
+ return 0, "", fmt.Errorf("config: invalid var name %q", k)
}
// Env values are validated here, while the operator can still see
// the plaintext — a bad value that only failed at render time would
@@ -155,16 +166,16 @@ func applyAt(ctx context.Context, s release.Store, key, app, bundle string, sets
// CLI, before sealing; by this point they're opaque envelopes.
if v.Kind == "env" {
if err := ValidVarValue(v.Value); err != nil {
- return 0, fmt.Errorf("config: var %s: %w", k, err)
+ return 0, "", fmt.Errorf("config: var %s: %w", k, err)
}
}
}
m, etag, found, err := getForWriteAt(ctx, s, key, app)
if err != nil {
- return 0, err
+ return 0, "", err
}
if !found && len(sets) == 0 {
- return 0, fmt.Errorf("config: %s has no %s bundle — nothing to unset", app, bundle)
+ return 0, "", fmt.Errorf("config: %s has no %s bundle — nothing to unset", app, bundle)
}
if m.Vars == nil {
m.Vars = map[string]Var{}
@@ -182,12 +193,16 @@ func applyAt(ctx context.Context, s release.Store, key, app, bundle string, sets
m.Schema, m.App = 1, app
raw, err := json.MarshalIndent(m, "", " ")
if err != nil {
- return 0, err
+ return 0, "", err
}
- if _, err := s.Put(ctx, key, raw, etag); err != nil {
- return 0, err
+ // The new etag is returned because the instance layer's writer (design
+ // §6.5) has to hand it to whoever will later compare it with an agent's
+ // delivery ack. The other two bundles' callers ignore it.
+ newETag, err := s.Put(ctx, key, raw, etag)
+ if err != nil {
+ return 0, "", err
}
- return removed, nil
+ return removed, newETag, nil
}
// DefaultBundle is how the default bundle (config.json) is named to a human
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go
index 0fe6d4d6c..5e7135fdc 100644
--- a/internal/appconfig/sync.go
+++ b/internal/appconfig/sync.go
@@ -12,6 +12,7 @@ import (
"bytes"
"context"
"crypto/ecdh"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -67,6 +68,9 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec
// render is not an overlay, it is a wholesale replacement, so
// syncHost refuses to let it drop keys the file on disk already holds
// (see its doc comment; 2026-08-02 review, CRITICAL 1 and round 2).
+ // The materialization loop sets the same flag for an UNBOUND route
+ // whose only config is its instance overlay (senders design §6.2),
+ // which is the same replacement reached by a different door.
defaultMissing bool
}
resolved := map[groupKey]resolution{}
@@ -140,11 +144,27 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec
}
resolved[k] = resolution{m: m, defaultMissing: !def.found}
}
+ // The instance layer (senders design §6.2), resolved per route AFTER the
+ // group base above. The group is still (Account, App, Environment) and
+ // the default bundle is still read once per app: only the third layer is
+ // per host, and it costs one listing per app plus a Get for each
+ // instance that actually has an overlay.
+ overlayFor := instanceOverlayLoader(ctx, s)
+ // One line per app when the LISTING fails, not one per route: every
+ // route of that app fails identically and for the same reason, since
+ // instanceOverlayLoader caches that failure per (account, app) —
+ // independent of Environment, which is why this is keyed on appKey and
+ // not groupKey. A per-host Get or parse failure is a DIFFERENT claim —
+ // two routes can have two separately corrupt overlay objects, each
+ // wrong for its own reason — so that case is never folded in here; see
+ // the errors.As branch below.
+ said := map[appKey]bool{}
for _, rt := range routes {
if !syncable(rt) {
continue
}
- r := resolved[groupKey{release.RouteAccount(rt), rt.App, rt.Environment}]
+ k := groupKey{release.RouteAccount(rt), rt.App, rt.Environment}
+ r := resolved[k]
if r.err != nil {
// Loud, per route, every pass: this route is running on a stale
// env file until an operator fixes it. Its file is left exactly
@@ -156,10 +176,75 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec
fmt.Printf(" %s: config unresolved (environment %q), env file left as-is: %v\n", rt.Host, env, r.err)
continue
}
+ // The instance overlay wins per key over the group's merge, and is
+ // fetched BEFORE the m == nil skip below: an unbound route of an app
+ // 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.
+ //
+ // Fetched ONLY for unit-backed routes (final review, plan 4): an
+ // exec-backed route never reaches syncHost below — SyncExec renders
+ // its overlay and reports its own listing/Get failures on it — so a
+ // Get paid here would be charged and discarded for every exec-backed
+ // route that has one, which is exactly the shape step 5 targets. A
+ // Get or parse failure paid here for such a route would also print
+ // "env file left as-is" about a file this pass never writes, a claim
+ // SyncExec already makes correctly on its own. Deliberately NOT
+ // hoisting the rt.Backing check up to gate this whole block, though:
+ // that would make the "exec-backed" print below fire for an
+ // exec-backed route with no config at all, which it does not do
+ // today — the check stays exactly where it always was, and only the
+ // overlay fetch itself is narrowed.
+ var inst *Manifest
+ var instETag string
+ if rt.Backing == registry.BackingUnit {
+ var err error
+ inst, instETag, err = overlayFor(k.acct, k.app, rt.Host)
+ if err != nil {
+ var listErr *instanceOverlayListErr
+ if errors.As(err, &listErr) {
+ // Said ONCE per (account, app), not once per route: the
+ // listing this failed on is cached per app inside the
+ // loader, so every route of the app, across every
+ // environment it is bound to, fails identically and for
+ // the same reason; a line each would bury the cause under
+ // its own repetitions on a box with many routes.
+ ak := appKey{k.acct, k.app}
+ if !said[ak] {
+ said[ak] = true
+ // rt.Host anchors the line to a real route (the
+ // first one this pass happened to reach), so an
+ // operator scanning the log has something to grep
+ // for; every other route of this app is silently
+ // covered by the same line.
+ fmt.Printf(" %s/%s: instance config unresolved (seen at %s), env files left as-is: %v\n", k.acct, k.app, rt.Host, err)
+ }
+ } else {
+ // A per-host Get or parse failure, unlike the listing
+ // above, is specific to THIS route: the listing already
+ // succeeded (it named this host), so another route of
+ // the same app can have a perfectly good overlay, or a
+ // failure of its own for a different reason. Folding this
+ // into the app-level line would leave that route's stale
+ // credential file invisible, so it is reported every time.
+ fmt.Printf(" %s: instance config unresolved, env file left as-is: %v\n", rt.Host, err)
+ }
+ continue
+ }
+ }
m := r.m
+ if m == nil && inst == nil {
+ continue // never configured at all: this file is not ours to touch
+ }
+ // An overlay with no group base behind it is a wholesale replacement,
+ // not an overlay. it is the same state a bound route with no default
+ // bundle is in, reached without a binding. It gets the same guard:
+ // see syncHost.
+ defaultMissing := r.defaultMissing
if m == nil {
- continue
+ defaultMissing = true
}
+ m = applyInstanceOverlay(k.app, m, inst)
// Only unit-backed routes read /etc/carlos/apps — an exec-backed
// route's env comes from <DataDir>/<host>.env, which the activator
// owns (it also carries CARLOS_ADMIN_TOKEN, so it can't be blindly
@@ -172,7 +257,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, 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
@@ -249,7 +334,10 @@ func reap(routes []registry.Route, envDir string) {
// render-diff-write-restart path at all). RenderEnv emits exactly one
// KEY=value line per var, so the two key sets are the same by construction —
// except for the agent-owned names applyAgentVars adds to and removes from
-// the rendered file afterward, which is why they are skipped below.
+// the rendered file afterward, which is why they are skipped below. m is the
+// route's FULL merge, all three layers of it (default, environment,
+// instance), because the question is what the file would lose, not which
+// bundle a key came from.
//
// Blank lines and #-comments are ignored, matching how systemd's
// EnvironmentFile reads these files: they carry no configuration, so their
@@ -336,11 +424,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.
@@ -387,21 +479,29 @@ func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.Pr
// properly, at the cost of changing the rendered file format.
if defaultMissing && existed && len(cur) > 0 {
lost, parsed := lostKeys(cur, m)
+ // Which overlay is standing alone. A bound route names its
+ // environment; an unbound one is here because this instance has its
+ // own config layer (senders design §6.2) and its app has no default
+ // bundle, and naming an empty environment would be a riddle.
+ alone := "this instance's own config layer"
+ if rt.Environment != "" {
+ alone = fmt.Sprintf("environment %q", rt.Environment)
+ }
switch {
case !parsed:
// Unparseable content has unknown keys, so nothing here can prove
// the render drops none of them. Assume the worst — this guard's
// whole job is to be wrong in the safe direction.
- fmt.Printf(" %s: environment %q is bound but %s has no default config bundle, so the merged view is that environment ALONE — "+
+ fmt.Printf(" %s: %s has no default config bundle, so the merged view is %s ALONE — "+
"refusing to replace %s, which this pass cannot parse as KEY=value and so cannot prove is safe to overwrite; "+
"import its config (`carlos env set --app %s KEY=value ...`) or remove the file\n",
- rt.Host, rt.Environment, rt.App, path, rt.App)
+ rt.Host, rt.App, alone, path, rt.App)
return
case len(lost) > 0:
- fmt.Printf(" %s: environment %q is bound but %s has no default config bundle, so the merged view is that environment ALONE — "+
+ fmt.Printf(" %s: %s has no default config bundle, so the merged view is %s ALONE — "+
"refusing to drop %d var(s) already in %s (%s); import them (`carlos env set --app %s KEY=value ...`) "+
"and this route converges on the next pass, or remove the file if it is dead\n",
- rt.Host, rt.Environment, rt.App, len(lost), path, strings.Join(lost, ", "), rt.App)
+ rt.Host, rt.App, alone, len(lost), path, strings.Join(lost, ", "), rt.App)
return
}
}
@@ -448,7 +548,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 5e520b447..834a10f46 100644
--- a/internal/appconfig/syncexec.go
+++ b/internal/appconfig/syncexec.go
@@ -37,6 +37,7 @@ import (
"bytes"
"context"
"crypto/ecdh"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -362,6 +363,14 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
// beats an unbounded silent freeze; routes bound to an environment are
// protected by the error path regardless, since an unresolved group
// never reaches a removal at all.
+ //
+ // The instance layer does not enter this count, and does not need to.
+ // The fault this guard exists for is registry-wide — a mangled account
+ // column makes EVERY key of every app 404 at once, the overlay listing
+ // included, since it hangs off the same mangled prefix — so an overlay
+ // could not survive the fault it is counting. A route that does have an
+ // overlay never reaches a removal branch at all (see below), so it is
+ // neither protected nor endangered by this number.
eligible, absent := 0, 0
for _, r := range resolved {
if r.err != nil {
@@ -379,6 +388,21 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
fmt.Printf(" config sync: all %d resolvable (non-errored) config groups on this box resolved absent — refusing to remove any config files this pass; check the registry's account column and the deployment bucket\n", eligible)
}
+ // The instance layer (senders design §6.2), resolved per route after the
+ // group base. Same loader, same cost, same failure contract as the unit
+ // pass's: one listing per app, a Get only for a host it named, and an
+ // unresolved overlay leaves the file exactly as it is.
+ overlayFor := instanceOverlayLoader(ctx, s)
+ // One line per app when the LISTING fails, not one per route: every
+ // route of that app fails identically and for the same reason, since
+ // instanceOverlayLoader caches that failure per (account, app) —
+ // independent of Environment, which is why this is keyed on appKey and
+ // not groupKey. A per-host Get or parse failure is a DIFFERENT claim —
+ // two routes can have two separately corrupt overlay objects, each
+ // wrong for its own reason — so that case is never folded in here; see
+ // the errors.As branch below.
+ said := map[appKey]bool{}
+
for _, rt := range routes {
in, addrShape, sidecarShape := inScope(rt)
if !in {
@@ -405,7 +429,64 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
fmt.Printf(" %s: config unresolved (environment %q), config file left as-is: %v\n", rt.Host, env, r.err)
continue
}
- if r.m == nil && skipRemovals {
+ // Fetched here, used at the bottom, because a failure must be able to
+ // 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, instETag, instErr := overlayFor(k.acct, k.app, rt.Host)
+ if instErr != nil {
+ var listErr *instanceOverlayListErr
+ if errors.As(instErr, &listErr) {
+ // Said ONCE per (account, app), not once per route: the
+ // listing this failed on is cached per app inside the
+ // loader, so every route of the app, across every
+ // environment it is bound to, fails identically and for
+ // the same reason; a line each would bury the cause under
+ // its own repetitions on a box with many routes.
+ ak := appKey{k.acct, k.app}
+ if !said[ak] {
+ said[ak] = true
+ // rt.Host anchors the line to a real route (the first
+ // one this pass happened to reach), matching sync.go's
+ // own wording, so an operator scanning the log has a
+ // hostname to grep for — every other route of this app
+ // is silently covered by the same line.
+ fmt.Printf(" %s/%s: instance config unresolved (seen at %s), config files left as-is: %v\n", k.acct, k.app, rt.Host, instErr)
+ }
+ } else {
+ // A per-host Get or parse failure, unlike the listing
+ // above, is specific to THIS route: the listing already
+ // succeeded (it named this host), so another route of
+ // the same app can have a perfectly good overlay, or a
+ // failure of its own for a different reason. Folding this
+ // into the app-level line would leave that route's stale
+ // config file invisible, so it is reported every time.
+ fmt.Printf(" %s: instance config unresolved, config file left as-is: %v\n", rt.Host, instErr)
+ }
+ continue
+ }
+ // An overlay is configuration, so a route that has one is never
+ // "absent" for the MASS-REMOVAL guard below, however its group base
+ // resolved: writing a file a bundle asked for is not a removal.
+ //
+ // This narrowing (the `inst == nil` clause, added by this branch)
+ // makes the guard itself strictly narrower than it was before: a
+ // route with an overlay now sails through where it used to be held
+ // with everything else. That is backstopped, everywhere this
+ // narrowing actually matters (r.m == nil, i.e. a mangled account
+ // column made the group base read absent), by the #344 guard just
+ // below — EXCEPT for a custom-domain route. release.HostAccountLabel
+ // only derives a <sqid> to check from a PLATFORM-domain hostname; a
+ // custom-domain host's label is "", the #344 check's `sq != ""` test
+ // is false, and the guard simply does not fire. So a custom-domain
+ // route with an overlay, on a box whose account column is mangled in
+ // the exact way #344 exists to catch, has no backstop here at all —
+ // it sails through this guard AND past #344, and the wholesale
+ // overlay-alone replacement both guards were meant to prevent for it
+ // can still happen. Known and accepted (final review, plan 4): the
+ // fix would need a host-independent way to validate the account
+ // column, which #344 does not have either.
+ if r.m == nil && inst == nil && skipRemovals {
continue
}
// The #344 guard: an absent manifest removes the config file ONLY
@@ -415,6 +496,20 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
// stamps it from bucket listings, and a mis-stamp deleted a live
// tenant's delivered secrets on 2026-08-28). Held, loudly, like an
// unresolved group — the fix is the row's account, not this file.
+ //
+ // UNLIKE the mass-removal guard above, this hold is NOT narrowed by
+ // an overlay (review finding, plan 4 task 7 fix round 1: the brief's
+ // own reasoning — "a route with an overlay never reaches a removal
+ // branch anyway" — does not transfer here, because this is not a
+ // removal branch. It is "this account column cannot be trusted,
+ // touch nothing." A mis-stamp that makes the group's default-bundle
+ // READ land under the wrong prefix makes the instance layer's own
+ // WRITE (ApplyInstance) land under that same wrong prefix too, so an
+ // overlay resolving here under the mis-stamped account is exactly
+ // what a mis-stamped fleet produces — not evidence the account
+ // column is safe to act on for this route. Wholesale-replacing the
+ // file with that overlay's contents would be the 2026-08-28 failure
+ // again, just reached through the overlay instead of the base.
if r.m == nil {
if sq := release.HostAccountLabel(rt.Host, appsDomain); sq != "" && sq != k.acct {
fmt.Printf(" %s: no config manifest under account %q, but the host names account %q — config file left as-is; fix the route row's account column (#344)\n",
@@ -466,7 +561,15 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
case sidecarShape:
hostBounce = nil
}
- syncExecHost(rt, r.m, Key(k.acct, k.app), priv, dataDir, hostBounce, header, ack)
+ // The group's merge, overlaid by this instance's own layer. A nil
+ // base with an overlay is the overlay ALONE, which is a real
+ // configuration and not the absence that drives a removal; nil and
+ // nil together stay nil, which still removes the file.
+ m := r.m
+ if m != nil || inst != nil {
+ m = applyInstanceOverlay(k.app, m, inst)
+ }
+ 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
@@ -770,6 +873,11 @@ func (r RestartPending) Empty() bool { return len(r.Hosts) == 0 && len(r.Unresol
// short list read as a complete one.
func AwaitingRestart(ctx context.Context, s release.Store, routes []registry.Route, acct, app, envName string, keys []string) RestartPending {
var out RestartPending
+ // The instance layer, through the same loader the sweeps use, so this
+ // notice and the pass that writes the files agree about what sits above
+ // a bundle. One listing for the app, then a Get per route that has an
+ // overlay; both live for one call, like the caches below.
+ overlayFor := instanceOverlayLoader(ctx, s)
// Both caches exist for the same reason SyncExec's do: a box with many
// TCP-addr routes on one bundle must not cost more than one read for
// it. Unlike SyncExec's, they live for one call.
@@ -841,19 +949,33 @@ func AwaitingRestart(ctx context.Context, s release.Store, routes []registry.Rou
if !v.On {
continue
}
- // The shadowing test, and the reason keys is a parameter: a write
- // to the DEFAULT bundle changes what a bound route resolves to
- // only for keys that route's own bundle does not state. Where every
+ // The shadowing test, and the reason keys is a parameter: where every
// touched key is shadowed, the merged manifest is byte-identical,
// syncExecHost's bytes.Equal writes nothing, and naming the route
// would send someone to restart a process for a value it was never
- // going to see.
- //
- // Only for a default-bundle write reaching a BOUND route: a write
- // to the route's own bundle is never shadowed, and an unbound
- // route has nothing above it.
- if envName == "" && rt.Environment != "" && len(keys) > 0 && allShadowed(keys, eb.m) {
- continue
+ // going to see. An empty keys disables this whole test (nothing to
+ // shadow — see keys' own doc comment), so the instance layer is
+ // consulted only when it could actually change the verdict: a route
+ // whose overlay is unreadable must not be downgraded to Unresolved
+ // over a call that was never going to ask it anything.
+ if len(keys) > 0 {
+ // Which layers sit ABOVE the bundle that was written, for this
+ // route. A write cannot change what a route resolves to for a
+ // key some higher layer states, and the third layer sits above
+ // both of the other two.
+ inst, _, instErr := overlayFor(acct, app, rt.Host)
+ if instErr != nil {
+ out.Unresolved = append(out.Unresolved, rt.Host)
+ continue
+ }
+ var above []*Manifest
+ if envName == "" && rt.Environment != "" {
+ above = append(above, eb.m)
+ }
+ above = append(above, inst)
+ if allShadowed(keys, above...) {
+ continue
+ }
}
out.Hosts = append(out.Hosts, RestartHost{Host: rt.Host, Unit: rt.Unit})
}
@@ -865,16 +987,32 @@ func AwaitingRestart(ctx context.Context, s release.Store, routes []registry.Rou
return out
}
-// allShadowed reports whether every one of keys is STATED by m — the test
-// that decides whether a default-bundle write can have changed what a route
-// bound to m actually resolves to. An unset key counts the same as a set
-// one: removing a key the overlay shadows changes the merge by nothing.
-func allShadowed(keys []string, m *Manifest) bool {
- if m == nil {
- return false
- }
+// allShadowed reports whether every one of keys is STATED by at least one
+// of the layers above the bundle a write touched — the test that decides
+// whether that write can have changed what a route actually resolves to. An
+// unset key counts the same as a set one: removing a key a higher layer
+// shadows changes the merge by nothing.
+//
+// above is variadic because how many layers sit over a write depends on
+// which bundle was written: a default-bundle write to a bound route is
+// under both the named bundle and the instance overlay, a named-bundle
+// write is under the overlay alone, and a write to the overlay itself has
+// nothing over it. A nil layer (no bundle of that kind for this route) is
+// skipped rather than treated as stating nothing, so an empty list answers
+// false and the route is named.
+func allShadowed(keys []string, above ...*Manifest) bool {
for _, k := range keys {
- if _, ok := m.Vars[k]; !ok {
+ stated := false
+ for _, m := range above {
+ if m == nil {
+ continue
+ }
+ if _, ok := m.Vars[k]; ok {
+ stated = true
+ break
+ }
+ }
+ if !stated {
return false
}
}
@@ -936,6 +1074,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
@@ -945,7 +1088,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
@@ -1043,7 +1186,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 453569c88..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 {
@@ -2261,6 +2261,75 @@ func TestSyncExecHoldsConfigWhenHostNamesADifferentAccount(t *testing.T) {
}
}
+// The #344 guard is NOT a removal branch, and an overlay does not clear it
+// (task 7 fix round 1 — the brief's own reasoning, "a route with an overlay
+// never reaches a removal branch anyway," does not transfer to this guard).
+// A mis-stamp that makes the group's default-bundle READ land under the
+// wrong account prefix makes the instance layer's own WRITE
+// (ApplyInstance) land under that same wrong prefix too, so an overlay
+// resolving here under the mis-stamped account is exactly what a
+// mis-stamped fleet produces — not evidence the account column is safe to
+// act on. Sibling of TestSyncExecHoldsConfigWhenHostNamesADifferentAccount,
+// with an overlay added to the held route.
+func TestSyncExecHoldsConfigWhenHostNamesADifferentAccountEvenWithAnOverlay(t *testing.T) {
+ ctx, s, reg, dd, priv, _ := execKit(t)
+ pk, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "birthday-alarm.bde.oncarlos.test", Kind: "instance", App: "birthday-alarm",
+ Account: "bab", // the mis-stamp
+ Status: "ready", Socket: "/run/carlos/birthday.sock",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ // The overlay lands at apps/bab/birthday-alarm/instances-config/ — the
+ // SAME mis-stamped prefix the group's absent default bundle was looked
+ // up under, which is the whole point: a mangled account column mangles
+ // both.
+ if _, _, err := ApplyInstance(ctx, s, "bab", "birthday-alarm", "birthday-alarm.bde.oncarlos.test",
+ map[string]Var{"TENANT": {Kind: "env", Value: "mis-stamped"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ // A second, healthy group so the mass-removal guard (which needs EVERY
+ // group absent) stays out of the way — this guard is per-route.
+ if err := reg.Upsert(registry.Route{
+ Host: "inst.example.com", Kind: "instance", App: "eleven", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/inst.sock",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ if err := SetEnv(ctx, s, "bab", "eleven", map[string]string{"A": "1"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ path := filepath.Join(dd, "birthday-alarm.bde.oncarlos.test.config.env")
+ if err := os.WriteFile(path, []byte("BIRTHDAYALARM_INSTANCE_KEY=deadbeef\n"), 0o600); err != nil {
+ t.Fatalf("stage: %v", err)
+ }
+ var bounced []string
+ out := captureStdout(t, func() {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
+ bounced = append(bounced, h)
+ return nil
+ }, "oncarlos.test", nil); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ })
+ cur, err := os.ReadFile(path)
+ if err != nil || string(cur) != "BIRTHDAYALARM_INSTANCE_KEY=deadbeef\n" {
+ t.Fatalf("delivered config was replaced by the overlay under a mis-stamped account: %q %v", cur, err)
+ }
+ for _, h := range bounced {
+ if h == "birthday-alarm.bde.oncarlos.test" {
+ t.Fatalf("held route was bounced: %v", bounced)
+ }
+ }
+ if !strings.Contains(out, "birthday-alarm.bde.oncarlos.test") || !strings.Contains(out, "#344") {
+ t.Fatalf("the #344 hold did not fire for a route that has an overlay:\n%s", out)
+ }
+}
+
// TestSyncExecWritesConfigAckForSocketExecRoute is the review's finding 1
// (2026-09-07): syncExecHost DOES bounce a socket-exec route (bounce is
// non-nil for it, only a tcp-exec-file/addr route gets nil), so its ack