carlos / platform Public

appconfig: config generation object — every write leaves a cheap etag to watch (issue #44)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WVANvB83eHrRANRpEL8mn
Paul Campbell 5c6606e583b899a45e3a43e60774f27660ce72af parent c0fec0a
8 files changed, +572 −4
  • internal/appconfig/crypto.go +31 −0
  • internal/appconfig/crypto_test.go +66 −0
  • internal/appconfig/environments.go +12 −1
  • internal/appconfig/generation.go +128 −0
  • internal/appconfig/generation_test.go +212 −0
  • internal/appconfig/store.go +21 −3
  • internal/appconfig/syncexec.go +43 −0
  • internal/appconfig/syncexec_test.go +59 −0
diff --git a/internal/appconfig/crypto.go b/internal/appconfig/crypto.go
index 2019e602c..aa2630762 100644
--- a/internal/appconfig/crypto.go
+++ b/internal/appconfig/crypto.go
@@ -144,6 +144,37 @@ func Open(priv *ecdh.PrivateKey, envelopeB64 string) ([]byte, error) {
return plaintext, nil
}
+// CheckEnvelope structurally validates a sealed envelope WITHOUT any key
+// material: it base64 decodes, the ephemeral point parses, and there is room
+// for a nonce and a GCM tag. The console door runs this at SET time for the
+// same reason applyAt runs ValidVarValue then — a garbage envelope accepted
+// now would stall the app's WHOLE config at render time, hours later, on
+// every host, and out of sight of whoever pasted it.
+//
+// It cannot prove decryptability: only the box holding CARLOS_SECRETS_KEY
+// can do that, and by design the console never holds it. What it proves is
+// the shape Open would not reject outright — the cheap half of Open's own
+// preamble, minus the ECDH. Errors name the defect and NEVER echo the
+// envelope: it is someone's ciphertext, and this function's callers put its
+// error text into HTTP responses and logs.
+func CheckEnvelope(envelopeB64 string) error {
+ raw, err := base64.StdEncoding.DecodeString(envelopeB64)
+ if err != nil {
+ return errors.New("envelope is not valid base64")
+ }
+ // 16: the GCM tag, which is present even for empty plaintext — so this
+ // is the length of the shortest envelope Seal can produce, not an
+ // arbitrary floor.
+ const minLen = ephLen + nonceLen + 16
+ if len(raw) < minLen {
+ return fmt.Errorf("envelope too short (%d bytes, want at least %d)", len(raw), minLen)
+ }
+ if _, err := ecdh.P256().NewPublicKey(raw[:ephLen]); err != nil {
+ return errors.New("envelope has no valid ephemeral key")
+ }
+ return nil
+}
+
// gcmFor derives the AES-256-GCM cipher an ECDH shared secret drives.
func gcmFor(shared []byte) (cipher.AEAD, error) {
r := hkdf.New(sha256.New, shared, nil, []byte(hkdfInfo))
diff --git a/internal/appconfig/crypto_test.go b/internal/appconfig/crypto_test.go
index 2218392fa..ad8461397 100644
--- a/internal/appconfig/crypto_test.go
+++ b/internal/appconfig/crypto_test.go
@@ -5,6 +5,7 @@ import (
"bytes"
"crypto/ecdh"
"encoding/base64"
+ "strings"
"testing"
)
@@ -112,3 +113,68 @@ func TestOpenRejectsTamperedCiphertext(t *testing.T) {
t.Fatal("Open accepted tampered ciphertext, want an error")
}
}
+
+// TestCheckEnvelopeAcceptsSealed pins the property the console door relies
+// on: anything Seal produces passes the structural check, including the
+// shortest legal envelope (empty plaintext, which is still a full GCM tag).
+// If these two ever diverge, the door starts refusing envelopes the box
+// could have opened.
+func TestCheckEnvelopeAcceptsSealed(t *testing.T) {
+ _, pub, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ pubKey, err := ParsePublicKey(pub, "test")
+ if err != nil {
+ t.Fatalf("ParsePublicKey: %v", err)
+ }
+ for _, plaintext := range [][]byte{[]byte("s3kr1t"), {}} {
+ envelope, err := Seal(pubKey, plaintext)
+ if err != nil {
+ t.Fatalf("Seal(%q): %v", plaintext, err)
+ }
+ if err := CheckEnvelope(envelope); err != nil {
+ t.Fatalf("CheckEnvelope rejected a Seal envelope for plaintext %q: %v", plaintext, err)
+ }
+ }
+}
+
+// TestCheckEnvelopeRefusals walks the structural defects the door has to
+// catch at SET time, and asserts on the one thing every refusal must have in
+// common besides being an error: it names the DEFECT and never echoes the
+// envelope. An envelope is ciphertext an operator may have pasted from
+// anywhere; a refusal that quotes it back puts it into console logs, audit
+// rows and HTTP responses for nothing.
+func TestCheckEnvelopeRefusals(t *testing.T) {
+ // 65 bytes that are not a P-256 point (0xAA is not the 0x04
+ // uncompressed-point tag), padded past the minimum length so the length
+ // check cannot be what refuses it.
+ notAPoint := make([]byte, ephLen+nonceLen+16)
+ for i := range notAPoint {
+ notAPoint[i] = 0xAA
+ }
+ cases := []struct {
+ name string
+ envelope string
+ want string // substring naming the defect
+ }{
+ {"empty", "", "too short"},
+ {"not base64", "!!!not base64!!!", "base64"},
+ {"too short", base64.StdEncoding.EncodeToString(make([]byte, ephLen+nonceLen+15)), "too short"},
+ {"ephemeral key is not a point", base64.StdEncoding.EncodeToString(notAPoint), "ephemeral key"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := CheckEnvelope(tc.envelope)
+ if err == nil {
+ t.Fatalf("CheckEnvelope accepted %s", tc.name)
+ }
+ if !strings.Contains(err.Error(), tc.want) {
+ t.Fatalf("error %q does not name the defect (want it to mention %q)", err, tc.want)
+ }
+ if tc.envelope != "" && strings.Contains(err.Error(), tc.envelope) {
+ t.Fatalf("error echoes the envelope back: %q", err)
+ }
+ })
+ }
+}
diff --git a/internal/appconfig/environments.go b/internal/appconfig/environments.go
index 552d19bca..d37c9c18e 100644
--- a/internal/appconfig/environments.go
+++ b/internal/appconfig/environments.go
@@ -99,6 +99,12 @@ func UnsetEnvironment(ctx context.Context, s release.Store, acct, app, envName s
return applyEnvironment(ctx, s, acct, app, envName, nil, keys)
}
+// applyEnvironment is apply's environment-scoped twin — same validation,
+// same applyAt, and the same post-success generation touch. The generation
+// object is per-APP, not per-bundle (generation.go): SyncExec resolves each
+// route's own (app, environment) pair itself, so one signal per app is
+// enough, and a per-bundle signal would mean the watcher had to enumerate
+// bundles to know what to watch.
func applyEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) (int, error) {
if !release.ValidAccount(acct) {
return 0, fmt.Errorf("config: invalid account %q", acct)
@@ -109,7 +115,12 @@ 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)
}
- return 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
+ }
+ touchLogged(ctx, s, acct, app)
+ return removed, nil
}
// ListEnvironments returns app's named environment bundle names (bare
diff --git a/internal/appconfig/generation.go b/internal/appconfig/generation.go
new file mode 100644
index 000000000..81f360eaf
--- /dev/null
+++ b/internal/appconfig/generation.go
@@ -0,0 +1,128 @@
+// The convergence signal: one tiny object per app that every successful
+// config write touches, so a watcher can learn "something changed" from a
+// single cheap etag instead of re-reading every bundle on a timer. See spec
+// 2026-08-04-config-console-api-design.md §1 (issue #44) — config should
+// converge the way a canary adopts, in seconds, without restarting the
+// box's shared edge service to deliver one app's variable.
+//
+// Bucket layout addition:
+//
+// apps/<acct>/<app>/config-gen.json CAS-guarded, schema-1 generation object
+package appconfig
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Generation is the small sibling object every config write touches —
+// apps/<acct>/<app>/config-gen.json — so the agent's fast loop (host
+// package) can watch ONE cheap etag per app instead of re-reading every
+// bundle hourly. One per app across ALL bundles: SyncExec resolves
+// per-(app, environment) itself, so the signal only needs app granularity.
+// Change detection is by etag; the fields exist for humans debugging a
+// convergence question and to make each touch's bytes distinct (a
+// content-derived etag, which DirStore uses, would not move for a
+// byte-identical rewrite). Absent is legal forever — an app last written
+// by an older CLI has no generation object and never will until someone
+// writes its config again; the hourly configSyncLoop remains the floor.
+type Generation struct {
+ Schema int `json:"schema"` // 1
+ TouchedAt string `json:"touched_at"`
+ Nonce string `json:"nonce"`
+}
+
+// GenKey is the bucket key of app's generation object, derived from
+// release.AppPrefix for the same reason Key is: everything an app owns sits
+// under one account-scoped prefix, permanently. Deliberately a sibling of
+// config.json rather than a field inside it — a field would mean every
+// watcher re-reads (and re-parses) the whole manifest, and would make the
+// signal race the very write it is signalling.
+func GenKey(acct, app string) string { return release.AppPrefix(acct, app) + "config-gen.json" }
+
+// TouchGeneration bumps app's generation object, the signal that this app's
+// config changed. A CAS conflict is SUCCESS, not an error: it means another
+// writer touched the generation between our read and our write, so the
+// signal this function exists to fire has already fired — retrying would
+// only produce a second, redundant bump. os.ErrNotExist on the read means
+// first touch, so the write is create-only (etag ""), which is what makes
+// the conflict carve-out safe: two first touches race, one wins, both
+// callers are correct.
+//
+// Exported because `carlos env sync` (and the console door behind it) is a
+// bare touch with no config write attached — the escape hatch for "apply
+// what's already in the bucket, now".
+func TouchGeneration(ctx context.Context, s release.Store, acct, app 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)
+ }
+ key := GenKey(acct, app)
+ _, etag, err := s.Get(ctx, key)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ if errors.Is(err, os.ErrNotExist) {
+ etag = ""
+ }
+ nonce := make([]byte, 16)
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return err
+ }
+ raw, err := json.Marshal(Generation{
+ Schema: 1,
+ TouchedAt: time.Now().UTC().Format(time.RFC3339),
+ Nonce: hex.EncodeToString(nonce),
+ })
+ if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, key, raw, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ return nil
+ }
+ return err
+ }
+ return nil
+}
+
+// touchLogged is TouchGeneration demoted to a log line, for use AFTER a
+// successful manifest write: the write landed, so a failed touch must not
+// fail the call — reporting failure for a change that is live would send an
+// operator to re-run a write that already happened. The real cost of a
+// failed touch is that convergence falls back to the hourly pass, which is
+// exactly where every pre-generation CLI already leaves it.
+func touchLogged(ctx context.Context, s release.Store, acct, app string) {
+ if err := TouchGeneration(ctx, s, acct, app); err != nil {
+ log.Printf("config: %s/%s: generation touch failed (converges on the hourly pass): %v", acct, app, err)
+ }
+}
+
+// Apply writes sets and removes unsets from the DEFAULT bundle in one CAS
+// write, returning how many of unsets were actually present — the console
+// door's shape, since one PUT may carry both halves and two CAS writes would
+// mean a half-applied request whenever the second one lost a race.
+// SetEnv/SetSecret/Unset remain the narrow verbs the CLI's direct mode uses;
+// this is the same underlying path, just not pre-narrowed to one kind.
+func Apply(ctx context.Context, s release.Store, acct, app string, sets map[string]Var, unsets []string) (int, error) {
+ return apply(ctx, s, acct, app, DefaultBundle, sets, unsets)
+}
+
+// ApplyEnvironment is Apply for a named environment bundle — same one-write
+// mixed set/unset contract, same refusal to create a bundle on an
+// unset-only call (see applyAt).
+func ApplyEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) (int, error) {
+ return applyEnvironment(ctx, s, acct, app, envName, sets, unsets)
+}
diff --git a/internal/appconfig/generation_test.go b/internal/appconfig/generation_test.go
new file mode 100644
index 000000000..db6dd83ad
--- /dev/null
+++ b/internal/appconfig/generation_test.go
@@ -0,0 +1,212 @@
+// internal/appconfig/generation_test.go
+package appconfig
+
+import (
+ "context"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// genEtag reads the app's generation etag, "" for absent — the exact
+// observation the agent's fast loop makes, so a test that asserts on it is
+// asserting on the signal itself rather than on this package's internals.
+func genEtag(t *testing.T, s release.Store, acct, app string) string {
+ t.Helper()
+ _, etag, err := s.Get(context.Background(), GenKey(acct, app))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return ""
+ }
+ t.Fatalf("read generation: %v", err)
+ }
+ return etag
+}
+
+func TestTouchGenerationCreatesAndMoves(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := TouchGeneration(ctx, s, "bab", "keymail"); err != nil {
+ t.Fatalf("first TouchGeneration: %v", err)
+ }
+ raw, first, err := s.Get(ctx, GenKey("bab", "keymail"))
+ if err != nil {
+ t.Fatalf("Get after first touch: %v", err)
+ }
+ var g Generation
+ if err := json.Unmarshal(raw, &g); err != nil {
+ t.Fatalf("decode generation: %v", err)
+ }
+ if g.Schema != 1 {
+ t.Fatalf("Schema = %d, want 1", g.Schema)
+ }
+ if _, err := time.Parse(time.RFC3339, g.TouchedAt); err != nil {
+ t.Fatalf("TouchedAt %q is not RFC3339: %v", g.TouchedAt, err)
+ }
+ if nonce, err := hex.DecodeString(g.Nonce); err != nil || len(nonce) == 0 {
+ t.Fatalf("Nonce = %q, want nonempty hex (decode err %v)", g.Nonce, err)
+ }
+ // The etag is the whole point: a second touch must be a DIFFERENT
+ // object, or the loop watching it sees nothing. DirStore's etag is
+ // content-derived, so this also pins that each touch's bytes differ.
+ if err := TouchGeneration(ctx, s, "bab", "keymail"); err != nil {
+ t.Fatalf("second TouchGeneration: %v", err)
+ }
+ if second := genEtag(t, s, "bab", "keymail"); second == first {
+ t.Fatalf("etag %q unchanged after a second touch — the change signal never fires", second)
+ }
+}
+
+// racingPutStore lands a different writer's value at key just before
+// forwarding the caller's Put, which then fails CAS against the etag the
+// caller read — the exact race two operators (or an operator and the
+// console door) running config writes at the same moment produce.
+type racingPutStore struct {
+ release.Store
+ key string
+ done bool
+}
+
+func (r *racingPutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if key == r.key && !r.done {
+ r.done = true
+ other := []byte(`{"schema":1,"touched_at":"2026-01-01T00:00:00Z","nonce":"00112233445566778899aabbccddeeff"}`)
+ if _, err := r.Store.Put(ctx, key, other, ifMatch); err != nil {
+ return "", err
+ }
+ }
+ return r.Store.Put(ctx, key, body, ifMatch)
+}
+
+func TestTouchGenerationCASConflictIsSuccess(t *testing.T) {
+ inner := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := TouchGeneration(ctx, inner, "bab", "keymail"); err != nil {
+ t.Fatalf("seed TouchGeneration: %v", err)
+ }
+ racing := &racingPutStore{Store: inner, key: GenKey("bab", "keymail")}
+ if err := TouchGeneration(ctx, racing, "bab", "keymail"); err != nil {
+ t.Fatalf("TouchGeneration on a lost CAS race: %v, want nil (the signal already fired)", err)
+ }
+ if !racing.done {
+ t.Fatal("the racing writer never ran — this test proved nothing")
+ }
+}
+
+// TestTouchGenerationSurfacesRealPutErrors is the other half of the CAS
+// carve-out: only ErrCASConflict is success. Any other store failure means
+// nobody touched the generation, so the caller must hear about it.
+func TestTouchGenerationSurfacesRealPutErrors(t *testing.T) {
+ s := &failingPutStore{Store: release.NewDirStore(t.TempDir()), err: errors.New("bucket is on fire")}
+ if err := TouchGeneration(context.Background(), s, "bab", "keymail"); err == nil {
+ t.Fatal("TouchGeneration swallowed a non-CAS Put error, want it surfaced")
+ }
+}
+
+// failingPutStore fails every Put with a fixed non-CAS error.
+type failingPutStore struct {
+ release.Store
+ err error
+}
+
+func (f *failingPutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ return "", f.err
+}
+
+func TestTouchGenerationRefusesInvalidNames(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := TouchGeneration(ctx, s, "../etc", "keymail"); err == nil {
+ t.Fatal("TouchGeneration accepted an invalid account")
+ }
+ if err := TouchGeneration(ctx, s, "bab", "../etc"); err == nil {
+ t.Fatal("TouchGeneration accepted an invalid app")
+ }
+}
+
+// TestWritesBumpGeneration is the contract the console door and the CLI both
+// depend on: EVERY successful config write leaves a moved etag behind, and a
+// REFUSED write leaves none — a failed write has nothing to converge to, and
+// a spurious touch would cost the fleet a SyncExec pass for nothing.
+func TestWritesBumpGeneration(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ envelope := testEnvelope(t)
+
+ writes := []struct {
+ name string
+ run func() error
+ }{
+ {"SetEnv", func() error {
+ return SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"})
+ }},
+ {"SetSecret", func() error {
+ return SetSecret(ctx, s, "bab", "keymail", map[string]string{"SES_SECRET": envelope})
+ }},
+ {"Unset", func() error {
+ _, err := Unset(ctx, s, "bab", "keymail", []string{"LOG_LEVEL"})
+ return err
+ }},
+ {"SaveEnvironment", func() error {
+ return SaveEnvironment(ctx, s, "bab", "keymail", "staging", map[string]Var{"PORT": {Kind: "env", Value: "8080"}}, nil)
+ }},
+ {"UnsetEnvironment", func() error {
+ _, err := UnsetEnvironment(ctx, s, "bab", "keymail", "staging", []string{"PORT"})
+ return err
+ }},
+ {"SetExecDelivery", func() error {
+ return SetExecDelivery(ctx, s, "bab", "keymail", ExecDeliveryFile)
+ }},
+ {"Apply", func() error {
+ _, err := Apply(ctx, s, "bab", "keymail", map[string]Var{"A": {Kind: "env", Value: "1"}}, []string{"SES_SECRET"})
+ return err
+ }},
+ {"ApplyEnvironment", func() error {
+ _, err := ApplyEnvironment(ctx, s, "bab", "keymail", "staging", map[string]Var{"B": {Kind: "env", Value: "2"}}, nil)
+ return err
+ }},
+ }
+ for _, w := range writes {
+ before := genEtag(t, s, "bab", "keymail")
+ if err := w.run(); err != nil {
+ t.Fatalf("%s: %v", w.name, err)
+ }
+ if after := genEtag(t, s, "bab", "keymail"); after == before {
+ t.Fatalf("%s left the generation etag at %q — no convergence signal", w.name, before)
+ }
+ }
+
+ // A REFUSED write: unsetting from an app that has no bundle at all is
+ // applyAt's create-refusal. Nothing landed, so nothing may be signalled.
+ before := genEtag(t, s, "bab", "unconfigured")
+ if _, err := Unset(ctx, s, "bab", "unconfigured", []string{"NOPE"}); err == nil {
+ t.Fatal("Unset on an app with no bundle succeeded, want a refusal")
+ }
+ if after := genEtag(t, s, "bab", "unconfigured"); after != before {
+ t.Fatalf("a refused write bumped the generation (%q -> %q)", before, after)
+ }
+}
+
+// testEnvelope seals a throwaway value so secret-shaped writes in these
+// tests carry a real envelope rather than a string that only looks like one.
+func testEnvelope(t *testing.T) string {
+ t.Helper()
+ _, pub, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ pubKey, err := ParsePublicKey(pub, "test")
+ if err != nil {
+ t.Fatalf("ParsePublicKey: %v", err)
+ }
+ envelope, err := Seal(pubKey, []byte("shh"))
+ if err != nil {
+ t.Fatalf("Seal: %v", err)
+ }
+ return envelope
+}
diff --git a/internal/appconfig/store.go b/internal/appconfig/store.go
index 19d23a570..34be45bb4 100644
--- a/internal/appconfig/store.go
+++ b/internal/appconfig/store.go
@@ -106,6 +106,12 @@ func getForWriteAt(ctx context.Context, s release.Store, key, app string) (Manif
// surfaces as release.ErrCASConflict — callers report it and let the operator
// re-run; there is no automatic retry, matching release.Promote's own
// behavior.
+//
+// On success it also touches the app's generation object (generation.go), so
+// the change is visible to the agent's fast loop within seconds rather than
+// at the next hourly sweep. Best-effort by construction: the manifest write
+// has already landed by then, so a failed touch is logged, never returned
+// (see touchLogged).
func apply(ctx context.Context, s release.Store, acct, app, bundle string, sets map[string]Var, unsets []string) (int, error) {
if !release.ValidAccount(acct) {
return 0, fmt.Errorf("config: invalid account %q", acct)
@@ -113,7 +119,12 @@ 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)
}
- return 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
+ }
+ touchLogged(ctx, s, acct, app)
+ return removed, nil
}
// applyAt is apply generalized to any manifest key — see getIfExistsAt's doc
@@ -260,6 +271,13 @@ func SetExecDelivery(ctx context.Context, s release.Store, acct, app, value stri
if err != nil {
return err
}
- _, err = s.Put(ctx, key, raw, etag)
- return err
+ if _, err := s.Put(ctx, key, raw, etag); err != nil {
+ return err
+ }
+ // The opt-in flag changes what SyncExec delivers for this app's routes,
+ // so it is a config change like any other and earns the same fast
+ // convergence — see apply's comment for why the touch cannot fail this
+ // call.
+ touchLogged(ctx, s, acct, app)
+ return nil
}
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go
index 7366b8424..306f6d2f9 100644
--- a/internal/appconfig/syncexec.go
+++ b/internal/appconfig/syncexec.go
@@ -35,6 +35,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "sort"
"strings"
"github.com/carlosframework/platform/internal/registry"
@@ -434,6 +435,48 @@ func execAddrCandidate(rt registry.Route) bool {
return execRouteBase(rt) && rt.Socket == "" && rt.Addr != ""
}
+// ExecConfigApps is the distinct (account, app) set the agent's fast
+// generation loop (host package) polls: every app with at least one route
+// SyncExec could deliver config to. Accounts are resolved through
+// release.RouteAccount, the same way every other read of a route's account
+// resolves, so the key the loop watches is the key a write actually touches.
+//
+// The SHAPE half only — the TCP-addr case's opt-in (Manifest.ExecDelivery)
+// is NOT consulted, because consulting it costs a bucket read per app,
+// which is exactly what the fast loop exists to avoid. The cost of that
+// choice is bounded: polling an un-opted app's generation is one wasted
+// small read per tick, and the SyncExec pass a change triggers applies the
+// opt-in properly before writing anything.
+//
+// Exported from THIS file, beside the predicates it composes, so the loop's
+// notion of eligibility can never drift from SyncExec's own — an app the
+// loop watched but SyncExec skipped would burn reads forever, and an app it
+// didn't watch would silently fall back to the hourly pass.
+func ExecConfigApps(routes []registry.Route) [][2]string {
+ seen := map[[2]string]bool{}
+ var out [][2]string
+ for _, rt := range routes {
+ if !execSyncable(rt) && !execAddrCandidate(rt) {
+ continue
+ }
+ k := [2]string{release.RouteAccount(rt), rt.App}
+ if seen[k] {
+ continue
+ }
+ seen[k] = true
+ out = append(out, k)
+ }
+ // Sorted so the loop's per-tick read order — and any log line built
+ // from it — is stable across passes, where registry order is not.
+ sort.Slice(out, func(i, j int) bool {
+ if out[i][0] != out[j][0] {
+ return out[i][0] < out[j][0]
+ }
+ return out[i][1] < out[j][1]
+ })
+ return out
+}
+
// execAddrConfigHeader is prepended to a TCP-addr (issue #18) route's
// <host>.config.env — and ONLY that shape. RenderEnv itself stays
// header-free, deliberately: it is shared by sync.go's unit path AND this
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go
index 11f5b632d..9db2fda22 100644
--- a/internal/appconfig/syncexec_test.go
+++ b/internal/appconfig/syncexec_test.go
@@ -1508,3 +1508,62 @@ func TestSyncExecFetchFailureLeavesOtherGroupsConverging(t *testing.T) {
t.Fatalf("bounced = %v, want exactly [hostb] — appa's route left untouched, appb's route converged", bounced)
}
}
+
+// TestExecConfigApps pins the set the agent's fast generation loop polls:
+// exactly the apps SyncExec could deliver config to, by route SHAPE — and
+// nothing else. The opt-in half of the TCP-addr case is deliberately NOT
+// consulted here (that would cost a bucket read per app, the very thing the
+// fast loop exists to avoid), so an un-opted addr route is in this list;
+// see ExecConfigApps' doc comment.
+func TestExecConfigApps(t *testing.T) {
+ routes := []registry.Route{
+ // In: the socket-exec shape, unconditionally syncable.
+ {Host: "sock.example.com", Kind: "instance", App: "eleven", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/sock.sock"},
+ // In: the TCP-addr shape (issue #18), opt-in unknown and unread.
+ {Host: "addr.example.com", Kind: "instance", App: "titogo", Account: "zed",
+ Status: "ready", Addr: "127.0.0.1:9000"},
+ // In, and deduped with sock.example.com: one app, two routes, one
+ // generation object between them.
+ {Host: "sock2.example.com", Kind: "instance", App: "eleven", Account: "bab",
+ Status: "", Socket: "/run/carlos/sock2.sock"},
+ // In under release.OpsAccount: a route whose Account is unusable as
+ // a bucket segment reads as the ops account everywhere else, and
+ // must here too or the loop watches a key nothing writes.
+ {Host: "legacy.example.com", Kind: "instance", App: "kass", Account: "",
+ Status: "ready", Socket: "/run/carlos/legacy.sock"},
+ // Out: unit-backed routes belong to the root adopt path.
+ {Host: "unit.example.com", Kind: "instance", App: "unitapp", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/unit.sock", Backing: registry.BackingUnit},
+ // Out: platform routes are not app config.
+ {Host: "plat.example.com", Kind: "instance", App: "platapp", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/plat.sock", Platform: true},
+ // Out: a draining route is on its way out; nothing to converge.
+ {Host: "drain.example.com", Kind: "instance", App: "drainapp", Account: "bab",
+ Status: "draining", Socket: "/run/carlos/drain.sock"},
+ // Out: no app means no config manifest to key on.
+ {Host: "noapp.example.com", Kind: "instance", App: "", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/noapp.sock"},
+ // Out: neither socket nor addr — no upstream either predicate matches.
+ {Host: "bare.example.com", Kind: "instance", App: "bareapp", Account: "bab",
+ Status: "ready"},
+ // Out: not an instance route at all.
+ {Host: "proxy.example.com", Kind: "proxy", App: "proxyapp", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/proxy.sock"},
+ }
+ got := ExecConfigApps(routes)
+ want := [][2]string{
+ {"bab", "eleven"},
+ {"bab", "kass"},
+ {"zed", "titogo"},
+ }
+ if !slices.Equal(got, want) {
+ t.Fatalf("ExecConfigApps = %v, want %v (distinct, sorted by account then app)", got, want)
+ }
+}
+
+func TestExecConfigAppsNoRoutes(t *testing.T) {
+ if got := ExecConfigApps(nil); len(got) != 0 {
+ t.Fatalf("ExecConfigApps(nil) = %v, want empty", got)
+ }
+}