| 1 | // The convergence signal: one tiny object per app that every successful |
| 2 | // config write touches, so a watcher can learn "something changed" from a |
| 3 | // single cheap etag instead of re-reading every bundle on a timer. See spec |
| 4 | // 2026-08-04-config-console-api-design.md §1 (issue #44) — config should |
| 5 | // converge the way a canary adopts, in seconds, without restarting the |
| 6 | // box's shared edge service to deliver one app's variable. |
| 7 | // |
| 8 | // Bucket layout addition: |
| 9 | // |
| 10 | // apps/<acct>/<app>/config-gen.json CAS-guarded, schema-1 generation object |
| 11 | package appconfig |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "crypto/rand" |
| 16 | "encoding/hex" |
| 17 | "encoding/json" |
| 18 | "errors" |
| 19 | "fmt" |
| 20 | "io" |
| 21 | "log" |
| 22 | "os" |
| 23 | "time" |
| 24 | |
| 25 | "github.com/carlosframework/platform/internal/release" |
| 26 | ) |
| 27 | |
| 28 | // Generation is the small sibling object every config write touches — |
| 29 | // apps/<acct>/<app>/config-gen.json — so the agent's fast loop (host |
| 30 | // package) can watch ONE cheap etag per app instead of re-reading every |
| 31 | // bundle hourly. One per app across ALL bundles: SyncExec resolves |
| 32 | // per-(app, environment) itself, so the signal only needs app granularity. |
| 33 | // Change detection is by etag; the fields exist for humans debugging a |
| 34 | // convergence question and to make each touch's bytes distinct (a |
| 35 | // content-derived etag, which DirStore uses, would not move for a |
| 36 | // byte-identical rewrite). Absent is legal forever — an app last written |
| 37 | // by an older CLI has no generation object and never will until someone |
| 38 | // writes its config again; the hourly configSyncLoop remains the floor. |
| 39 | type Generation struct { |
| 40 | Schema int `json:"schema"` // 1 |
| 41 | TouchedAt string `json:"touched_at"` |
| 42 | Nonce string `json:"nonce"` |
| 43 | } |
| 44 | |
| 45 | // GenKey is the bucket key of app's generation object, derived from |
| 46 | // release.AppPrefix for the same reason Key is: everything an app owns sits |
| 47 | // under one account-scoped prefix, permanently. Deliberately a sibling of |
| 48 | // config.json rather than a field inside it — a field would mean every |
| 49 | // watcher re-reads (and re-parses) the whole manifest, and would make the |
| 50 | // signal race the very write it is signalling. |
| 51 | func GenKey(acct, app string) string { return release.AppPrefix(acct, app) + "config-gen.json" } |
| 52 | |
| 53 | // TouchGeneration bumps app's generation object, the signal that this app's |
| 54 | // config changed. A CAS conflict is SUCCESS, not an error: it means another |
| 55 | // writer touched the generation between our read and our write, so the |
| 56 | // signal this function exists to fire has already fired — retrying would |
| 57 | // only produce a second, redundant bump. os.ErrNotExist on the read means |
| 58 | // first touch, so the write is create-only (etag ""), which is what makes |
| 59 | // the conflict carve-out safe: two first touches race, one wins, both |
| 60 | // callers are correct. |
| 61 | // |
| 62 | // Exported because `carlos env sync` (and the console door behind it) is a |
| 63 | // bare touch with no config write attached — the escape hatch for "apply |
| 64 | // what's already in the bucket, now". |
| 65 | func claimRawTouchGeneration(ctx context.Context, s release.Store, acct, app string) error { |
| 66 | if !release.ValidAccount(acct) { |
| 67 | return fmt.Errorf("config: invalid account %q", acct) |
| 68 | } |
| 69 | if !release.ValidApp(app) { |
| 70 | return fmt.Errorf("config: invalid app %q", app) |
| 71 | } |
| 72 | key := GenKey(acct, app) |
| 73 | _, etag, err := s.Get(ctx, key) |
| 74 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 75 | return err |
| 76 | } |
| 77 | if errors.Is(err, os.ErrNotExist) { |
| 78 | etag = "" |
| 79 | } |
| 80 | nonce := make([]byte, 16) |
| 81 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { |
| 82 | return err |
| 83 | } |
| 84 | raw, err := json.Marshal(Generation{ |
| 85 | Schema: 1, |
| 86 | TouchedAt: time.Now().UTC().Format(time.RFC3339), |
| 87 | Nonce: hex.EncodeToString(nonce), |
| 88 | }) |
| 89 | if err != nil { |
| 90 | return err |
| 91 | } |
| 92 | if _, err := s.Put(ctx, key, raw, etag); err != nil { |
| 93 | if errors.Is(err, release.ErrCASConflict) { |
| 94 | return nil |
| 95 | } |
| 96 | return err |
| 97 | } |
| 98 | return nil |
| 99 | } |
| 100 | |
| 101 | // touchLogged is TouchGeneration demoted to a log line, for use AFTER a |
| 102 | // successful manifest write: the write landed, so a failed touch must not |
| 103 | // fail the call — reporting failure for a change that is live would send an |
| 104 | // operator to re-run a write that already happened. The real cost of a |
| 105 | // failed touch is that convergence falls back to the hourly pass, which is |
| 106 | // exactly where every pre-generation CLI already leaves it. |
| 107 | func touchLogged(ctx context.Context, s release.Store, acct, app string) { |
| 108 | if err := TouchGeneration(ctx, s, acct, app); err != nil { |
| 109 | log.Printf("config: %s/%s: generation touch failed (converges on the hourly pass): %v", acct, app, err) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // Apply writes sets and removes unsets from the DEFAULT bundle in one CAS |
| 114 | // write, returning how many of unsets were actually present — the console |
| 115 | // door's shape, since one PUT may carry both halves and two CAS writes would |
| 116 | // mean a half-applied request whenever the second one lost a race. |
| 117 | // SetEnv/SetSecret/Unset remain the narrow verbs the CLI's direct mode uses; |
| 118 | // this is the same underlying path, just not pre-narrowed to one kind. |
| 119 | func claimRawApply(ctx context.Context, s release.Store, acct, app string, sets map[string]Var, unsets []string) (int, error) { |
| 120 | return apply(ctx, s, acct, app, DefaultBundle, sets, unsets) |
| 121 | } |
| 122 | |
| 123 | // ApplyEnvironment is Apply for a named environment bundle — same one-write |
| 124 | // mixed set/unset contract, same refusal to create a bundle on an |
| 125 | // unset-only call (see applyAt). |
| 126 | func claimRawApplyEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) (int, error) { |
| 127 | return applyEnvironment(ctx, s, acct, app, envName, sets, unsets) |
| 128 | } |
| 129 | func Apply(ctx context.Context, s release.Store, acct, app string, sets map[string]Var, unsets []string) (claimResult0 int, claimResult1 error) { |
| 130 | claimResult1 = release.WithAppMutation(ctx, s, acct, app, "appconfig.Apply", func(claimContext context.Context) error { |
| 131 | claimResult0, claimResult1 = claimRawApply(claimContext, s, acct, app, sets, unsets) |
| 132 | return claimResult1 |
| 133 | }) |
| 134 | return |
| 135 | } |
| 136 | func ApplyEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) (claimResult0 int, claimResult1 error) { |
| 137 | claimResult1 = release.WithAppMutation(ctx, s, acct, app, "appconfig.ApplyEnvironment", func(claimContext context.Context) error { |
| 138 | claimResult0, claimResult1 = claimRawApplyEnvironment(claimContext, s, acct, app, envName, sets, unsets) |
| 139 | return claimResult1 |
| 140 | }) |
| 141 | return |
| 142 | } |
| 143 | func TouchGeneration(ctx context.Context, s release.Store, acct, app string) (claimResult0 error) { |
| 144 | claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.TouchGeneration", func(claimContext context.Context) error { |
| 145 | claimResult0 = claimRawTouchGeneration(claimContext, s, acct, app) |
| 146 | return claimResult0 |
| 147 | }) |
| 148 | return |
| 149 | } |
| 150 | |