| 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. |
| 23 | package appconfig |
| 24 | |
| 25 | import ( |
| 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. |
| 66 | func 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. |
| 90 | func 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 | |