| 1 | // Task 6: the self-hoster's "what would I publish?" check. DryRun runs the |
| 2 | // REAL writer/merger/publisher path — CollectBox, WriteBox, BuildFleet, |
| 3 | // PublishFleet — over a fixture registry deliberately stuffed with needles |
| 4 | // (hostnames, a machine id, mid-band fractions), then reads every object it |
| 5 | // just published back out of the store and fails loudly if any needle |
| 6 | // survived. It exists so an operator can run `carlos status -dry-run <dir>` |
| 7 | // against a scratch directory BEFORE ever pointing this binary at a real |
| 8 | // bucket, and see for themselves that the privacy contract holds end to end |
| 9 | // rather than trusting the package's own unit tests never regress. |
| 10 | package status |
| 11 | |
| 12 | import ( |
| 13 | "context" |
| 14 | "encoding/json" |
| 15 | "fmt" |
| 16 | "io" |
| 17 | "strings" |
| 18 | "time" |
| 19 | |
| 20 | "github.com/carlosframework/platform/internal/registry" |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | ) |
| 23 | |
| 24 | // dryRunOptInApp is the ONE app in DryRun's fixture registry that opts into |
| 25 | // the status page via StatusNamed. It exists to prove the leak-scan doesn't |
| 26 | // overreach: an opted-in app name is a deliberate publication, not a leak, |
| 27 | // so DryRun asserts this name DOES appear in fleet.apps — the flip side of |
| 28 | // every other needle below, which must NOT appear anywhere. |
| 29 | const dryRunOptInApp = "demoapp" |
| 30 | |
| 31 | // dryRunNeedles are every fixture secret DryRun seeds into its throwaway |
| 32 | // registry and probes. None of these substrings may appear in ANY object |
| 33 | // DryRun publishes. "prod-secret" is listed both whole and as a bare prefix |
| 34 | // so a leak that only carries part of the hostname (e.g. truncated in some |
| 35 | // future summary field) still trips the scan. The fixture fractions are |
| 36 | // checked as JSON numbers separately: timestamps can legitimately contain |
| 37 | // the same digits in their fractional seconds. |
| 38 | var dryRunNeedles = []string{ |
| 39 | "prod-secret.example.com", // fixture hostname |
| 40 | "prod-secret", // hostname's leak-prone substring on its own |
| 41 | "example.com", // the fixture domain generally |
| 42 | "ip-10-0-0-7", // fixture machine id |
| 43 | } |
| 44 | |
| 45 | const ( |
| 46 | dryRunMemFrac = 0.73 |
| 47 | dryRunDiskFrac = 0.95 |
| 48 | ) |
| 49 | |
| 50 | var dryRunFractionNeedles = []float64{dryRunMemFrac, dryRunDiskFrac} |
| 51 | |
| 52 | // dryRunMetricsNeedles are the distinctive raw values Task 5's BoxMetrics |
| 53 | // carries — real numbers and strings that Box.Metrics is the ONE deliberate |
| 54 | // exception privileged to publish (see snapshot.go's package doc), and which |
| 55 | // therefore MUST appear in the published box object. Unlike dryRunNeedles |
| 56 | // above (banned everywhere, box object included), these are allowed inside |
| 57 | // status/boxes/*.json and banned only from everything else DryRun publishes |
| 58 | // — see the scope split in DryRun's scan loop below. Each value is |
| 59 | // deliberately distinctive (the repeated 42s, a value with no legitimate |
| 60 | // reason to appear in a template or a fleet-wide aggregate) so a false |
| 61 | // negative from an incidental digit collision is effectively impossible. |
| 62 | var dryRunMetricsNeedles = []string{ |
| 63 | "4242424242", // fixture MemTotalBytes, verbatim |
| 64 | "4242424241", // fixture MemAvailBytes, verbatim |
| 65 | "4242424240", // fixture DiskFreeBytes, verbatim |
| 66 | "42.42", // fixture Load1, verbatim |
| 67 | "dryrun4242sha", // fixture Release, verbatim |
| 68 | "reconnecting", // fixture Tunnel, verbatim (a real, valid Tunnel value) |
| 69 | } |
| 70 | |
| 71 | // dryRunRouteVersionHost and dryRunRouteVersionVersion are Task 8's own |
| 72 | // needle pair (#78): a THIRD un-opted route, distinct from leakyapp and |
| 73 | // svc/internal above, whose only purpose is carrying a Version — the one |
| 74 | // thing that makes CollectBox populate Box.Metrics.RouteVersions, this |
| 75 | // package's one hostname-keyed field. Neither string reuses any existing |
| 76 | // needle (not dryRunNeedles, not dryRunMetricsNeedles, not demoapp's own |
| 77 | // "abc1234"): route_versions gets its own dedicated positive/negative check, |
| 78 | // scope-split exactly like dryRunMetricsNeedles below, rather than riding |
| 79 | // along on an assertion built for something else. |
| 80 | const ( |
| 81 | dryRunRouteVersionHost = "staged-secret.internal" |
| 82 | dryRunRouteVersionVersion = "stagedver9012" |
| 83 | ) |
| 84 | |
| 85 | // dryRunRouteVersionNeedles is the scope-split pair for the fixture above: |
| 86 | // REQUIRED inside status/boxes/*.json (proof that RouteVersions actually |
| 87 | // attached — an unexercised field is a field this self-check can't vouch |
| 88 | // for) and BANNED everywhere else DryRun publishes (proof that a |
| 89 | // route_versions hostname, unlike a band word or an aggregate, never |
| 90 | // reaches fleet.json or the rendered page). Same reasoning and the same |
| 91 | // scan-loop shape as dryRunMetricsNeedles, because RouteVersions lives in |
| 92 | // the identical private home: Box.Metrics. |
| 93 | var dryRunRouteVersionNeedles = []string{dryRunRouteVersionHost, dryRunRouteVersionVersion} |
| 94 | |
| 95 | // dryRunRouteServingHost and dryRunRouteServingServing are Task 13's needle |
| 96 | // pair (plan 5's ServingVersion): a THIRD un-opted route, distinct from |
| 97 | // all others, whose only purpose is carrying a ServingVersion — the one |
| 98 | // thing that makes CollectBox populate Box.Metrics.RouteServing. Neither |
| 99 | // string reuses any existing needle: route_serving gets its own |
| 100 | // dedicated positive/negative check, scope-split exactly like |
| 101 | // dryRunRouteVersionNeedles, rather than riding along on an assertion |
| 102 | // built for something else. |
| 103 | const ( |
| 104 | dryRunRouteServingHost = "serving-secret.internal" |
| 105 | dryRunRouteServingServing = "servingver5678" |
| 106 | ) |
| 107 | |
| 108 | // dryRunRouteServingNeedles is the scope-split pair for the fixture above: |
| 109 | // REQUIRED inside status/boxes/*.json (proof that RouteServing actually |
| 110 | // attached — an unexercised field is a field this self-check can't vouch |
| 111 | // for) and BANNED everywhere else DryRun publishes (proof that |
| 112 | // route_serving hostnames never reach fleet.json or the rendered page). |
| 113 | // Same reasoning and the same scan-loop shape as dryRunRouteVersionNeedles, |
| 114 | // because RouteServing lives in the identical private home: Box.Metrics. |
| 115 | var dryRunRouteServingNeedles = []string{dryRunRouteServingHost, dryRunRouteServingServing} |
| 116 | |
| 117 | // dryRunUpstreamHost and dryRunUpstreamAddr are the multi-upstream plan's own |
| 118 | // needle pair (Task 6) for Box.Metrics.Upstreams — the probe table, which is |
| 119 | // the most identifying thing this block carries: a route hostname as the KEY |
| 120 | // and a real backend address inside the value. Distinct strings again from |
| 121 | // every other needle set here (an address shape no other fixture uses, so a |
| 122 | // digit collision with dryRunMetricsNeedles' 4242s is impossible), and scoped |
| 123 | // the same way for the same reason: REQUIRED in status/boxes/*.json, so the |
| 124 | // self-check can't pass by never populating the field, and BANNED everywhere |
| 125 | // else DryRun publishes. |
| 126 | const ( |
| 127 | dryRunUpstreamHost = "upstream-secret.internal" |
| 128 | dryRunUpstreamAddr = "10.77.77.7:7717" |
| 129 | ) |
| 130 | |
| 131 | var dryRunUpstreamNeedles = []string{dryRunUpstreamHost, dryRunUpstreamAddr} |
| 132 | |
| 133 | // dryRunConfigRefusalHost is the config guard's needle (amadan #213): |
| 134 | // Box.Metrics.ConfigRefusals names the hosts a keyless box refuses, so the |
| 135 | // same scope split applies. REQUIRED in status/boxes/*.json and BANNED in |
| 136 | // every other published object. |
| 137 | const dryRunConfigRefusalHost = "refused-secret.internal" |
| 138 | |
| 139 | var dryRunConfigRefusalNeedles = []string{dryRunConfigRefusalHost} |
| 140 | |
| 141 | // dryRunReleaseRefusalHost is the release refusal needle (D1): |
| 142 | // Box.Metrics.ReleaseRefusals names the hosts whose build this box rolled |
| 143 | // back, so the same scope split applies. REQUIRED in status/boxes/*.json and |
| 144 | // BANNED in every other published object. |
| 145 | const dryRunReleaseRefusalHost = "refused-build.internal" |
| 146 | |
| 147 | var dryRunReleaseRefusalNeedles = []string{dryRunReleaseRefusalHost} |
| 148 | |
| 149 | // dryRunProofWaitHost is the proof wait needle (D3): Box.Metrics.ProofWaits |
| 150 | // names the hosts this box is holding for a dev proof, so the same scope |
| 151 | // split applies. REQUIRED in status/boxes/*.json and BANNED in every other |
| 152 | // published object. |
| 153 | const dryRunProofWaitHost = "proof-wait.internal" |
| 154 | |
| 155 | var dryRunProofWaitNeedles = []string{dryRunProofWaitHost} |
| 156 | |
| 157 | // dryRunRolloutHost is the rollout report needle: Box.Metrics.Rollout names |
| 158 | // the instances the rollout scheduler manages (hosts, failure records), so |
| 159 | // the same scope split applies. REQUIRED in status/boxes/*.json and BANNED in |
| 160 | // every other published object. |
| 161 | const dryRunRolloutHost = "rollout-host.internal" |
| 162 | |
| 163 | var dryRunRolloutNeedles = []string{dryRunRolloutHost} |
| 164 | |
| 165 | // dryRunBuildStateHost and dryRunBuildStateBuild are the build-state needles |
| 166 | // (settings spec rev 11 §4.4): Box.Metrics.BuildStates names the hosts this |
| 167 | // box runs and the builds their build states name, so the same scope split |
| 168 | // applies. REQUIRED in status/boxes/*.json and BANNED in every other |
| 169 | // published object. |
| 170 | const ( |
| 171 | dryRunBuildStateHost = "build-state-host.internal" |
| 172 | dryRunBuildStateBuild = "v-dryrun-build-state" |
| 173 | ) |
| 174 | |
| 175 | var dryRunBuildStateNeedles = []string{dryRunBuildStateHost, dryRunBuildStateBuild} |
| 176 | |
| 177 | // dryRunUpdatesHost and dryRunUpdatesBuild are the update-decision needles |
| 178 | // (settings spec §4.3): Box.Metrics.Updates names the hosts this box runs and |
| 179 | // the builds root chose for them, so the same scope split applies. REQUIRED in |
| 180 | // status/boxes/*.json and BANNED in every other published object. |
| 181 | const ( |
| 182 | dryRunUpdatesHost = "updates-host.internal" |
| 183 | dryRunUpdatesBuild = "v-dryrun-updates" |
| 184 | ) |
| 185 | |
| 186 | var dryRunUpdatesNeedles = []string{dryRunUpdatesHost, dryRunUpdatesBuild} |
| 187 | |
| 188 | // ScanForLeaks reports which of banned's strings appear anywhere in j. It is |
| 189 | // the single implementation both DryRun's self-check and |
| 190 | // contract_test.go's scanForLeaks test helper run — the test package can't |
| 191 | // call an unexported function here, and duplicating the logic in both places |
| 192 | // would let them silently drift into checking different things, which is |
| 193 | // exactly the kind of gap a needle could slip through. |
| 194 | func ScanForLeaks(j []byte, banned []string) []string { |
| 195 | s := string(j) |
| 196 | var found []string |
| 197 | for _, b := range banned { |
| 198 | if strings.Contains(s, b) { |
| 199 | found = append(found, b) |
| 200 | } |
| 201 | } |
| 202 | return found |
| 203 | } |
| 204 | |
| 205 | // dryRunFixtures builds a registry and probe set stuffed with needles: three |
| 206 | // un-opted hosts (one instance, one service, and one instance whose only job |
| 207 | // is carrying a Version — see dryRunRouteVersionNeedles) that must vanish |
| 208 | // from every public surface, one opted-in app that must survive by name, and |
| 209 | // mid-band fractions — 0.73 and 0.95 sit inside the "busy"/"at-capacity" |
| 210 | // bands per snapshot.go's 0.70/0.90 cuts, so banding actually gets exercised |
| 211 | // rather than trivially cleared at an edge. |
| 212 | func dryRunFixtures() ([]registry.Route, Probes) { |
| 213 | routes := []registry.Route{ |
| 214 | { |
| 215 | Host: "prod-secret.example.com", Kind: "instance", App: "leakyapp", |
| 216 | Status: "ready", Socket: "/run/carlos/leakyapp.sock", |
| 217 | }, |
| 218 | { |
| 219 | Host: "demoapp.internal", Kind: "instance", App: dryRunOptInApp, |
| 220 | Status: "ready", StatusNamed: true, Channel: "stable", Version: "abc1234", |
| 221 | }, |
| 222 | { |
| 223 | Host: "svc-secret.example.com", Kind: "service", App: "svc/internal", |
| 224 | Status: "ready", |
| 225 | }, |
| 226 | { |
| 227 | // Un-opted, so its host must not surface via the apps feed either |
| 228 | // — the ONLY reason this route exists is to give CollectBox a |
| 229 | // Version to fold into Box.Metrics.RouteVersions (see the needle |
| 230 | // pair's own doc comment above). |
| 231 | Host: dryRunRouteVersionHost, Kind: "instance", App: "stagedapp", |
| 232 | Status: "ready", Version: dryRunRouteVersionVersion, |
| 233 | }, |
| 234 | { |
| 235 | // Un-opted, so its host must not surface via the apps feed either |
| 236 | // — the ONLY reason this route exists is to give CollectBox a |
| 237 | // ServingVersion to fold into Box.Metrics.RouteServing (see the |
| 238 | // needle pair's own doc comment above). |
| 239 | Host: dryRunRouteServingHost, Kind: "sidecar", App: "servingapp", |
| 240 | Status: "ready", ServingVersion: dryRunRouteServingServing, |
| 241 | }, |
| 242 | } |
| 243 | |
| 244 | memFrac, diskFrac := dryRunMemFrac, dryRunDiskFrac |
| 245 | lag := int64(120) |
| 246 | adopting := true // a known reading, so adoption_running publishes non-null and gets scanned |
| 247 | configPath := true // ditto for config_path_running (issue #44) |
| 248 | restartPath := true // ditto for restart_path_running (issue #113) |
| 249 | expiredCanaries := 2 // ditto for expired_canaries — a known count, not the unexercised nil path |
| 250 | |
| 251 | // Task 5's raw Metrics fixtures: distinctive values (see |
| 252 | // dryRunMetricsNeedles) that MUST land in the published box object and |
| 253 | // MUST NOT land anywhere else DryRun publishes — the positive half of |
| 254 | // proving Box.Metrics's private exception actually stays private. |
| 255 | memTotal, memAvail, diskFree := int64(4242424242), int64(4242424241), int64(4242424240) |
| 256 | load1 := 42.42 |
| 257 | |
| 258 | probes := Probes{ |
| 259 | MachineID: "ip-10-0-0-7", |
| 260 | Region: "us-east-1", |
| 261 | MemFrac: &memFrac, |
| 262 | DiskFrac: &diskFrac, |
| 263 | LagSecs: &lag, |
| 264 | AdoptRunning: &adopting, |
| 265 | ConfigPathRunning: &configPath, |
| 266 | RestartPathRunning: &restartPath, |
| 267 | ExpiredCanaries: &expiredCanaries, |
| 268 | // Every hibernating route reports "listening" so Awake/Asleep publish |
| 269 | // non-null — an unexercised null field is a field DryRun can't scan. |
| 270 | Listening: func(string) *bool { v := true; return &v }, |
| 271 | MemTotalBytes: &memTotal, |
| 272 | MemAvailBytes: &memAvail, |
| 273 | Load1: &load1, |
| 274 | DiskFreeBytes: &diskFree, |
| 275 | Release: "dryrun4242sha", |
| 276 | Tunnel: "reconnecting", |
| 277 | // One refused host (amadan #213), so the scope check below has |
| 278 | // something to require in the private block and ban elsewhere. |
| 279 | ConfigRefusals: []ConfigRefusal{ |
| 280 | {Host: dryRunConfigRefusalHost, Reason: "no_key", Since: time.Now().Add(-time.Hour), Last: time.Now(), Count: 2}, |
| 281 | }, |
| 282 | ConfigRefusalsTotal: 1, |
| 283 | // One rolled-back build (D1), for the same scope check. |
| 284 | ReleaseRefusals: []ReleaseRefusal{ |
| 285 | {Host: dryRunReleaseRefusalHost, Version: "v-dryrun", Reason: "exited: status=2", RefusedAt: time.Now(), Kind: "wrong-program"}, |
| 286 | }, |
| 287 | // One host held for a dev proof (D3), for the same scope check. |
| 288 | ProofWaits: []ProofWait{ |
| 289 | {Host: dryRunProofWaitHost, Version: "v-dryrun", SHA256: "dryrunproofsha", Prover: "dev", Reason: "no proof from dev", Since: time.Now()}, |
| 290 | }, |
| 291 | // One held instance and its failure record, for the rollout scope check. |
| 292 | Rollout: &RolloutReport{ |
| 293 | Hosts: map[string]HostRollout{ |
| 294 | dryRunRolloutHost: {Account: "dryrun", App: "app", Installed: "v-dryrun", Serving: "v-old", Good: "v-old", Awake: true, |
| 295 | Attempt: AttemptFailed, AttemptBuild: "v-dryrun", HeldBy: "v-dryrun", Reason: ReasonHealth}, |
| 296 | }, |
| 297 | Apps: map[string]AppRollout{"dryrun/app": {Queued: 1}}, |
| 298 | Failures: []RolloutFailure{ |
| 299 | {Account: "dryrun", App: "app", Build: "v-dryrun", Host: dryRunRolloutHost, Mode: "overlap", Reason: ReasonHealth, PromotedAt: time.Now(), At: time.Now()}, |
| 300 | }, |
| 301 | }, |
| 302 | // One host's build-state builds, for the build_states scope check. |
| 303 | BuildStates: map[string]BuildStatePins{ |
| 304 | dryRunBuildStateHost: {Account: "dryrun", App: "app", Builds: []string{dryRunBuildStateBuild}, Complete: true}, |
| 305 | }, |
| 306 | // One host's update decision, for the updates scope check. |
| 307 | UpdateSettings: UpdateSettingsProtocol, |
| 308 | Updates: map[string]HostUpdates{ |
| 309 | dryRunUpdatesHost: {Desired: dryRunUpdatesBuild, Reason: "manual", Completed: 1}, |
| 310 | }, |
| 311 | // The probe table, keyed by a route host and carrying a backend |
| 312 | // address — see dryRunUpstreamNeedles. One up and one down upstream, |
| 313 | // so the down one's reason string is exercised too. |
| 314 | Upstreams: map[string][]UpstreamState{ |
| 315 | dryRunUpstreamHost: { |
| 316 | {Addr: dryRunUpstreamAddr, Up: true, Since: time.Now().Add(-time.Hour)}, |
| 317 | {Addr: "10.77.77.8:7717", Up: false, Since: time.Now().Add(-time.Minute), Fails: 3, LastError: "dial: connection refused"}, |
| 318 | }, |
| 319 | }, |
| 320 | } |
| 321 | return routes, probes |
| 322 | } |
| 323 | |
| 324 | // appPublished reports whether name appears in the fleet's opted-in apps — |
| 325 | // used to assert dryRunOptInApp made it through, the positive half of the |
| 326 | // privacy contract that a pure leak-scan (which only ever proves absence) |
| 327 | // can't check on its own. |
| 328 | func appPublished(f *Fleet, name string) bool { |
| 329 | for _, a := range f.Apps { |
| 330 | if a.Name == name { |
| 331 | return true |
| 332 | } |
| 333 | } |
| 334 | return false |
| 335 | } |
| 336 | |
| 337 | // bandFieldKeys are the JSON key names that ever carry a band word (see |
| 338 | // snapshot.go's loadWords/capacityWords): Box's "load" and "capacity" fields |
| 339 | // (the latter a bare string), each boxes[] element's "load", and Fleet's |
| 340 | // nested "capacity.level". Keyed by field name rather than a fixed JSON |
| 341 | // path so the same check works unmodified against Box's shape, Fleet's |
| 342 | // shape, and any future struct that reuses these field names. |
| 343 | var bandFieldKeys = map[string]bool{"load": true, "capacity": true, "level": true} |
| 344 | |
| 345 | // scanBandFieldsForDigits walks decoded JSON looking for band-word fields |
| 346 | // and flags any whose value carries a digit. Per the plan's privacy rule, a |
| 347 | // band field is a WORD (comfortable/busy/critical, plenty/getting-full/at- |
| 348 | // capacity), never a number — a digit surviving into one of these fields |
| 349 | // means a raw fraction escaped banding somewhere between CollectBox and the |
| 350 | // published bytes, independent of whether it happens to match one of the |
| 351 | // exact fixture fractions in dryRunNeedles. |
| 352 | func scanBandFieldsForDigits(v interface{}) []string { |
| 353 | var bad []string |
| 354 | var walk func(interface{}) |
| 355 | walk = func(v interface{}) { |
| 356 | switch t := v.(type) { |
| 357 | case map[string]interface{}: |
| 358 | for k, val := range t { |
| 359 | if bandFieldKeys[k] { |
| 360 | if s, ok := val.(string); ok && strings.ContainsAny(s, "0123456789") { |
| 361 | bad = append(bad, fmt.Sprintf("%s=%q", k, s)) |
| 362 | } |
| 363 | } |
| 364 | walk(val) |
| 365 | } |
| 366 | case []interface{}: |
| 367 | for _, elem := range t { |
| 368 | walk(elem) |
| 369 | } |
| 370 | } |
| 371 | } |
| 372 | walk(v) |
| 373 | return bad |
| 374 | } |
| 375 | |
| 376 | func scanForNumericLeaks(v interface{}, banned []float64) []string { |
| 377 | seen := map[float64]bool{} |
| 378 | var walk func(interface{}) |
| 379 | walk = func(v interface{}) { |
| 380 | switch t := v.(type) { |
| 381 | case map[string]interface{}: |
| 382 | for _, value := range t { |
| 383 | walk(value) |
| 384 | } |
| 385 | case []interface{}: |
| 386 | for _, value := range t { |
| 387 | walk(value) |
| 388 | } |
| 389 | case float64: |
| 390 | for _, needle := range banned { |
| 391 | if t == needle { |
| 392 | seen[needle] = true |
| 393 | } |
| 394 | } |
| 395 | } |
| 396 | } |
| 397 | walk(v) |
| 398 | var found []string |
| 399 | for _, needle := range banned { |
| 400 | if seen[needle] { |
| 401 | found = append(found, fmt.Sprintf("%g", needle)) |
| 402 | } |
| 403 | } |
| 404 | return found |
| 405 | } |
| 406 | |
| 407 | // DryRun runs the real publish pipeline into a DirStore rooted at dir, then |
| 408 | // reads back everything it just published and fails on any leaked needle — |
| 409 | // see the package doc comment above for why. Progress (each published key) |
| 410 | // is written to out as it happens, ending with the literal "dry-run: no |
| 411 | // leaks" attestation the plan's Step-1 test greps for; dir is expected to be |
| 412 | // a scratch directory (t.TempDir() in tests, an operator-chosen throwaway |
| 413 | // path on the command line) since DryRun writes real objects into it. |
| 414 | func DryRun(dir string, out io.Writer) error { |
| 415 | ctx := context.Background() |
| 416 | s := release.NewDirStore(dir) |
| 417 | |
| 418 | routes, probes := dryRunFixtures() |
| 419 | now := time.Now() |
| 420 | |
| 421 | box := CollectBox(routes, probes, now) |
| 422 | if err := WriteBox(ctx, s, box); err != nil { |
| 423 | return fmt.Errorf("dry run: write box: %w", err) |
| 424 | } |
| 425 | |
| 426 | fleet, err := BuildFleet(ctx, s, routes, now, 5*time.Minute) |
| 427 | if err != nil { |
| 428 | return fmt.Errorf("dry run: build fleet: %w", err) |
| 429 | } |
| 430 | if err := PublishFleet(ctx, s, fleet); err != nil { |
| 431 | return fmt.Errorf("dry run: publish fleet: %w", err) |
| 432 | } |
| 433 | |
| 434 | // The positive check: opt-in is the one exception a leak-scan can't |
| 435 | // prove by itself (absence of a leak says nothing about whether the |
| 436 | // thing that SHOULD be there actually is). |
| 437 | if !appPublished(fleet, dryRunOptInApp) { |
| 438 | return fmt.Errorf("dry run: opted-in app %q must appear in fleet.apps — opt-in path is broken", dryRunOptInApp) |
| 439 | } |
| 440 | |
| 441 | keys, err := s.List(ctx, "status/") |
| 442 | if err != nil { |
| 443 | return fmt.Errorf("dry run: list published objects: %w", err) |
| 444 | } |
| 445 | if len(keys) == 0 { |
| 446 | return fmt.Errorf("dry run: nothing published under status/ — publish path is broken") |
| 447 | } |
| 448 | |
| 449 | // boxKeySeen tracks whether the scan actually reached a status/boxes/ |
| 450 | // object carrying the Metrics fixtures — the positive half of the |
| 451 | // Metrics scope check below, the same reason appPublished exists for |
| 452 | // the opt-in app: a leak-scan that finds nothing proves nothing about |
| 453 | // whether the thing that SHOULD be there actually is. |
| 454 | boxKeySeen := false |
| 455 | // routeVersionKeySeen is boxKeySeen's sibling for Task 8's own scoped |
| 456 | // pair (dryRunRouteVersionNeedles): whether the scan actually reached a |
| 457 | // status/boxes/ object carrying the route_versions fixture, so this |
| 458 | // self-check can't pass by simply never exercising RouteVersions. |
| 459 | routeVersionKeySeen := false |
| 460 | // routeServingKeySeen is the same positive half for the serving version |
| 461 | // map (dryRunRouteServingNeedles) — Box.Metrics.RouteServing, plan 5 |
| 462 | // Task 2. |
| 463 | routeServingKeySeen := false |
| 464 | // upstreamKeySeen is the same positive half for the probe table |
| 465 | // (dryRunUpstreamNeedles) — Box.Metrics.Upstreams, Task 6. |
| 466 | upstreamKeySeen := false |
| 467 | // configRefusalKeySeen is the same positive half for the config guard's |
| 468 | // refusal list (dryRunConfigRefusalNeedles), amadan #213. |
| 469 | configRefusalKeySeen := false |
| 470 | // releaseRefusalKeySeen is the same positive half for D1's release |
| 471 | // refusal list (dryRunReleaseRefusalNeedles). |
| 472 | releaseRefusalKeySeen := false |
| 473 | // proofWaitKeySeen is the same positive half for D3's proof wait list |
| 474 | // (dryRunProofWaitNeedles). |
| 475 | proofWaitKeySeen := false |
| 476 | // rolloutKeySeen is the same positive half for the rollout report |
| 477 | // (dryRunRolloutNeedles). |
| 478 | rolloutKeySeen := false |
| 479 | // buildStateKeySeen is the same positive half for the build-state report |
| 480 | // (dryRunBuildStateNeedles). |
| 481 | buildStateKeySeen := false |
| 482 | // updatesKeySeen is the same positive half for the update decisions |
| 483 | // (dryRunUpdatesNeedles). |
| 484 | updatesKeySeen := false |
| 485 | |
| 486 | for _, key := range keys { |
| 487 | body, _, err := s.Get(ctx, key) |
| 488 | if err != nil { |
| 489 | return fmt.Errorf("dry run: read back %s: %w", key, err) |
| 490 | } |
| 491 | fmt.Fprintf(out, "published %s (%d bytes)\n", key, len(body)) |
| 492 | |
| 493 | if leaks := ScanForLeaks(body, dryRunNeedles); len(leaks) > 0 { |
| 494 | return fmt.Errorf("dry run: %s leaked %v", key, leaks) |
| 495 | } |
| 496 | |
| 497 | // dryRunMetricsNeedles are scoped: status/boxes/*.json is Box.Metrics's |
| 498 | // one privileged, private home (see snapshot.go's package doc), so |
| 499 | // the raw fixture values are EXPECTED there and must NOT be scanned |
| 500 | // out — that isn't a leak, it's the field doing its job. Every other |
| 501 | // key DryRun publishes (fleet.json, index.html, status.css) is |
| 502 | // exactly what BuildFleet/PublishFleet expose beyond the bucket, so |
| 503 | // those raw values must never appear there. |
| 504 | if strings.HasPrefix(key, "status/boxes/") { |
| 505 | if leaks := ScanForLeaks(body, dryRunMetricsNeedles); len(leaks) == 0 { |
| 506 | // None of the fixtures made it in at all — Metrics isn't |
| 507 | // being attached, which means this check isn't exercising |
| 508 | // anything (a false "no leak"). |
| 509 | return fmt.Errorf("dry run: %s: expected raw Metrics fixtures %v, found none — Box.Metrics not attached?", key, dryRunMetricsNeedles) |
| 510 | } |
| 511 | boxKeySeen = true |
| 512 | } else if leaks := ScanForLeaks(body, dryRunMetricsNeedles); len(leaks) > 0 { |
| 513 | return fmt.Errorf("dry run: %s leaked raw Metrics fixture %v — Box.Metrics must never reach the public feed", key, leaks) |
| 514 | } |
| 515 | |
| 516 | // dryRunRouteVersionNeedles gets the identical scope split as |
| 517 | // dryRunMetricsNeedles just above, for the identical reason: |
| 518 | // RouteVersions lives in Box.Metrics too, so status/boxes/*.json is |
| 519 | // its one privileged home and every other published key must never |
| 520 | // carry it. |
| 521 | if strings.HasPrefix(key, "status/boxes/") { |
| 522 | if leaks := ScanForLeaks(body, dryRunRouteVersionNeedles); len(leaks) == 0 { |
| 523 | return fmt.Errorf("dry run: %s: expected route_versions fixtures %v, found none — RouteVersions not attached?", key, dryRunRouteVersionNeedles) |
| 524 | } |
| 525 | routeVersionKeySeen = true |
| 526 | } else if leaks := ScanForLeaks(body, dryRunRouteVersionNeedles); len(leaks) > 0 { |
| 527 | return fmt.Errorf("dry run: %s leaked a route_versions fixture %v — RouteVersions must never reach the public feed", key, leaks) |
| 528 | } |
| 529 | |
| 530 | // dryRunRouteServingNeedles: same scope split as RouteVersions. |
| 531 | // RouteServing (plan 5's ServingVersion) is also a Box.Metrics field |
| 532 | // with hostnames, so status/boxes/*.json is its one privileged home |
| 533 | // and every other published key must never carry it. |
| 534 | if strings.HasPrefix(key, "status/boxes/") { |
| 535 | if leaks := ScanForLeaks(body, dryRunRouteServingNeedles); len(leaks) == 0 { |
| 536 | return fmt.Errorf("dry run: %s: expected route_serving fixtures %v, found none — RouteServing not attached?", key, dryRunRouteServingNeedles) |
| 537 | } |
| 538 | routeServingKeySeen = true |
| 539 | } else if leaks := ScanForLeaks(body, dryRunRouteServingNeedles); len(leaks) > 0 { |
| 540 | return fmt.Errorf("dry run: %s leaked a route_serving fixture %v — RouteServing must never reach the public feed", key, leaks) |
| 541 | } |
| 542 | |
| 543 | // dryRunUpstreamNeedles: the third scope split, on the same pattern. |
| 544 | // Upstreams is Box.Metrics's other hostname-keyed field and the only |
| 545 | // one carrying backend ADDRESSES, so status/boxes/*.json is its one |
| 546 | // privileged home and fleet.json/index.html must never show either |
| 547 | // half of the pair. |
| 548 | if strings.HasPrefix(key, "status/boxes/") { |
| 549 | if leaks := ScanForLeaks(body, dryRunUpstreamNeedles); len(leaks) == 0 { |
| 550 | return fmt.Errorf("dry run: %s: expected upstreams fixtures %v, found none — Upstreams not attached?", key, dryRunUpstreamNeedles) |
| 551 | } |
| 552 | upstreamKeySeen = true |
| 553 | } else if leaks := ScanForLeaks(body, dryRunUpstreamNeedles); len(leaks) > 0 { |
| 554 | return fmt.Errorf("dry run: %s leaked an upstreams fixture %v — Upstreams must never reach the public feed", key, leaks) |
| 555 | } |
| 556 | |
| 557 | // dryRunConfigRefusalNeedles: the fourth scope split. The refusal list |
| 558 | // names real hosts, so status/boxes/*.json is its one private home. |
| 559 | if strings.HasPrefix(key, "status/boxes/") { |
| 560 | if leaks := ScanForLeaks(body, dryRunConfigRefusalNeedles); len(leaks) == 0 { |
| 561 | return fmt.Errorf("dry run: %s: expected config_refusals fixtures %v, found none — ConfigRefusals not attached?", key, dryRunConfigRefusalNeedles) |
| 562 | } |
| 563 | configRefusalKeySeen = true |
| 564 | } else if leaks := ScanForLeaks(body, dryRunConfigRefusalNeedles); len(leaks) > 0 { |
| 565 | return fmt.Errorf("dry run: %s leaked a config_refusals fixture %v — ConfigRefusals must never reach the public feed", key, leaks) |
| 566 | } |
| 567 | |
| 568 | // dryRunReleaseRefusalNeedles: the fifth scope split, on the same |
| 569 | // pattern. The rolled-back builds name real hosts. |
| 570 | if strings.HasPrefix(key, "status/boxes/") { |
| 571 | if leaks := ScanForLeaks(body, dryRunReleaseRefusalNeedles); len(leaks) == 0 { |
| 572 | return fmt.Errorf("dry run: %s: expected release_refusals fixtures %v, found none — ReleaseRefusals not attached?", key, dryRunReleaseRefusalNeedles) |
| 573 | } |
| 574 | releaseRefusalKeySeen = true |
| 575 | } else if leaks := ScanForLeaks(body, dryRunReleaseRefusalNeedles); len(leaks) > 0 { |
| 576 | return fmt.Errorf("dry run: %s leaked a release_refusals fixture %v — ReleaseRefusals must never reach the public feed", key, leaks) |
| 577 | } |
| 578 | |
| 579 | // dryRunProofWaitNeedles: the sixth scope split, on the same |
| 580 | // pattern. The hosts held for a dev proof are real route names. |
| 581 | if strings.HasPrefix(key, "status/boxes/") { |
| 582 | if leaks := ScanForLeaks(body, dryRunProofWaitNeedles); len(leaks) == 0 { |
| 583 | return fmt.Errorf("dry run: %s: expected proof_waits fixtures %v, found none — ProofWaits not attached?", key, dryRunProofWaitNeedles) |
| 584 | } |
| 585 | proofWaitKeySeen = true |
| 586 | } else if leaks := ScanForLeaks(body, dryRunProofWaitNeedles); len(leaks) > 0 { |
| 587 | return fmt.Errorf("dry run: %s leaked a proof_waits fixture %v — ProofWaits must never reach the public feed", key, leaks) |
| 588 | } |
| 589 | |
| 590 | // dryRunRolloutNeedles: the seventh scope split, on the same |
| 591 | // pattern. The rollout report names the managed instances. |
| 592 | if strings.HasPrefix(key, "status/boxes/") { |
| 593 | if leaks := ScanForLeaks(body, dryRunRolloutNeedles); len(leaks) == 0 { |
| 594 | return fmt.Errorf("dry run: %s: expected rollout fixtures %v, found none — Rollout not attached?", key, dryRunRolloutNeedles) |
| 595 | } |
| 596 | rolloutKeySeen = true |
| 597 | } else if leaks := ScanForLeaks(body, dryRunRolloutNeedles); len(leaks) > 0 { |
| 598 | return fmt.Errorf("dry run: %s leaked a rollout fixture %v — Rollout must never reach the public feed", key, leaks) |
| 599 | } |
| 600 | |
| 601 | // dryRunBuildStateNeedles: the eighth scope split, on the same |
| 602 | // pattern. The build-state report names hosts and builds. |
| 603 | if strings.HasPrefix(key, "status/boxes/") { |
| 604 | if leaks := ScanForLeaks(body, dryRunBuildStateNeedles); len(leaks) == 0 { |
| 605 | return fmt.Errorf("dry run: %s: expected build_states fixtures %v, found none. BuildStates not attached?", key, dryRunBuildStateNeedles) |
| 606 | } |
| 607 | buildStateKeySeen = true |
| 608 | } else if leaks := ScanForLeaks(body, dryRunBuildStateNeedles); len(leaks) > 0 { |
| 609 | return fmt.Errorf("dry run: %s leaked a build_states fixture %v. BuildStates must never reach the public feed", key, leaks) |
| 610 | } |
| 611 | |
| 612 | // dryRunUpdatesNeedles: the ninth scope split, on the same pattern. |
| 613 | // The update decisions name hosts and builds. |
| 614 | if strings.HasPrefix(key, "status/boxes/") { |
| 615 | if leaks := ScanForLeaks(body, dryRunUpdatesNeedles); len(leaks) == 0 { |
| 616 | return fmt.Errorf("dry run: %s: expected updates fixtures %v, found none. Updates not attached?", key, dryRunUpdatesNeedles) |
| 617 | } |
| 618 | updatesKeySeen = true |
| 619 | } else if leaks := ScanForLeaks(body, dryRunUpdatesNeedles); len(leaks) > 0 { |
| 620 | return fmt.Errorf("dry run: %s leaked an updates fixture %v. Updates must never reach the public feed", key, leaks) |
| 621 | } |
| 622 | |
| 623 | // Page assets (index.html, status.css) aren't JSON and simply fail |
| 624 | // to decode here — silently skipped, since the band-digit check |
| 625 | // only means anything for objects that actually carry Load/Capacity |
| 626 | // fields. |
| 627 | var decoded interface{} |
| 628 | if err := json.Unmarshal(body, &decoded); err == nil { |
| 629 | if leaks := scanForNumericLeaks(decoded, dryRunFractionNeedles); len(leaks) > 0 { |
| 630 | return fmt.Errorf("dry run: %s leaked %v", key, leaks) |
| 631 | } |
| 632 | if bad := scanBandFieldsForDigits(decoded); len(bad) > 0 { |
| 633 | return fmt.Errorf("dry run: %s: band field carries a digit: %v", key, bad) |
| 634 | } |
| 635 | } |
| 636 | } |
| 637 | |
| 638 | if !boxKeySeen { |
| 639 | return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — Metrics scope check never ran") |
| 640 | } |
| 641 | if !routeVersionKeySeen { |
| 642 | return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — route_versions scope check never ran") |
| 643 | } |
| 644 | if !routeServingKeySeen { |
| 645 | return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — route_serving scope check never ran") |
| 646 | } |
| 647 | if !upstreamKeySeen { |
| 648 | return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — upstreams scope check never ran") |
| 649 | } |
| 650 | if !configRefusalKeySeen { |
| 651 | return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — config_refusals scope check never ran") |
| 652 | } |
| 653 | if !releaseRefusalKeySeen { |
| 654 | return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — release_refusals scope check never ran") |
| 655 | } |
| 656 | if !proofWaitKeySeen { |
| 657 | return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — proof_waits scope check never ran") |
| 658 | } |
| 659 | if !rolloutKeySeen { |
| 660 | return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — rollout scope check never ran") |
| 661 | } |
| 662 | if !buildStateKeySeen { |
| 663 | return fmt.Errorf("dry run: no status/boxes/*.json object was scanned. The build_states scope check never ran") |
| 664 | } |
| 665 | |
| 666 | if !updatesKeySeen { |
| 667 | return fmt.Errorf("dry run: no status/boxes/*.json object was scanned. The updates scope check never ran") |
| 668 | } |
| 669 | |
| 670 | fmt.Fprintln(out, "dry-run: no leaks") |
| 671 | return nil |
| 672 | } |
| 673 | |