carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Route-shape fingerprints: the registry-side half of "has this box's
2// config delivery got anything to do".
3//
4// The agent's fast loop (host package's configGenLoop) decides whether to
5// converge by reading each config-carrying app's generation object out of
6// the bucket. That catches every BUCKET-side change — a var set, a secret
7// rotated, an exec_delivery flag flipped — and nothing at all on the
8// REGISTRY side. But a route's Environment is a registry column, and it
9// decides which bundle the route's config comes from, so binding a route
10// to an environment changes what that route should be served while moving
11// no etag anywhere. Delivery then waits out configSyncLoop's hourly floor:
12// issue #196, reproduced on carlos-tito-ie 2026-08-15 as ten minutes of a
13// live route serving the wrong merge, whose only symptom was the app
14// reporting a credential missing — which reads as a BAD credential rather
15// than an undelivered one. The nudge that fixed it was re-writing three
16// identical secret values, purely to bump the generation object.
17//
18// These digests give that loop something to watch. They live here, beside
19// ExecConfigApps and UnitConfigApps and composed from the SAME eligibility
20// predicates those two use, for the reason both of those give for living
21// where they do: a fingerprint that disagreed with the sweep about which
22// routes matter would either burn passes forever or miss changes silently.
23package appconfig
24
25import (
26 "crypto/sha256"
27 "encoding/hex"
28 "sort"
29
30 "github.com/carlosframework/platform/internal/registry"
31 "github.com/carlosframework/platform/internal/release"
32)
33
34// ConfigRouteFingerprints returns one digest per delivery half over the
35// registry-side facts that decide what config a route gets: which routes
36// are in scope at all, and — for each — the (account, app, environment)
37// triple its manifest resolves from.
38//
39// Set MEMBERSHIP is what carries scope changes: a route that gains
40// Status: "failed", loses its app stamp, or is deleted outright simply
41// stops contributing a row, which moves the digest exactly as a rebinding
42// does. That is deliberate — those transitions all change what should be
43// on disk (a file to remove, a file to stop updating), and the pass a
44// movement triggers resolves each case properly rather than acting on the
45// digest itself.
46//
47// The OPT-IN is deliberately NOT here, for the reason ExecConfigApps gives
48// for omitting it too: reading it costs a bucket fetch per app per tick,
49// which is exactly what the fast loop exists to avoid. It does not need to
50// be — exec_delivery is a bundle write, and every bundle write bumps the
51// app's generation object (SetExecDeliveryAt), which the loop already
52// watches.
53//
54// The INSTANCE CONFIG LAYER is deliberately not here either, and for the
55// same reason with a sharper edge: apps/<acct>/<app>/instances-config/
56// <host>.json is a bucket object, so seeing a change to one would cost a
57// listing per app per tick at best and a read per route at worst. It does
58// not need to be seen here — writing or deleting an overlay bumps the app's
59// generation object (ApplyInstance, DeleteInstanceConfig), which this loop
60// already watches, and that is the layer's whole signal.
61//
62// Two digests, not one, because the two halves are delivered by different
63// processes: the exec half by the agent itself (SyncExec), the unit half
64// by a root pass the agent can only REQUEST. A shared digest would ask for
65// a root config run every time an exec route was rebound.
66func ConfigRouteFingerprints(routes []registry.Route) (exec, unit string) {
67 var execRows, unitRows []string
68 for _, rt := range routes {
69 // Exactly the inputs the two sweeps resolve a route's manifest
70 // from: SyncExec's groupKey and Sync's are both
71 // (RouteAccount, App, Environment), and Host is what names the
72 // file. Nothing else on the row can change which bytes belong in
73 // it, so nothing else belongs in the digest — a Version stamp
74 // here would fire a pass on every adopt.
75 row := rt.Host + "|" + release.RouteAccount(rt) + "|" + rt.App + "|" + rt.Environment
76 if execSyncable(rt) || execAddrCandidate(rt) {
77 execRows = append(execRows, row)
78 }
79 if syncable(rt) && rt.Backing == registry.BackingUnit {
80 unitRows = append(unitRows, row)
81 }
82 }
83 return digestRows(execRows), digestRows(unitRows)
84}
85
86// digestRows sorts and hashes. Sorted because registry order is not stable
87// across passes and an order-sensitive digest would fire constantly;
88// NUL-separated because none of the four fields can contain one, so no two
89// distinct row sets can concatenate to the same bytes.
90func digestRows(rows []string) string {
91 sort.Strings(rows)
92 h := sha256.New()
93 for _, r := range rows {
94 h.Write([]byte(r))
95 h.Write([]byte{0})
96 }
97 return hex.EncodeToString(h.Sum(nil))
98}
99