carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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
11package appconfig
12
13import (
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.
39type 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.
51func 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".
65func 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.
107func 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.
119func 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).
126func 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}
129func 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}
136func 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}
143func 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