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
8 files changed,
+572
−4
internal/appconfig/crypto.go+31 −0internal/appconfig/crypto_test.go+66 −0internal/appconfig/environments.go+12 −1internal/appconfig/generation.go+128 −0internal/appconfig/generation_test.go+212 −0internal/appconfig/store.go+21 −3internal/appconfig/syncexec.go+43 −0internal/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) |
| + } |
| +} |