| new file mode 100644 |
| index 000000000..6bbf50605 |
| --- /dev/null |
| +++ b/internal/status/dryrun.go |
| @@ -0,0 +1,222 @@ |
| +// Task 6: the self-hoster's "what would I publish?" check. DryRun runs the |
| +// REAL writer/merger/publisher path — CollectBox, WriteBox, BuildFleet, |
| +// PublishFleet — over a fixture registry deliberately stuffed with needles |
| +// (hostnames, a machine id, mid-band fractions), then reads every object it |
| +// just published back out of the store and fails loudly if any needle |
| +// survived. It exists so an operator can run `carlos status -dry-run <dir>` |
| +// against a scratch directory BEFORE ever pointing this binary at a real |
| +// bucket, and see for themselves that the privacy contract holds end to end |
| +// rather than trusting the package's own unit tests never regress. |
| +package status |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// dryRunOptInApp is the ONE app in DryRun's fixture registry that opts into |
| +// the status page via StatusNamed. It exists to prove the leak-scan doesn't |
| +// overreach: an opted-in app name is a deliberate publication, not a leak, |
| +// so DryRun asserts this name DOES appear in fleet.apps — the flip side of |
| +// every other needle below, which must NOT appear anywhere. |
| +const dryRunOptInApp = "demoapp" |
| + |
| +// dryRunNeedles are every fixture secret DryRun seeds into its throwaway |
| +// registry and probes. None of these substrings may appear in ANY object |
| +// DryRun publishes. "prod-secret" is listed both whole and as a bare prefix |
| +// so a leak that only carries part of the hostname (e.g. truncated in some |
| +// future summary field) still trips the scan. "0.73"/"0.95" are the exact |
| +// literal fractions fed into the probes — if either ever showed up verbatim |
| +// in published JSON, a raw ratio escaped banding somewhere upstream of the |
| +// digit-in-band-fields check below (which catches band words carrying ANY |
| +// digit, not just these two). |
| +var dryRunNeedles = []string{ |
| + "prod-secret.example.com", // fixture hostname |
| + "prod-secret", // hostname's leak-prone substring on its own |
| + "example.com", // the fixture domain generally |
| + "ip-10-0-0-7", // fixture machine id |
| + "0.73", // fixture MemFrac, verbatim |
| + "0.95", // fixture DiskFrac, verbatim |
| +} |
| + |
| +// ScanForLeaks reports which of banned's strings appear anywhere in j. It is |
| +// the single implementation both DryRun's self-check and |
| +// contract_test.go's scanForLeaks test helper run — the test package can't |
| +// call an unexported function here, and duplicating the logic in both places |
| +// would let them silently drift into checking different things, which is |
| +// exactly the kind of gap a needle could slip through. |
| +func ScanForLeaks(j []byte, banned []string) []string { |
| + s := string(j) |
| + var found []string |
| + for _, b := range banned { |
| + if strings.Contains(s, b) { |
| + found = append(found, b) |
| + } |
| + } |
| + return found |
| +} |
| + |
| +// dryRunFixtures builds a registry and probe set stuffed with needles: two |
| +// un-opted hosts (one instance, one service) that must vanish entirely, one |
| +// opted-in app that must survive by name, and mid-band fractions — 0.73 and |
| +// 0.95 sit inside the "busy"/"at-capacity" bands per snapshot.go's |
| +// 0.70/0.90 cuts, so banding actually gets exercised rather than trivially |
| +// cleared at an edge. |
| +func dryRunFixtures() ([]registry.Route, Probes) { |
| + routes := []registry.Route{ |
| + { |
| + Host: "prod-secret.example.com", Kind: "instance", App: "leakyapp", |
| + Status: "ready", Socket: "/run/carlos/leakyapp.sock", |
| + }, |
| + { |
| + Host: "demoapp.internal", Kind: "instance", App: dryRunOptInApp, |
| + Status: "ready", StatusNamed: true, Channel: "stable", Version: "abc1234", |
| + }, |
| + { |
| + Host: "svc-secret.example.com", Kind: "service", App: "svc/internal", |
| + Status: "ready", |
| + }, |
| + } |
| + |
| + memFrac, diskFrac := 0.73, 0.95 |
| + lag := int64(120) |
| + probes := Probes{ |
| + MachineID: "ip-10-0-0-7", |
| + Region: "us-east-1", |
| + MemFrac: &memFrac, |
| + DiskFrac: &diskFrac, |
| + LagSecs: &lag, |
| + // Every hibernating route reports "listening" so Awake/Asleep publish |
| + // non-null — an unexercised null field is a field DryRun can't scan. |
| + Listening: func(string) *bool { v := true; return &v }, |
| + } |
| + return routes, probes |
| +} |
| + |
| +// appPublished reports whether name appears in the fleet's opted-in apps — |
| +// used to assert dryRunOptInApp made it through, the positive half of the |
| +// privacy contract that a pure leak-scan (which only ever proves absence) |
| +// can't check on its own. |
| +func appPublished(f *Fleet, name string) bool { |
| + for _, a := range f.Apps { |
| + if a.Name == name { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// bandFieldKeys are the JSON key names that ever carry a band word (see |
| +// snapshot.go's loadWords/capacityWords): Box's "load" and "capacity" fields |
| +// (the latter a bare string), each boxes[] element's "load", and Fleet's |
| +// nested "capacity.level". Keyed by field name rather than a fixed JSON |
| +// path so the same check works unmodified against Box's shape, Fleet's |
| +// shape, and any future struct that reuses these field names. |
| +var bandFieldKeys = map[string]bool{"load": true, "capacity": true, "level": true} |
| + |
| +// scanBandFieldsForDigits walks decoded JSON looking for band-word fields |
| +// and flags any whose value carries a digit. Per the plan's privacy rule, a |
| +// band field is a WORD (comfortable/busy/critical, plenty/getting-full/at- |
| +// capacity), never a number — a digit surviving into one of these fields |
| +// means a raw fraction escaped banding somewhere between CollectBox and the |
| +// published bytes, independent of whether it happens to match one of the |
| +// exact fixture fractions in dryRunNeedles. |
| +func scanBandFieldsForDigits(v interface{}) []string { |
| + var bad []string |
| + var walk func(interface{}) |
| + walk = func(v interface{}) { |
| + switch t := v.(type) { |
| + case map[string]interface{}: |
| + for k, val := range t { |
| + if bandFieldKeys[k] { |
| + if s, ok := val.(string); ok && strings.ContainsAny(s, "0123456789") { |
| + bad = append(bad, fmt.Sprintf("%s=%q", k, s)) |
| + } |
| + } |
| + walk(val) |
| + } |
| + case []interface{}: |
| + for _, elem := range t { |
| + walk(elem) |
| + } |
| + } |
| + } |
| + walk(v) |
| + return bad |
| +} |
| + |
| +// DryRun runs the real publish pipeline into a DirStore rooted at dir, then |
| +// reads back everything it just published and fails on any leaked needle — |
| +// see the package doc comment above for why. Progress (each published key) |
| +// is written to out as it happens, ending with the literal "dry-run: no |
| +// leaks" attestation the plan's Step-1 test greps for; dir is expected to be |
| +// a scratch directory (t.TempDir() in tests, an operator-chosen throwaway |
| +// path on the command line) since DryRun writes real objects into it. |
| +func DryRun(dir string, out io.Writer) error { |
| + ctx := context.Background() |
| + s := release.NewDirStore(dir) |
| + |
| + routes, probes := dryRunFixtures() |
| + now := time.Now() |
| + |
| + box := CollectBox(routes, probes, now) |
| + if err := WriteBox(ctx, s, box); err != nil { |
| + return fmt.Errorf("dry run: write box: %w", err) |
| + } |
| + |
| + fleet, err := BuildFleet(ctx, s, routes, now, 5*time.Minute) |
| + if err != nil { |
| + return fmt.Errorf("dry run: build fleet: %w", err) |
| + } |
| + if err := PublishFleet(ctx, s, fleet); err != nil { |
| + return fmt.Errorf("dry run: publish fleet: %w", err) |
| + } |
| + |
| + // The positive check: opt-in is the one exception a leak-scan can't |
| + // prove by itself (absence of a leak says nothing about whether the |
| + // thing that SHOULD be there actually is). |
| + if !appPublished(fleet, dryRunOptInApp) { |
| + return fmt.Errorf("dry run: opted-in app %q must appear in fleet.apps — opt-in path is broken", dryRunOptInApp) |
| + } |
| + |
| + keys, err := s.List(ctx, "status/") |
| + if err != nil { |
| + return fmt.Errorf("dry run: list published objects: %w", err) |
| + } |
| + if len(keys) == 0 { |
| + return fmt.Errorf("dry run: nothing published under status/ — publish path is broken") |
| + } |
| + |
| + for _, key := range keys { |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + return fmt.Errorf("dry run: read back %s: %w", key, err) |
| + } |
| + fmt.Fprintf(out, "published %s (%d bytes)\n", key, len(body)) |
| + |
| + if leaks := ScanForLeaks(body, dryRunNeedles); len(leaks) > 0 { |
| + return fmt.Errorf("dry run: %s leaked %v", key, leaks) |
| + } |
| + |
| + // Page assets (index.html, status.css) aren't JSON and simply fail |
| + // to decode here — silently skipped, since the band-digit check |
| + // only means anything for objects that actually carry Load/Capacity |
| + // fields. |
| + var decoded interface{} |
| + if err := json.Unmarshal(body, &decoded); err == nil { |
| + if bad := scanBandFieldsForDigits(decoded); len(bad) > 0 { |
| + return fmt.Errorf("dry run: %s: band field carries a digit: %v", key, bad) |
| + } |
| + } |
| + } |
| + |
| + fmt.Fprintln(out, "dry-run: no leaks") |
| + return nil |
| +} |