carlos / platform Public

status: dry-run — the self-hoster's "what would I publish?" in one verb

DryRun (internal/status/dryrun.go) runs the real CollectBox -> WriteBox ->
BuildFleet -> PublishFleet pipeline over a fixture registry stuffed with
needles (a fixture hostname, a machine id, mid-band fractions 0.73/0.95),
then reads every object it just published back out of the store and fails
loudly on any leaked needle or any digit inside a band-word field. One
route opts in (demoapp) and DryRun asserts it DOES appear in fleet.apps —
opt-in is the exception a pure leak-scan can't prove on its own.

ScanForLeaks is exported so contract_test.go's scanForLeaks (package
status_test, which can't reach an unexported symbol here) delegates to it
instead of keeping a second copy that could silently drift out of sync.

carlos status -dry-run <dir> (cli.go's RunStatus, wired in main.go) is the
CLI entry point; carlos status with no flag prints usage to stderr and
exits 1 — live publish has no CLI mode, it's the box agent's 5-minute
status-lease tick, not a one-shot command.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5d5098e1f31abaa44c73ebbf66eaeedecb07cd8b parent 9ca4a6c
5 files changed, +292 −9
  • cli.go +40 −0
  • internal/status/contract_test.go +7 −9
  • internal/status/dryrun.go +222 −0
  • internal/status/status_test.go +19 −0
  • main.go +4 −0
diff --git a/cli.go b/cli.go
index 3ddc749a9..2a43023ef 100644
--- a/cli.go
+++ b/cli.go
@@ -21,6 +21,7 @@ import (
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
"github.com/carlosframework/platform/internal/version"
)
@@ -291,3 +292,42 @@ func runKeygen(args []string) {
fmt.Printf("CARLOS_RELEASE_KEY=%s\nCARLOS_RELEASE_PUBKEY=%s\n", seed, pub)
fmt.Fprintln(os.Stderr, "store the KEY in your secret manager; the PUBKEY goes in host config")
}
+
+// statusUsage is shared between "no flag given" and "-h/--help" so an
+// operator gets the same explanation either way: this verb only has a
+// dry-run mode. The fleet status page's LIVE publish loop (CollectBox ->
+// WriteBox on every box, BuildFleet -> PublishFleet on the lease holder)
+// runs inside the box agent's own 5-minute tick, not from a one-shot CLI
+// invocation — there is deliberately no "carlos status publish" here.
+const statusUsage = `usage: carlos status -dry-run <dir>
+
+ -dry-run <dir> build a throwaway fleet stuffed with fixture secrets
+ (hostnames, a machine id, mid-band fractions), run it
+ through the real CollectBox/WriteBox/BuildFleet/
+ PublishFleet path into <dir>, then read every published
+ object back and fail if any fixture leaked. The self-
+ hoster's "what would I publish?" check, run against a
+ scratch directory before ever pointing this binary at a
+ real bucket.
+
+carlos status has no live mode: the fleet status page is published every 5
+minutes by the box agent's own status lease holder (see "carlos agent"),
+never by a one-shot CLI invocation.
+`
+
+// RunStatus is the "carlos status" verb: dry-run only. Modeled on ops.Run's
+// FlagSet shape (main.go's "ops" case), but flatter — status has exactly one
+// mode today, so there is no sub-verb dispatch to build out yet.
+func RunStatus(args []string) {
+ fs := flag.NewFlagSet("status", flag.ExitOnError)
+ dryRunDir := fs.String("dry-run", "", "build a fixture fleet, publish it into this directory, and verify no leaks")
+ fs.Parse(args)
+
+ if *dryRunDir == "" {
+ fmt.Fprint(os.Stderr, statusUsage)
+ os.Exit(1)
+ }
+ if err := status.DryRun(*dryRunDir, os.Stdout); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go
index 871ed8ae4..154202123 100644
--- a/internal/status/contract_test.go
+++ b/internal/status/contract_test.go
@@ -102,16 +102,14 @@ func walkKeys(t *testing.T, obj interface{}, path string, allowedKeys map[string
// scanForLeaks searches the JSON bytes for any of the banned strings and
// returns the list of banned strings actually found. Used by tests to verify
-// sensitive data (hostnames, IDs) never leaks into the output.
+// sensitive data (hostnames, IDs) never leaks into the output. Delegates to
+// status.ScanForLeaks (dryrun.go) rather than reimplementing the scan here:
+// DryRun needs this same check at runtime (this file is package
+// status_test, which DryRun — living in package status — cannot import), and
+// two copies of "search bytes for banned substrings" would risk silently
+// drifting into checking different things.
func scanForLeaks(j []byte, banned []string) []string {
- jStr := string(j)
- var found []string
- for _, b := range banned {
- if strings.Contains(jStr, b) {
- found = append(found, b)
- }
- }
- return found
+ return status.ScanForLeaks(j, banned)
}
// fixtureRoutes creates test routes with a variety of kinds, some hibernating,
diff --git a/internal/status/dryrun.go b/internal/status/dryrun.go
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
+}
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index 8df8f4f6c..8796f8b5e 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -1,6 +1,7 @@
package status_test
import (
+ "bytes"
"context"
"encoding/json"
"errors"
@@ -446,3 +447,21 @@ func TestWriteBoxReturnsErrorOnDoubleCASConflict(t *testing.T) {
t.Errorf("Put called %d times, want exactly 2 (no third attempt)", flaky.putCalls)
}
}
+
+// ---- Task 6: dry-run self-check --------------------------------------------
+
+// TestDryRunSelfChecks is the plan's Step-1 test verbatim: DryRun must run
+// the real CollectBox -> WriteBox -> BuildFleet -> PublishFleet path over a
+// fixture registry stuffed with needles (hostnames, a machine id, mid-band
+// fractions), then read every published object back and prove none of those
+// needles survived — the self-hoster's "what would I publish?" check, run
+// before ever pointing this binary at a real bucket.
+func TestDryRunSelfChecks(t *testing.T) {
+ var buf bytes.Buffer
+ if err := status.DryRun(t.TempDir(), &buf); err != nil {
+ t.Fatalf("dry run: %v\n%s", err, buf.String())
+ }
+ if !strings.Contains(buf.String(), "no leaks") {
+ t.Errorf("dry run must attest the self-check ran: %s", buf.String())
+ }
+}
diff --git a/main.go b/main.go
index aa180c470..a1fcc8c06 100644
--- a/main.go
+++ b/main.go
@@ -43,6 +43,8 @@ func main() {
runKeygen(args)
case "ops":
ops.Run(args)
+ case "status":
+ RunStatus(args)
case "agent":
host.RunAgent(args)
case "bootstrap":
@@ -72,5 +74,7 @@ commands:
canary ship this worktree to an ephemeral canary channel
bootstrap prepare a host (units, dirs, user)
ops litestream | restore-verify
+ status -dry-run <dir>: fleet status page leak self-check
+ (live publish has no CLI mode; the agent owns it)
vet check an app release against the platform contract`)
}