// The instance config layer (tenant-email senders design §6.2): a third // bundle, one object per route host, resolved after the named environment. // Same Manifest shape, same sealed envelopes, same name and value rules as // the other two — a per-instance bundle is not a new format, just a third // bucket key the same shape is written to. // // Bucket layout addition: // // apps///instances-config/.json CAS-guarded, schema-1 manifest // // It exists so a credential can be scoped to ONE instance. #148's shape is // one instance per customer under a single app, and everything the platform // delivers today lands in a bundle every instance of the app renders, so a // per-customer secret delivered through the default bundle reaches every // other customer's process. // // Manifest.ExecDelivery is NOT read from this layer. That is the ruling at // appconfig.go's ExecDelivery doc comment, unchanged: a per-route delivery // flag would need a registry column, a record field and reconcile // convergence, and two routes resolving through the same bundle could // disagree about whether they are delivered. A per-host bundle is exactly // the granularity that ruling refused, so resolveExecDelivery is never // handed one of these manifests and a field that somehow appears in one is // inert. package appconfig import ( "context" "fmt" "regexp" "strings" "sync" "github.com/carlosframework/platform/internal/release" ) // InstancesConfigPrefix is the list prefix behind "which instances of this // app have an overlay" — every instances-config/.json key shares it. // Inside apps/// on purpose: release.PurgeApp's prefix sweep // reaches it, so a purged app cannot leave an object full of sealed // secrets behind. func InstancesConfigPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "instances-config/" } // InstanceConfigKey is the bucket key of one instance's overlay. host is a // route's Host column verbatim, which is what makes the key derivable from // a registry row with nothing to look up. func InstanceConfigKey(acct, app, host string) string { return InstancesConfigPrefix(acct, app) + host + ".json" } // instanceHostRe is a bare lowercase hostname: labels of letters, digits // and hyphens, joined by dots, never starting or ending with a hyphen or a // dot. Case-restricted like ValidEnvironmentName and for the same reason, // with one more: this string is also the file name sync derives on the box // (sync.go's filepath.Base guard), so one convention keeps a bucket key and // a file name from disagreeing about which host they belong to. var instanceHostRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$`) // ValidInstanceHost reports whether host is usable as an instance overlay // key. A separator or a traversal is refused at the one gate every caller // passes through, because this string is concatenated into a bucket key: a // host of "../config" would otherwise name the app's default bundle. func ValidInstanceHost(host string) bool { if host == "" || len(host) > 253 || strings.Contains(host, "..") { return false } // Per-label bound as well as the whole-name one, matching DNS and // launchctx/controlname's label rule. Without it a single 200-character // label passes here and is refused everywhere else that handles the same // host, which is a disagreement between two gates rather than one rule. for _, label := range strings.Split(host, ".") { if len(label) > 63 { return false } } return instanceHostRe.MatchString(host) } // LoadInstanceConfig reads one instance's overlay, returning the manifest, // the etag it was read at, and whether the object exists at all. // // The etag is the point of this signature. The delivery ack (design §6.5) // carries the etag the agent actually rendered, and the console compares it // with the one its own Put returned: that comparison is decidable, where a // map of delivered key names cannot tell an old agent apart from a new one // rendering a route with no overlay vars. // // Not-found is (nil, "", false, nil): an instance with no overlay is the // normal case and the state every instance is in until something writes one. func LoadInstanceConfig(ctx context.Context, s release.Store, acct, app, host string) (*Manifest, string, bool, error) { if !release.ValidAccount(acct) { return nil, "", false, fmt.Errorf("config: invalid account %q", acct) } if !release.ValidApp(app) { return nil, "", false, fmt.Errorf("config: invalid app %q", app) } if !ValidInstanceHost(host) { return nil, "", false, fmt.Errorf("config: invalid instance host %q", host) } return getIfExistsAtETag(ctx, s, InstanceConfigKey(acct, app, host)) } // ApplyInstance writes sets and removes unsets from ONE instance's overlay // in one CAS write, returning how many of unsets were actually present and // THE ETAG IT WROTE AT. // // That etag is the contract with the box. The console keeps it, the agent // reports the etag it rendered in its config ack, and the two are compared // (design §6.5, InstanceConfigDelivery). A map of delivered key names would // not do: it cannot tell an agent too old to render this layer apart from a // new agent rendering a route whose overlay sets nothing it recognises. // // There is no CLI verb and no console door onto this function, by design // (§6.2, "only the platform writes this layer"). It exists so a credential // can be scoped to one instance, and the only caller that scopes one is the // email walk in step 5. // // Unset-only against an overlay that does not exist is refused, not turned // into a create, for the reason applyAt's own doc comment gives: an empty // manifest that EXISTS is a state the sweeps converge a file to, and // minting one to hold nothing would deliver an empty file to a live // instance. func claimRawApplyInstance(ctx context.Context, s release.Store, acct, app, host string, sets map[string]Var, unsets []string) (int, string, error) { if !release.ValidAccount(acct) { return 0, "", fmt.Errorf("config: invalid account %q", acct) } if !release.ValidApp(app) { return 0, "", fmt.Errorf("config: invalid app %q", app) } if !ValidInstanceHost(host) { return 0, "", fmt.Errorf("config: invalid instance host %q", host) } removed, etag, err := applyAt(ctx, s, InstanceConfigKey(acct, app, host), app, "instance "+host, sets, unsets) if err != nil { return 0, "", err } // The generation object is the ONLY signal this layer has. // ConfigRouteFingerprints (routefingerprint.go) digests registry columns // and cannot see a bucket-side change, so an overlay written without // this touch would wait out configSyncLoop's hourly floor. Same // best-effort demotion every other write here makes: the manifest has // landed, so a failed touch is logged, never returned. touchLogged(ctx, s, acct, app) return removed, etag, nil } // DeleteInstanceConfig removes one instance's overlay outright, which // converges that route back to its group's merge on the next pass. Absent // is success: a retried teardown must converge rather than fail, and // release.Store.Delete is already idempotent. // // The generation is touched even when nothing was removed. Branching on it // would mean a Get before every delete to find out, and one redundant touch // costs a tiny object write where a missed one costs an hour of a live // instance holding config nothing meant it to have. func claimRawDeleteInstanceConfig(ctx context.Context, s release.Store, acct, app, host 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) } if !ValidInstanceHost(host) { return fmt.Errorf("config: invalid instance host %q", host) } if err := s.Delete(ctx, InstanceConfigKey(acct, app, host)); err != nil { return err } touchLogged(ctx, s, acct, app) return nil } // LoadMergedHost is LoadMerged plus the instance layer: default < // environment < instance, the instance winning per key, and the overlay's // etag returned alongside so a caller can check delivery against it later // (design §6.2, §6.5). An empty etag means this host has no overlay, which // is a different statement from an agent that never rendered one. // // LoadMerged keeps its signature and its meaning. Everything that asks // "what does this APP's environment resolve to" still gets two layers, and // only a caller that names a host gets three. // // The two sync sweeps do NOT call this function, deliberately: they resolve // one group base for every route that shares it and would re-fetch // config.json per route if they came through here. They reach the same // answer through instanceOverlays and applyInstanceOverlay instead, and the // equivalence for a VALID host is pinned by a test rather than by hope — // TestSyncRendersTheInstanceOverlayForItsOwnRouteOnly compares a sweep's // rendered file against this function's own answer for the same host. // // That equivalence does NOT extend to a host that fails ValidInstanceHost. // This function refuses one outright (see the guard below): a hostname a // caller cannot even ask about is an error here. The sweeps never make that // call at all — instanceOverlayLoader treats such a host as "not named by // the listing", which reads as "no overlay", and the route converges on its // group's merge same as any other unconfigured instance. A caller that // wants the sweep's own answer for a host it cannot yet prove valid (step // 5's write path, before ApplyInstance's own ValidInstanceHost gate runs) // must not substitute this function for that reason: the two disagree here, // on purpose, and only one of them is what a box will actually deliver. func LoadMergedHost(ctx context.Context, s release.Store, acct, app, envName, host string) (*Manifest, string, error) { m, v, err := LoadMergedHostVersions(ctx, s, acct, app, envName, host) return m, v.Overlay, err } // MergedVersions names the version (etag) of each object a three-layer // merge actually read: the app's default bundle, the route's named // environment, and the host's instance overlay. "" means that layer was // absent or not part of the merge (an unbound route reads no environment). type MergedVersions struct { Default, Environment, Overlay string } // LoadMergedHostVersions is LoadMergedHost returning the etag of every // object it merged, each taken from the very read that returned its bytes // (getIfExistsAtETag). It is the one loader a move's held render and T3 // use (moves design §9.1): a later, independent read could not establish // which versions were rendered, because a bundle written in between would // answer with the new etag over the old bytes. LoadMergedHost is this // function with everything but the overlay's etag discarded. func LoadMergedHostVersions(ctx context.Context, s release.Store, acct, app, envName, host string) (*Manifest, MergedVersions, error) { if !ValidInstanceHost(host) { return nil, MergedVersions{}, fmt.Errorf("config: invalid instance host %q", host) } // The host's overlay is read beside the app's layers (loadMergedETags), // not after them; the answers are taken in the old order. var ( inst *Manifest etag string found bool instErr error wg sync.WaitGroup ) wg.Add(1) go func() { defer wg.Done() inst, etag, found, instErr = LoadInstanceConfig(ctx, s, acct, app, host) }() base, defETag, envETag, err := loadMergedETags(ctx, s, acct, app, envName) wg.Wait() if err != nil { return nil, MergedVersions{}, err } v := MergedVersions{Default: defETag, Environment: envETag} if err := instErr; err != nil { return nil, MergedVersions{}, err } if !found { return base, v, nil } v.Overlay = etag return applyInstanceOverlay(app, base, inst), v, nil } // applyInstanceOverlay lays inst over base, per key. base may be nil (the // app has no default bundle and the route is unbound), in which case the // merged view is the overlay ALONE — which the callers treat as the // wholesale replacement it is, see sync.go's defaultMissing guard. // // A nil overlay returns base untouched, pointer and all: that is the path // every instance without an overlay takes, and rebuilding the manifest // there would strip exec_delivery off the default bundle object LoadMerged // hands back for an unbound route. // // The merged view never carries exec_delivery FROM the overlay. See this // file's own doc comment: a per-host delivery flag is the granularity the // #195 ruling refused, and the merged manifest is what gets rendered into a // file, where a delivery flag has no business appearing anyway. func applyInstanceOverlay(app string, base, inst *Manifest) *Manifest { if inst == nil { return base } merged := &Manifest{Schema: 1, App: app, Vars: map[string]Var{}} if base != nil { for k, v := range base.Vars { merged.Vars[k] = v } } for k, v := range inst.Vars { merged.Vars[k] = v } return merged } // instanceOverlays lists which of this app's instances have an overlay at // all: one listing per (account, app), cached by the caller for a whole // pass, so a sweep pays one List per app and then one Get per instance that // actually has one. Reading every route's key blind would cost a bucket // round trip per instance on a box where nothing has an overlay, which is // every box in the fleet the day this ships. // // A key that does not end in .json, or whose host does not survive // ValidInstanceHost, is skipped rather than reported: nothing can have // written it through this package, so it is not this app's overlay // whatever it is. func instanceOverlays(ctx context.Context, s release.Store, acct, app string) (map[string]bool, error) { prefix := InstancesConfigPrefix(acct, app) keys, err := s.List(ctx, prefix) if err != nil { return nil, err } out := make(map[string]bool, len(keys)) for _, k := range keys { host := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") if host == strings.TrimPrefix(k, prefix) || !ValidInstanceHost(host) { continue } out[host] = true } return out, nil } // instanceOverlayListErr marks an error as coming from instanceOverlayLoader's // cached LISTING (instanceOverlays) rather than from a per-host Get. The // listing is the one call every route of an app shares, so every route // fails identically and for the same reason when it errors — a caller may // dedup on that. A per-host Get or parse failure carries no such guarantee: // two routes can have two separately corrupt overlay objects, each failing // for its own reason, and folding both into one line would hide the second // one's failure entirely. errors.As is how a caller tells the two apart. type instanceOverlayListErr struct{ error } // Unwrap lets errors.Is/errors.As see through the wrapper to the listing // failure itself, the same courtesy every %w wrap in this package gives. func (e *instanceOverlayListErr) Unwrap() error { return e.error } // instanceOverlayLoader is one pass's instance-layer reader, shared by both // sweeps (Sync and SyncExec) so neither can drift from the other about what // a host gets or what it costs. One List per (account, app), cached for the // pass including its failure, then a Get only for a host that listing // named. // // A listing FAILURE is returned to the caller wrapped in // instanceOverlayListErr, never folded into "no overlay". The two sweeps // treat it the way they treat a bundle fetch failure: leave the file // exactly as it is, say so, retry next pass. Folding it away would silently // deliver the app-wide merge to an instance that is meant to have its own // credential, which is the one outcome this layer exists to prevent. // // A host the listing named but whose object is gone by the time of the Get // is simply "no overlay": a delete racing the pass converges the route back // to its group's merge, which is what the delete asked for. // // The index map below has no lock. That's safe only because each call to // this function returns a fresh closure over its own map, and both Sync and // SyncExec build one loader per pass and drive it from their own single // route loop — never two goroutines sharing one closure. A caller that ever // shared one instanceOverlayLoader value across concurrently running sweeps // would race on this map; don't. func instanceOverlayLoader(ctx context.Context, s release.Store) func(acct, app, host string) (*Manifest, string, error) { type appKey struct{ acct, app string } type indexRes struct { hosts map[string]bool err error } index := map[appKey]indexRes{} return func(acct, app, host string) (*Manifest, string, error) { ak := appKey{acct, app} idx, done := index[ak] if !done { hosts, err := instanceOverlays(ctx, s, acct, app) if err != nil { err = &instanceOverlayListErr{fmt.Errorf("%s/%s: instance config listing failed: %w", acct, app, err)} } idx = indexRes{hosts: hosts, err: err} index[ak] = idx } if idx.err != nil { return nil, "", idx.err } if !idx.hosts[host] { return nil, "", nil } m, etag, found, err := LoadInstanceConfig(ctx, s, acct, app, host) if err != nil { return nil, "", fmt.Errorf("%s/%s: instance config for %s: %w", acct, app, host, err) } if !found { return nil, "", nil } return m, etag, nil } } func ApplyInstance(ctx context.Context, s release.Store, acct, app, host string, sets map[string]Var, unsets []string) (claimResult0 int, claimResult1 string, claimResult2 error) { claimResult2 = release.WithAppMutation(ctx, s, acct, app, "appconfig.ApplyInstance", func(claimContext context.Context) error { claimResult0, claimResult1, claimResult2 = claimRawApplyInstance(claimContext, s, acct, app, host, sets, unsets) return claimResult2 }) return } func DeleteInstanceConfig(ctx context.Context, s release.Store, acct, app, host string) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.DeleteInstanceConfig", func(claimContext context.Context) error { claimResult0 = claimRawDeleteInstanceConfig(claimContext, s, acct, app, host) return claimResult0 }) return }