carlos / platform Public

appconfig: pin the instance layer's signal and one pass over every shape of it (plan 4, task 11)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev f042623e5cc7247ad2ad5a1df8b25f25e5b5109b parent 9e0ed51
3 files changed, +142 −1
  • internal/appconfig/appconfig.go +6 −1
  • internal/appconfig/instances_test.go +128 −0
  • internal/appconfig/routefingerprint.go +8 −0
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/instances_test.go b/internal/appconfig/instances_test.go
index 3ac51fb54..eeca7a231 100644
--- a/internal/appconfig/instances_test.go
+++ b/internal/appconfig/instances_test.go
@@ -1051,3 +1051,131 @@ func TestSyncExecMassRemovalGuardHoldsRemovalsButNotOverlays(t *testing.T) {
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)
+ }
+}
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