// 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
`
// 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. The fixture fractions are
// checked as JSON numbers separately: timestamps can legitimately contain
// the same digits in their fractional seconds.
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
}
const (
dryRunMemFrac = 0.73
dryRunDiskFrac = 0.95
)
var dryRunFractionNeedles = []float64{dryRunMemFrac, dryRunDiskFrac}
// dryRunMetricsNeedles are the distinctive raw values Task 5's BoxMetrics
// carries — real numbers and strings that Box.Metrics is the ONE deliberate
// exception privileged to publish (see snapshot.go's package doc), and which
// therefore MUST appear in the published box object. Unlike dryRunNeedles
// above (banned everywhere, box object included), these are allowed inside
// status/boxes/*.json and banned only from everything else DryRun publishes
// — see the scope split in DryRun's scan loop below. Each value is
// deliberately distinctive (the repeated 42s, a value with no legitimate
// reason to appear in a template or a fleet-wide aggregate) so a false
// negative from an incidental digit collision is effectively impossible.
var dryRunMetricsNeedles = []string{
"4242424242", // fixture MemTotalBytes, verbatim
"4242424241", // fixture MemAvailBytes, verbatim
"4242424240", // fixture DiskFreeBytes, verbatim
"42.42", // fixture Load1, verbatim
"dryrun4242sha", // fixture Release, verbatim
"reconnecting", // fixture Tunnel, verbatim (a real, valid Tunnel value)
}
// dryRunRouteVersionHost and dryRunRouteVersionVersion are Task 8's own
// needle pair (#78): a THIRD un-opted route, distinct from leakyapp and
// svc/internal above, whose only purpose is carrying a Version — the one
// thing that makes CollectBox populate Box.Metrics.RouteVersions, this
// package's one hostname-keyed field. Neither string reuses any existing
// needle (not dryRunNeedles, not dryRunMetricsNeedles, not demoapp's own
// "abc1234"): route_versions gets its own dedicated positive/negative check,
// scope-split exactly like dryRunMetricsNeedles below, rather than riding
// along on an assertion built for something else.
const (
dryRunRouteVersionHost = "staged-secret.internal"
dryRunRouteVersionVersion = "stagedver9012"
)
// dryRunRouteVersionNeedles is the scope-split pair for the fixture above:
// REQUIRED inside status/boxes/*.json (proof that RouteVersions actually
// attached — an unexercised field is a field this self-check can't vouch
// for) and BANNED everywhere else DryRun publishes (proof that a
// route_versions hostname, unlike a band word or an aggregate, never
// reaches fleet.json or the rendered page). Same reasoning and the same
// scan-loop shape as dryRunMetricsNeedles, because RouteVersions lives in
// the identical private home: Box.Metrics.
var dryRunRouteVersionNeedles = []string{dryRunRouteVersionHost, dryRunRouteVersionVersion}
// dryRunRouteServingHost and dryRunRouteServingServing are Task 13's needle
// pair (plan 5's ServingVersion): a THIRD un-opted route, distinct from
// all others, whose only purpose is carrying a ServingVersion — the one
// thing that makes CollectBox populate Box.Metrics.RouteServing. Neither
// string reuses any existing needle: route_serving gets its own
// dedicated positive/negative check, scope-split exactly like
// dryRunRouteVersionNeedles, rather than riding along on an assertion
// built for something else.
const (
dryRunRouteServingHost = "serving-secret.internal"
dryRunRouteServingServing = "servingver5678"
)
// dryRunRouteServingNeedles is the scope-split pair for the fixture above:
// REQUIRED inside status/boxes/*.json (proof that RouteServing actually
// attached — an unexercised field is a field this self-check can't vouch
// for) and BANNED everywhere else DryRun publishes (proof that
// route_serving hostnames never reach fleet.json or the rendered page).
// Same reasoning and the same scan-loop shape as dryRunRouteVersionNeedles,
// because RouteServing lives in the identical private home: Box.Metrics.
var dryRunRouteServingNeedles = []string{dryRunRouteServingHost, dryRunRouteServingServing}
// dryRunUpstreamHost and dryRunUpstreamAddr are the multi-upstream plan's own
// needle pair (Task 6) for Box.Metrics.Upstreams — the probe table, which is
// the most identifying thing this block carries: a route hostname as the KEY
// and a real backend address inside the value. Distinct strings again from
// every other needle set here (an address shape no other fixture uses, so a
// digit collision with dryRunMetricsNeedles' 4242s is impossible), and scoped
// the same way for the same reason: REQUIRED in status/boxes/*.json, so the
// self-check can't pass by never populating the field, and BANNED everywhere
// else DryRun publishes.
const (
dryRunUpstreamHost = "upstream-secret.internal"
dryRunUpstreamAddr = "10.77.77.7:7717"
)
var dryRunUpstreamNeedles = []string{dryRunUpstreamHost, dryRunUpstreamAddr}
// dryRunConfigRefusalHost is the config guard's needle (amadan #213):
// Box.Metrics.ConfigRefusals names the hosts a keyless box refuses, so the
// same scope split applies. REQUIRED in status/boxes/*.json and BANNED in
// every other published object.
const dryRunConfigRefusalHost = "refused-secret.internal"
var dryRunConfigRefusalNeedles = []string{dryRunConfigRefusalHost}
// dryRunReleaseRefusalHost is the release refusal needle (D1):
// Box.Metrics.ReleaseRefusals names the hosts whose build this box rolled
// back, so the same scope split applies. REQUIRED in status/boxes/*.json and
// BANNED in every other published object.
const dryRunReleaseRefusalHost = "refused-build.internal"
var dryRunReleaseRefusalNeedles = []string{dryRunReleaseRefusalHost}
// dryRunProofWaitHost is the proof wait needle (D3): Box.Metrics.ProofWaits
// names the hosts this box is holding for a dev proof, so the same scope
// split applies. REQUIRED in status/boxes/*.json and BANNED in every other
// published object.
const dryRunProofWaitHost = "proof-wait.internal"
var dryRunProofWaitNeedles = []string{dryRunProofWaitHost}
// dryRunRolloutHost is the rollout report needle: Box.Metrics.Rollout names
// the instances the rollout scheduler manages (hosts, failure records), so
// the same scope split applies. REQUIRED in status/boxes/*.json and BANNED in
// every other published object.
const dryRunRolloutHost = "rollout-host.internal"
var dryRunRolloutNeedles = []string{dryRunRolloutHost}
// dryRunBuildStateHost and dryRunBuildStateBuild are the build-state needles
// (settings spec rev 11 §4.4): Box.Metrics.BuildStates names the hosts this
// box runs and the builds their build states name, so the same scope split
// applies. REQUIRED in status/boxes/*.json and BANNED in every other
// published object.
const (
dryRunBuildStateHost = "build-state-host.internal"
dryRunBuildStateBuild = "v-dryrun-build-state"
)
var dryRunBuildStateNeedles = []string{dryRunBuildStateHost, dryRunBuildStateBuild}
// dryRunUpdatesHost and dryRunUpdatesBuild are the update-decision needles
// (settings spec §4.3): Box.Metrics.Updates names the hosts this box runs and
// the builds root chose for them, so the same scope split applies. REQUIRED in
// status/boxes/*.json and BANNED in every other published object.
const (
dryRunUpdatesHost = "updates-host.internal"
dryRunUpdatesBuild = "v-dryrun-updates"
)
var dryRunUpdatesNeedles = []string{dryRunUpdatesHost, dryRunUpdatesBuild}
// 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: three
// un-opted hosts (one instance, one service, and one instance whose only job
// is carrying a Version — see dryRunRouteVersionNeedles) that must vanish
// from every public surface, 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",
},
{
// Un-opted, so its host must not surface via the apps feed either
// — the ONLY reason this route exists is to give CollectBox a
// Version to fold into Box.Metrics.RouteVersions (see the needle
// pair's own doc comment above).
Host: dryRunRouteVersionHost, Kind: "instance", App: "stagedapp",
Status: "ready", Version: dryRunRouteVersionVersion,
},
{
// Un-opted, so its host must not surface via the apps feed either
// — the ONLY reason this route exists is to give CollectBox a
// ServingVersion to fold into Box.Metrics.RouteServing (see the
// needle pair's own doc comment above).
Host: dryRunRouteServingHost, Kind: "sidecar", App: "servingapp",
Status: "ready", ServingVersion: dryRunRouteServingServing,
},
}
memFrac, diskFrac := dryRunMemFrac, dryRunDiskFrac
lag := int64(120)
adopting := true // a known reading, so adoption_running publishes non-null and gets scanned
configPath := true // ditto for config_path_running (issue #44)
restartPath := true // ditto for restart_path_running (issue #113)
expiredCanaries := 2 // ditto for expired_canaries — a known count, not the unexercised nil path
// Task 5's raw Metrics fixtures: distinctive values (see
// dryRunMetricsNeedles) that MUST land in the published box object and
// MUST NOT land anywhere else DryRun publishes — the positive half of
// proving Box.Metrics's private exception actually stays private.
memTotal, memAvail, diskFree := int64(4242424242), int64(4242424241), int64(4242424240)
load1 := 42.42
probes := Probes{
MachineID: "ip-10-0-0-7",
Region: "us-east-1",
MemFrac: &memFrac,
DiskFrac: &diskFrac,
LagSecs: &lag,
AdoptRunning: &adopting,
ConfigPathRunning: &configPath,
RestartPathRunning: &restartPath,
ExpiredCanaries: &expiredCanaries,
// 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 },
MemTotalBytes: &memTotal,
MemAvailBytes: &memAvail,
Load1: &load1,
DiskFreeBytes: &diskFree,
Release: "dryrun4242sha",
Tunnel: "reconnecting",
// One refused host (amadan #213), so the scope check below has
// something to require in the private block and ban elsewhere.
ConfigRefusals: []ConfigRefusal{
{Host: dryRunConfigRefusalHost, Reason: "no_key", Since: time.Now().Add(-time.Hour), Last: time.Now(), Count: 2},
},
ConfigRefusalsTotal: 1,
// One rolled-back build (D1), for the same scope check.
ReleaseRefusals: []ReleaseRefusal{
{Host: dryRunReleaseRefusalHost, Version: "v-dryrun", Reason: "exited: status=2", RefusedAt: time.Now(), Kind: "wrong-program"},
},
// One host held for a dev proof (D3), for the same scope check.
ProofWaits: []ProofWait{
{Host: dryRunProofWaitHost, Version: "v-dryrun", SHA256: "dryrunproofsha", Prover: "dev", Reason: "no proof from dev", Since: time.Now()},
},
// One held instance and its failure record, for the rollout scope check.
Rollout: &RolloutReport{
Hosts: map[string]HostRollout{
dryRunRolloutHost: {Account: "dryrun", App: "app", Installed: "v-dryrun", Serving: "v-old", Good: "v-old", Awake: true,
Attempt: AttemptFailed, AttemptBuild: "v-dryrun", HeldBy: "v-dryrun", Reason: ReasonHealth},
},
Apps: map[string]AppRollout{"dryrun/app": {Queued: 1}},
Failures: []RolloutFailure{
{Account: "dryrun", App: "app", Build: "v-dryrun", Host: dryRunRolloutHost, Mode: "overlap", Reason: ReasonHealth, PromotedAt: time.Now(), At: time.Now()},
},
},
// One host's build-state builds, for the build_states scope check.
BuildStates: map[string]BuildStatePins{
dryRunBuildStateHost: {Account: "dryrun", App: "app", Builds: []string{dryRunBuildStateBuild}, Complete: true},
},
// One host's update decision, for the updates scope check.
UpdateSettings: UpdateSettingsProtocol,
Updates: map[string]HostUpdates{
dryRunUpdatesHost: {Desired: dryRunUpdatesBuild, Reason: "manual", Completed: 1},
},
// The probe table, keyed by a route host and carrying a backend
// address — see dryRunUpstreamNeedles. One up and one down upstream,
// so the down one's reason string is exercised too.
Upstreams: map[string][]UpstreamState{
dryRunUpstreamHost: {
{Addr: dryRunUpstreamAddr, Up: true, Since: time.Now().Add(-time.Hour)},
{Addr: "10.77.77.8:7717", Up: false, Since: time.Now().Add(-time.Minute), Fails: 3, LastError: "dial: connection refused"},
},
},
}
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
}
func scanForNumericLeaks(v interface{}, banned []float64) []string {
seen := map[float64]bool{}
var walk func(interface{})
walk = func(v interface{}) {
switch t := v.(type) {
case map[string]interface{}:
for _, value := range t {
walk(value)
}
case []interface{}:
for _, value := range t {
walk(value)
}
case float64:
for _, needle := range banned {
if t == needle {
seen[needle] = true
}
}
}
}
walk(v)
var found []string
for _, needle := range banned {
if seen[needle] {
found = append(found, fmt.Sprintf("%g", needle))
}
}
return found
}
// 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")
}
// boxKeySeen tracks whether the scan actually reached a status/boxes/
// object carrying the Metrics fixtures — the positive half of the
// Metrics scope check below, the same reason appPublished exists for
// the opt-in app: a leak-scan that finds nothing proves nothing about
// whether the thing that SHOULD be there actually is.
boxKeySeen := false
// routeVersionKeySeen is boxKeySeen's sibling for Task 8's own scoped
// pair (dryRunRouteVersionNeedles): whether the scan actually reached a
// status/boxes/ object carrying the route_versions fixture, so this
// self-check can't pass by simply never exercising RouteVersions.
routeVersionKeySeen := false
// routeServingKeySeen is the same positive half for the serving version
// map (dryRunRouteServingNeedles) — Box.Metrics.RouteServing, plan 5
// Task 2.
routeServingKeySeen := false
// upstreamKeySeen is the same positive half for the probe table
// (dryRunUpstreamNeedles) — Box.Metrics.Upstreams, Task 6.
upstreamKeySeen := false
// configRefusalKeySeen is the same positive half for the config guard's
// refusal list (dryRunConfigRefusalNeedles), amadan #213.
configRefusalKeySeen := false
// releaseRefusalKeySeen is the same positive half for D1's release
// refusal list (dryRunReleaseRefusalNeedles).
releaseRefusalKeySeen := false
// proofWaitKeySeen is the same positive half for D3's proof wait list
// (dryRunProofWaitNeedles).
proofWaitKeySeen := false
// rolloutKeySeen is the same positive half for the rollout report
// (dryRunRolloutNeedles).
rolloutKeySeen := false
// buildStateKeySeen is the same positive half for the build-state report
// (dryRunBuildStateNeedles).
buildStateKeySeen := false
// updatesKeySeen is the same positive half for the update decisions
// (dryRunUpdatesNeedles).
updatesKeySeen := false
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)
}
// dryRunMetricsNeedles are scoped: status/boxes/*.json is Box.Metrics's
// one privileged, private home (see snapshot.go's package doc), so
// the raw fixture values are EXPECTED there and must NOT be scanned
// out — that isn't a leak, it's the field doing its job. Every other
// key DryRun publishes (fleet.json, index.html, status.css) is
// exactly what BuildFleet/PublishFleet expose beyond the bucket, so
// those raw values must never appear there.
if strings.HasPrefix(key, "status/boxes/") {
if leaks := ScanForLeaks(body, dryRunMetricsNeedles); len(leaks) == 0 {
// None of the fixtures made it in at all — Metrics isn't
// being attached, which means this check isn't exercising
// anything (a false "no leak").
return fmt.Errorf("dry run: %s: expected raw Metrics fixtures %v, found none — Box.Metrics not attached?", key, dryRunMetricsNeedles)
}
boxKeySeen = true
} else if leaks := ScanForLeaks(body, dryRunMetricsNeedles); len(leaks) > 0 {
return fmt.Errorf("dry run: %s leaked raw Metrics fixture %v — Box.Metrics must never reach the public feed", key, leaks)
}
// dryRunRouteVersionNeedles gets the identical scope split as
// dryRunMetricsNeedles just above, for the identical reason:
// RouteVersions lives in Box.Metrics too, so status/boxes/*.json is
// its one privileged home and every other published key must never
// carry it.
if strings.HasPrefix(key, "status/boxes/") {
if leaks := ScanForLeaks(body, dryRunRouteVersionNeedles); len(leaks) == 0 {
return fmt.Errorf("dry run: %s: expected route_versions fixtures %v, found none — RouteVersions not attached?", key, dryRunRouteVersionNeedles)
}
routeVersionKeySeen = true
} else if leaks := ScanForLeaks(body, dryRunRouteVersionNeedles); len(leaks) > 0 {
return fmt.Errorf("dry run: %s leaked a route_versions fixture %v — RouteVersions must never reach the public feed", key, leaks)
}
// dryRunRouteServingNeedles: same scope split as RouteVersions.
// RouteServing (plan 5's ServingVersion) is also a Box.Metrics field
// with hostnames, so status/boxes/*.json is its one privileged home
// and every other published key must never carry it.
if strings.HasPrefix(key, "status/boxes/") {
if leaks := ScanForLeaks(body, dryRunRouteServingNeedles); len(leaks) == 0 {
return fmt.Errorf("dry run: %s: expected route_serving fixtures %v, found none — RouteServing not attached?", key, dryRunRouteServingNeedles)
}
routeServingKeySeen = true
} else if leaks := ScanForLeaks(body, dryRunRouteServingNeedles); len(leaks) > 0 {
return fmt.Errorf("dry run: %s leaked a route_serving fixture %v — RouteServing must never reach the public feed", key, leaks)
}
// dryRunUpstreamNeedles: the third scope split, on the same pattern.
// Upstreams is Box.Metrics's other hostname-keyed field and the only
// one carrying backend ADDRESSES, so status/boxes/*.json is its one
// privileged home and fleet.json/index.html must never show either
// half of the pair.
if strings.HasPrefix(key, "status/boxes/") {
if leaks := ScanForLeaks(body, dryRunUpstreamNeedles); len(leaks) == 0 {
return fmt.Errorf("dry run: %s: expected upstreams fixtures %v, found none — Upstreams not attached?", key, dryRunUpstreamNeedles)
}
upstreamKeySeen = true
} else if leaks := ScanForLeaks(body, dryRunUpstreamNeedles); len(leaks) > 0 {
return fmt.Errorf("dry run: %s leaked an upstreams fixture %v — Upstreams must never reach the public feed", key, leaks)
}
// dryRunConfigRefusalNeedles: the fourth scope split. The refusal list
// names real hosts, so status/boxes/*.json is its one private home.
if strings.HasPrefix(key, "status/boxes/") {
if leaks := ScanForLeaks(body, dryRunConfigRefusalNeedles); len(leaks) == 0 {
return fmt.Errorf("dry run: %s: expected config_refusals fixtures %v, found none — ConfigRefusals not attached?", key, dryRunConfigRefusalNeedles)
}
configRefusalKeySeen = true
} else if leaks := ScanForLeaks(body, dryRunConfigRefusalNeedles); len(leaks) > 0 {
return fmt.Errorf("dry run: %s leaked a config_refusals fixture %v — ConfigRefusals must never reach the public feed", key, leaks)
}
// dryRunReleaseRefusalNeedles: the fifth scope split, on the same
// pattern. The rolled-back builds name real hosts.
if strings.HasPrefix(key, "status/boxes/") {
if leaks := ScanForLeaks(body, dryRunReleaseRefusalNeedles); len(leaks) == 0 {
return fmt.Errorf("dry run: %s: expected release_refusals fixtures %v, found none — ReleaseRefusals not attached?", key, dryRunReleaseRefusalNeedles)
}
releaseRefusalKeySeen = true
} else if leaks := ScanForLeaks(body, dryRunReleaseRefusalNeedles); len(leaks) > 0 {
return fmt.Errorf("dry run: %s leaked a release_refusals fixture %v — ReleaseRefusals must never reach the public feed", key, leaks)
}
// dryRunProofWaitNeedles: the sixth scope split, on the same
// pattern. The hosts held for a dev proof are real route names.
if strings.HasPrefix(key, "status/boxes/") {
if leaks := ScanForLeaks(body, dryRunProofWaitNeedles); len(leaks) == 0 {
return fmt.Errorf("dry run: %s: expected proof_waits fixtures %v, found none — ProofWaits not attached?", key, dryRunProofWaitNeedles)
}
proofWaitKeySeen = true
} else if leaks := ScanForLeaks(body, dryRunProofWaitNeedles); len(leaks) > 0 {
return fmt.Errorf("dry run: %s leaked a proof_waits fixture %v — ProofWaits must never reach the public feed", key, leaks)
}
// dryRunRolloutNeedles: the seventh scope split, on the same
// pattern. The rollout report names the managed instances.
if strings.HasPrefix(key, "status/boxes/") {
if leaks := ScanForLeaks(body, dryRunRolloutNeedles); len(leaks) == 0 {
return fmt.Errorf("dry run: %s: expected rollout fixtures %v, found none — Rollout not attached?", key, dryRunRolloutNeedles)
}
rolloutKeySeen = true
} else if leaks := ScanForLeaks(body, dryRunRolloutNeedles); len(leaks) > 0 {
return fmt.Errorf("dry run: %s leaked a rollout fixture %v — Rollout must never reach the public feed", key, leaks)
}
// dryRunBuildStateNeedles: the eighth scope split, on the same
// pattern. The build-state report names hosts and builds.
if strings.HasPrefix(key, "status/boxes/") {
if leaks := ScanForLeaks(body, dryRunBuildStateNeedles); len(leaks) == 0 {
return fmt.Errorf("dry run: %s: expected build_states fixtures %v, found none. BuildStates not attached?", key, dryRunBuildStateNeedles)
}
buildStateKeySeen = true
} else if leaks := ScanForLeaks(body, dryRunBuildStateNeedles); len(leaks) > 0 {
return fmt.Errorf("dry run: %s leaked a build_states fixture %v. BuildStates must never reach the public feed", key, leaks)
}
// dryRunUpdatesNeedles: the ninth scope split, on the same pattern.
// The update decisions name hosts and builds.
if strings.HasPrefix(key, "status/boxes/") {
if leaks := ScanForLeaks(body, dryRunUpdatesNeedles); len(leaks) == 0 {
return fmt.Errorf("dry run: %s: expected updates fixtures %v, found none. Updates not attached?", key, dryRunUpdatesNeedles)
}
updatesKeySeen = true
} else if leaks := ScanForLeaks(body, dryRunUpdatesNeedles); len(leaks) > 0 {
return fmt.Errorf("dry run: %s leaked an updates fixture %v. Updates must never reach the public feed", 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 leaks := scanForNumericLeaks(decoded, dryRunFractionNeedles); len(leaks) > 0 {
return fmt.Errorf("dry run: %s leaked %v", key, leaks)
}
if bad := scanBandFieldsForDigits(decoded); len(bad) > 0 {
return fmt.Errorf("dry run: %s: band field carries a digit: %v", key, bad)
}
}
}
if !boxKeySeen {
return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — Metrics scope check never ran")
}
if !routeVersionKeySeen {
return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — route_versions scope check never ran")
}
if !routeServingKeySeen {
return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — route_serving scope check never ran")
}
if !upstreamKeySeen {
return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — upstreams scope check never ran")
}
if !configRefusalKeySeen {
return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — config_refusals scope check never ran")
}
if !releaseRefusalKeySeen {
return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — release_refusals scope check never ran")
}
if !proofWaitKeySeen {
return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — proof_waits scope check never ran")
}
if !rolloutKeySeen {
return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — rollout scope check never ran")
}
if !buildStateKeySeen {
return fmt.Errorf("dry run: no status/boxes/*.json object was scanned. The build_states scope check never ran")
}
if !updatesKeySeen {
return fmt.Errorf("dry run: no status/boxes/*.json object was scanned. The updates scope check never ran")
}
fmt.Fprintln(out, "dry-run: no leaks")
return nil
}