carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The instance config layer (tenant-email senders design §6.2): a third
2// bundle, one object per route host, resolved after the named environment.
3// Same Manifest shape, same sealed envelopes, same name and value rules as
4// the other two — a per-instance bundle is not a new format, just a third
5// bucket key the same shape is written to.
6//
7// Bucket layout addition:
8//
9// apps/<acct>/<app>/instances-config/<host>.json CAS-guarded, schema-1 manifest
10//
11// It exists so a credential can be scoped to ONE instance. #148's shape is
12// one instance per customer under a single app, and everything the platform
13// delivers today lands in a bundle every instance of the app renders, so a
14// per-customer secret delivered through the default bundle reaches every
15// other customer's process.
16//
17// Manifest.ExecDelivery is NOT read from this layer. That is the ruling at
18// appconfig.go's ExecDelivery doc comment, unchanged: a per-route delivery
19// flag would need a registry column, a record field and reconcile
20// convergence, and two routes resolving through the same bundle could
21// disagree about whether they are delivered. A per-host bundle is exactly
22// the granularity that ruling refused, so resolveExecDelivery is never
23// handed one of these manifests and a field that somehow appears in one is
24// inert.
25package appconfig
26
27import (
28 "context"
29 "fmt"
30 "regexp"
31 "strings"
32 "sync"
33
34 "github.com/carlosframework/platform/internal/release"
35)
36
37// InstancesConfigPrefix is the list prefix behind "which instances of this
38// app have an overlay" — every instances-config/<host>.json key shares it.
39// Inside apps/<acct>/<app>/ on purpose: release.PurgeApp's prefix sweep
40// reaches it, so a purged app cannot leave an object full of sealed
41// secrets behind.
42func InstancesConfigPrefix(acct, app string) string {
43 return release.AppPrefix(acct, app) + "instances-config/"
44}
45
46// InstanceConfigKey is the bucket key of one instance's overlay. host is a
47// route's Host column verbatim, which is what makes the key derivable from
48// a registry row with nothing to look up.
49func InstanceConfigKey(acct, app, host string) string {
50 return InstancesConfigPrefix(acct, app) + host + ".json"
51}
52
53// instanceHostRe is a bare lowercase hostname: labels of letters, digits
54// and hyphens, joined by dots, never starting or ending with a hyphen or a
55// dot. Case-restricted like ValidEnvironmentName and for the same reason,
56// with one more: this string is also the file name sync derives on the box
57// (sync.go's filepath.Base guard), so one convention keeps a bucket key and
58// a file name from disagreeing about which host they belong to.
59var instanceHostRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$`)
60
61// ValidInstanceHost reports whether host is usable as an instance overlay
62// key. A separator or a traversal is refused at the one gate every caller
63// passes through, because this string is concatenated into a bucket key: a
64// host of "../config" would otherwise name the app's default bundle.
65func ValidInstanceHost(host string) bool {
66 if host == "" || len(host) > 253 || strings.Contains(host, "..") {
67 return false
68 }
69 // Per-label bound as well as the whole-name one, matching DNS and
70 // launchctx/controlname's label rule. Without it a single 200-character
71 // label passes here and is refused everywhere else that handles the same
72 // host, which is a disagreement between two gates rather than one rule.
73 for _, label := range strings.Split(host, ".") {
74 if len(label) > 63 {
75 return false
76 }
77 }
78 return instanceHostRe.MatchString(host)
79}
80
81// LoadInstanceConfig reads one instance's overlay, returning the manifest,
82// the etag it was read at, and whether the object exists at all.
83//
84// The etag is the point of this signature. The delivery ack (design §6.5)
85// carries the etag the agent actually rendered, and the console compares it
86// with the one its own Put returned: that comparison is decidable, where a
87// map of delivered key names cannot tell an old agent apart from a new one
88// rendering a route with no overlay vars.
89//
90// Not-found is (nil, "", false, nil): an instance with no overlay is the
91// normal case and the state every instance is in until something writes one.
92func LoadInstanceConfig(ctx context.Context, s release.Store, acct, app, host string) (*Manifest, string, bool, error) {
93 if !release.ValidAccount(acct) {
94 return nil, "", false, fmt.Errorf("config: invalid account %q", acct)
95 }
96 if !release.ValidApp(app) {
97 return nil, "", false, fmt.Errorf("config: invalid app %q", app)
98 }
99 if !ValidInstanceHost(host) {
100 return nil, "", false, fmt.Errorf("config: invalid instance host %q", host)
101 }
102 return getIfExistsAtETag(ctx, s, InstanceConfigKey(acct, app, host))
103}
104
105// ApplyInstance writes sets and removes unsets from ONE instance's overlay
106// in one CAS write, returning how many of unsets were actually present and
107// THE ETAG IT WROTE AT.
108//
109// That etag is the contract with the box. The console keeps it, the agent
110// reports the etag it rendered in its config ack, and the two are compared
111// (design §6.5, InstanceConfigDelivery). A map of delivered key names would
112// not do: it cannot tell an agent too old to render this layer apart from a
113// new agent rendering a route whose overlay sets nothing it recognises.
114//
115// There is no CLI verb and no console door onto this function, by design
116// (§6.2, "only the platform writes this layer"). It exists so a credential
117// can be scoped to one instance, and the only caller that scopes one is the
118// email walk in step 5.
119//
120// Unset-only against an overlay that does not exist is refused, not turned
121// into a create, for the reason applyAt's own doc comment gives: an empty
122// manifest that EXISTS is a state the sweeps converge a file to, and
123// minting one to hold nothing would deliver an empty file to a live
124// instance.
125func claimRawApplyInstance(ctx context.Context, s release.Store, acct, app, host string, sets map[string]Var, unsets []string) (int, string, error) {
126 if !release.ValidAccount(acct) {
127 return 0, "", fmt.Errorf("config: invalid account %q", acct)
128 }
129 if !release.ValidApp(app) {
130 return 0, "", fmt.Errorf("config: invalid app %q", app)
131 }
132 if !ValidInstanceHost(host) {
133 return 0, "", fmt.Errorf("config: invalid instance host %q", host)
134 }
135 removed, etag, err := applyAt(ctx, s, InstanceConfigKey(acct, app, host), app, "instance "+host, sets, unsets)
136 if err != nil {
137 return 0, "", err
138 }
139 // The generation object is the ONLY signal this layer has.
140 // ConfigRouteFingerprints (routefingerprint.go) digests registry columns
141 // and cannot see a bucket-side change, so an overlay written without
142 // this touch would wait out configSyncLoop's hourly floor. Same
143 // best-effort demotion every other write here makes: the manifest has
144 // landed, so a failed touch is logged, never returned.
145 touchLogged(ctx, s, acct, app)
146 return removed, etag, nil
147}
148
149// DeleteInstanceConfig removes one instance's overlay outright, which
150// converges that route back to its group's merge on the next pass. Absent
151// is success: a retried teardown must converge rather than fail, and
152// release.Store.Delete is already idempotent.
153//
154// The generation is touched even when nothing was removed. Branching on it
155// would mean a Get before every delete to find out, and one redundant touch
156// costs a tiny object write where a missed one costs an hour of a live
157// instance holding config nothing meant it to have.
158func claimRawDeleteInstanceConfig(ctx context.Context, s release.Store, acct, app, host string) error {
159 if !release.ValidAccount(acct) {
160 return fmt.Errorf("config: invalid account %q", acct)
161 }
162 if !release.ValidApp(app) {
163 return fmt.Errorf("config: invalid app %q", app)
164 }
165 if !ValidInstanceHost(host) {
166 return fmt.Errorf("config: invalid instance host %q", host)
167 }
168 if err := s.Delete(ctx, InstanceConfigKey(acct, app, host)); err != nil {
169 return err
170 }
171 touchLogged(ctx, s, acct, app)
172 return nil
173}
174
175// LoadMergedHost is LoadMerged plus the instance layer: default <
176// environment < instance, the instance winning per key, and the overlay's
177// etag returned alongside so a caller can check delivery against it later
178// (design §6.2, §6.5). An empty etag means this host has no overlay, which
179// is a different statement from an agent that never rendered one.
180//
181// LoadMerged keeps its signature and its meaning. Everything that asks
182// "what does this APP's environment resolve to" still gets two layers, and
183// only a caller that names a host gets three.
184//
185// The two sync sweeps do NOT call this function, deliberately: they resolve
186// one group base for every route that shares it and would re-fetch
187// config.json per route if they came through here. They reach the same
188// answer through instanceOverlays and applyInstanceOverlay instead, and the
189// equivalence for a VALID host is pinned by a test rather than by hope —
190// TestSyncRendersTheInstanceOverlayForItsOwnRouteOnly compares a sweep's
191// rendered file against this function's own answer for the same host.
192//
193// That equivalence does NOT extend to a host that fails ValidInstanceHost.
194// This function refuses one outright (see the guard below): a hostname a
195// caller cannot even ask about is an error here. The sweeps never make that
196// call at all — instanceOverlayLoader treats such a host as "not named by
197// the listing", which reads as "no overlay", and the route converges on its
198// group's merge same as any other unconfigured instance. A caller that
199// wants the sweep's own answer for a host it cannot yet prove valid (step
200// 5's write path, before ApplyInstance's own ValidInstanceHost gate runs)
201// must not substitute this function for that reason: the two disagree here,
202// on purpose, and only one of them is what a box will actually deliver.
203func LoadMergedHost(ctx context.Context, s release.Store, acct, app, envName, host string) (*Manifest, string, error) {
204 m, v, err := LoadMergedHostVersions(ctx, s, acct, app, envName, host)
205 return m, v.Overlay, err
206}
207
208// MergedVersions names the version (etag) of each object a three-layer
209// merge actually read: the app's default bundle, the route's named
210// environment, and the host's instance overlay. "" means that layer was
211// absent or not part of the merge (an unbound route reads no environment).
212type MergedVersions struct {
213 Default, Environment, Overlay string
214}
215
216// LoadMergedHostVersions is LoadMergedHost returning the etag of every
217// object it merged, each taken from the very read that returned its bytes
218// (getIfExistsAtETag). It is the one loader a move's held render and T3
219// use (moves design §9.1): a later, independent read could not establish
220// which versions were rendered, because a bundle written in between would
221// answer with the new etag over the old bytes. LoadMergedHost is this
222// function with everything but the overlay's etag discarded.
223func LoadMergedHostVersions(ctx context.Context, s release.Store, acct, app, envName, host string) (*Manifest, MergedVersions, error) {
224 if !ValidInstanceHost(host) {
225 return nil, MergedVersions{}, fmt.Errorf("config: invalid instance host %q", host)
226 }
227 // The host's overlay is read beside the app's layers (loadMergedETags),
228 // not after them; the answers are taken in the old order.
229 var (
230 inst *Manifest
231 etag string
232 found bool
233 instErr error
234 wg sync.WaitGroup
235 )
236 wg.Add(1)
237 go func() {
238 defer wg.Done()
239 inst, etag, found, instErr = LoadInstanceConfig(ctx, s, acct, app, host)
240 }()
241 base, defETag, envETag, err := loadMergedETags(ctx, s, acct, app, envName)
242 wg.Wait()
243 if err != nil {
244 return nil, MergedVersions{}, err
245 }
246 v := MergedVersions{Default: defETag, Environment: envETag}
247 if err := instErr; err != nil {
248 return nil, MergedVersions{}, err
249 }
250 if !found {
251 return base, v, nil
252 }
253 v.Overlay = etag
254 return applyInstanceOverlay(app, base, inst), v, nil
255}
256
257// applyInstanceOverlay lays inst over base, per key. base may be nil (the
258// app has no default bundle and the route is unbound), in which case the
259// merged view is the overlay ALONE — which the callers treat as the
260// wholesale replacement it is, see sync.go's defaultMissing guard.
261//
262// A nil overlay returns base untouched, pointer and all: that is the path
263// every instance without an overlay takes, and rebuilding the manifest
264// there would strip exec_delivery off the default bundle object LoadMerged
265// hands back for an unbound route.
266//
267// The merged view never carries exec_delivery FROM the overlay. See this
268// file's own doc comment: a per-host delivery flag is the granularity the
269// #195 ruling refused, and the merged manifest is what gets rendered into a
270// file, where a delivery flag has no business appearing anyway.
271func applyInstanceOverlay(app string, base, inst *Manifest) *Manifest {
272 if inst == nil {
273 return base
274 }
275 merged := &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}
276 if base != nil {
277 for k, v := range base.Vars {
278 merged.Vars[k] = v
279 }
280 }
281 for k, v := range inst.Vars {
282 merged.Vars[k] = v
283 }
284 return merged
285}
286
287// instanceOverlays lists which of this app's instances have an overlay at
288// all: one listing per (account, app), cached by the caller for a whole
289// pass, so a sweep pays one List per app and then one Get per instance that
290// actually has one. Reading every route's key blind would cost a bucket
291// round trip per instance on a box where nothing has an overlay, which is
292// every box in the fleet the day this ships.
293//
294// A key that does not end in .json, or whose host does not survive
295// ValidInstanceHost, is skipped rather than reported: nothing can have
296// written it through this package, so it is not this app's overlay
297// whatever it is.
298func instanceOverlays(ctx context.Context, s release.Store, acct, app string) (map[string]bool, error) {
299 prefix := InstancesConfigPrefix(acct, app)
300 keys, err := s.List(ctx, prefix)
301 if err != nil {
302 return nil, err
303 }
304 out := make(map[string]bool, len(keys))
305 for _, k := range keys {
306 host := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
307 if host == strings.TrimPrefix(k, prefix) || !ValidInstanceHost(host) {
308 continue
309 }
310 out[host] = true
311 }
312 return out, nil
313}
314
315// instanceOverlayListErr marks an error as coming from instanceOverlayLoader's
316// cached LISTING (instanceOverlays) rather than from a per-host Get. The
317// listing is the one call every route of an app shares, so every route
318// fails identically and for the same reason when it errors — a caller may
319// dedup on that. A per-host Get or parse failure carries no such guarantee:
320// two routes can have two separately corrupt overlay objects, each failing
321// for its own reason, and folding both into one line would hide the second
322// one's failure entirely. errors.As is how a caller tells the two apart.
323type instanceOverlayListErr struct{ error }
324
325// Unwrap lets errors.Is/errors.As see through the wrapper to the listing
326// failure itself, the same courtesy every %w wrap in this package gives.
327func (e *instanceOverlayListErr) Unwrap() error { return e.error }
328
329// instanceOverlayLoader is one pass's instance-layer reader, shared by both
330// sweeps (Sync and SyncExec) so neither can drift from the other about what
331// a host gets or what it costs. One List per (account, app), cached for the
332// pass including its failure, then a Get only for a host that listing
333// named.
334//
335// A listing FAILURE is returned to the caller wrapped in
336// instanceOverlayListErr, never folded into "no overlay". The two sweeps
337// treat it the way they treat a bundle fetch failure: leave the file
338// exactly as it is, say so, retry next pass. Folding it away would silently
339// deliver the app-wide merge to an instance that is meant to have its own
340// credential, which is the one outcome this layer exists to prevent.
341//
342// A host the listing named but whose object is gone by the time of the Get
343// is simply "no overlay": a delete racing the pass converges the route back
344// to its group's merge, which is what the delete asked for.
345//
346// The index map below has no lock. That's safe only because each call to
347// this function returns a fresh closure over its own map, and both Sync and
348// SyncExec build one loader per pass and drive it from their own single
349// route loop — never two goroutines sharing one closure. A caller that ever
350// shared one instanceOverlayLoader value across concurrently running sweeps
351// would race on this map; don't.
352func instanceOverlayLoader(ctx context.Context, s release.Store) func(acct, app, host string) (*Manifest, string, error) {
353 type appKey struct{ acct, app string }
354 type indexRes struct {
355 hosts map[string]bool
356 err error
357 }
358 index := map[appKey]indexRes{}
359 return func(acct, app, host string) (*Manifest, string, error) {
360 ak := appKey{acct, app}
361 idx, done := index[ak]
362 if !done {
363 hosts, err := instanceOverlays(ctx, s, acct, app)
364 if err != nil {
365 err = &instanceOverlayListErr{fmt.Errorf("%s/%s: instance config listing failed: %w", acct, app, err)}
366 }
367 idx = indexRes{hosts: hosts, err: err}
368 index[ak] = idx
369 }
370 if idx.err != nil {
371 return nil, "", idx.err
372 }
373 if !idx.hosts[host] {
374 return nil, "", nil
375 }
376 m, etag, found, err := LoadInstanceConfig(ctx, s, acct, app, host)
377 if err != nil {
378 return nil, "", fmt.Errorf("%s/%s: instance config for %s: %w", acct, app, host, err)
379 }
380 if !found {
381 return nil, "", nil
382 }
383 return m, etag, nil
384 }
385}
386func ApplyInstance(ctx context.Context, s release.Store, acct, app, host string, sets map[string]Var, unsets []string) (claimResult0 int, claimResult1 string, claimResult2 error) {
387 claimResult2 = release.WithAppMutation(ctx, s, acct, app, "appconfig.ApplyInstance", func(claimContext context.Context) error {
388 claimResult0, claimResult1, claimResult2 = claimRawApplyInstance(claimContext, s, acct, app, host, sets, unsets)
389 return claimResult2
390 })
391 return
392}
393func DeleteInstanceConfig(ctx context.Context, s release.Store, acct, app, host string) (claimResult0 error) {
394 claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.DeleteInstanceConfig", func(claimContext context.Context) error {
395 claimResult0 = claimRawDeleteInstanceConfig(claimContext, s, acct, app, host)
396 return claimResult0
397 })
398 return
399}
400