| 1 | package hostops |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "regexp" |
| 6 | "strings" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/controlname" |
| 10 | "github.com/carlosframework/platform/internal/economics" |
| 11 | "github.com/carlosframework/platform/internal/hostactors" |
| 12 | "github.com/carlosframework/platform/internal/infraview" |
| 13 | "github.com/carlosframework/platform/internal/moves" |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | "github.com/carlosframework/platform/internal/telemetry" |
| 16 | "github.com/carlosframework/platform/internal/usage" |
| 17 | ) |
| 18 | |
| 19 | // classifyDeploymentFamily reviews the deployment- and account-level |
| 20 | // families the route/worker census found writing under HostClaim. Each is an |
| 21 | // exact key shape owned by one writer, and its body must be what that writer |
| 22 | // serializes there: decoded strictly (no unknown field, no trailing value) |
| 23 | // into the writer's own type and bound to the key it is written at. None of |
| 24 | // these writers ever deletes, so every family is Put-only and a delete stays |
| 25 | // unclassified. ok=false means the key is none of these shapes; a lookalike |
| 26 | // under the same roots (an extra segment, an app-shaped subkey, another |
| 27 | // suffix) falls through to ErrUnclassified. |
| 28 | func classifyDeploymentFamily(w *ControlWriter, t MutationTarget) (Classification, bool, error) { |
| 29 | fam, check := deploymentFamily(t.Key) |
| 30 | if check == nil { |
| 31 | return Classification{}, false, nil |
| 32 | } |
| 33 | if t.Method != "Put" { |
| 34 | return Classification{}, true, fmt.Errorf("%w: %s refuses %s", ErrUnclassified, t.Key, t.Method) |
| 35 | } |
| 36 | reason, err := check(w, t) |
| 37 | if err != nil { |
| 38 | return Classification{}, true, err |
| 39 | } |
| 40 | return Classification{Family: fam, NonApp: reason}, true, nil |
| 41 | } |
| 42 | |
| 43 | type familyCheck func(*ControlWriter, MutationTarget) (string, error) |
| 44 | |
| 45 | // deploymentFamily matches the exact key shapes. It parses; the check |
| 46 | // validates the body against the key. |
| 47 | func deploymentFamily(key string) (string, familyCheck) { |
| 48 | p := strings.Split(key, "/") |
| 49 | switch { |
| 50 | case key == economics.ActiveKey: |
| 51 | return "economics-active", checkEconomicsActive |
| 52 | case len(p) == 3 && p[0] == "economics" && p[1] == "bills": |
| 53 | if month, ok := jsonName(p[2]); ok && validMonth(month) { |
| 54 | return "economics-bill", func(_ *ControlWriter, t MutationTarget) (string, error) { return checkBill(month, t) } |
| 55 | } |
| 56 | case len(p) == 3 && p[0] == "economics" && p[1] == "scenarios": |
| 57 | if name, ok := jsonName(p[2]); ok && economics.ValidScenarioName(name) { |
| 58 | return "economics-scenario", func(_ *ControlWriter, t MutationTarget) (string, error) { return checkScenario(name, t) } |
| 59 | } |
| 60 | case key == infraview.InventoryKey: |
| 61 | return "infra-inventory", checkInventory |
| 62 | case len(p) == 3 && p[0] == "usage" && (p[1] == "census" || p[1] == "storage"): |
| 63 | if date, ok := jsonName(p[2]); ok && validDate(date) { |
| 64 | if p[1] == "census" { |
| 65 | return "usage-census", func(_ *ControlWriter, t MutationTarget) (string, error) { return checkCensusDay(date, t) } |
| 66 | } |
| 67 | return "usage-storage", func(_ *ControlWriter, t MutationTarget) (string, error) { return checkStorageDay(date, t) } |
| 68 | } |
| 69 | case len(p) == 3 && p[0] == "usage" && p[1] == "scan": |
| 70 | if date, ok := jsonName(p[2]); ok && validDate(date) { |
| 71 | return "usage-scan", func(_ *ControlWriter, t MutationTarget) (string, error) { return checkScanDay(date, t) } |
| 72 | } |
| 73 | case len(p) == 4 && p[0] == "usage" && p[1] == "accounts" && controlname.ValidAccount(p[2]): |
| 74 | if month, ok := jsonName(p[3]); ok && validMonth(month) { |
| 75 | acct := p[2] |
| 76 | return "usage-statement", func(_ *ControlWriter, t MutationTarget) (string, error) { return checkStatement(acct, month, t) } |
| 77 | } |
| 78 | case len(p) == 3 && p[0] == "status" && p[1] == "consoles": |
| 79 | if id, ok := jsonName(p[2]); ok && consoleSegment(id) { |
| 80 | return "console-report", func(_ *ControlWriter, t MutationTarget) (string, error) { return checkConsoleReport(id, t) } |
| 81 | } |
| 82 | case key == moves.ConsoleKey: |
| 83 | return "moves-console", checkMovesConsole |
| 84 | case len(p) == 3 && p[0] == "accounts" && p[2] == "telemetry.json" && controlname.ValidAccount(p[1]): |
| 85 | return "account-telemetry", checkAccountTelemetry |
| 86 | case key == release.SystemKey(release.AntivirusName): |
| 87 | return "system-antivirus", checkAntivirus |
| 88 | case len(p) == 4 && p[0] == "bootstrap" && p[1] == "router": |
| 89 | version, platform := p[2], strings.TrimPrefix(p[3], "carlos-") |
| 90 | if release.ValidVersion(version) && version != "dev" && systemPlatformRe.MatchString(platform) && key == release.SystemArtifactKey(version, platform) { |
| 91 | return "system-artifact", checkSystemArtifact |
| 92 | } |
| 93 | case len(p) == 4 && p[0] == "control" && p[1] == "system" && p[2] == release.SystemComponentRouter: |
| 94 | if channel, ok := jsonName(p[3]); ok && (channel == release.RungCanary || channel == release.RungStable) { |
| 95 | return "system-pointer", func(w *ControlWriter, t MutationTarget) (string, error) { return checkSystemPointer(w, channel, t) } |
| 96 | } |
| 97 | } |
| 98 | return "", nil |
| 99 | } |
| 100 | |
| 101 | // systemPlatformRe mirrors release's unexported <goos>-<goarch> shape. |
| 102 | var systemPlatformRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`) |
| 103 | |
| 104 | func jsonName(file string) (string, bool) { |
| 105 | name, ok := strings.CutSuffix(file, ".json") |
| 106 | return name, ok && name != "" |
| 107 | } |
| 108 | |
| 109 | func validMonth(s string) bool { |
| 110 | at, err := time.Parse("2006-01", s) |
| 111 | return err == nil && at.Format("2006-01") == s |
| 112 | } |
| 113 | |
| 114 | func validDate(s string) bool { |
| 115 | at, err := time.Parse("2006-01-02", s) |
| 116 | return err == nil && at.Format("2006-01-02") == s |
| 117 | } |
| 118 | |
| 119 | // consoleSegment is hostactors' own segment rule for a console id, the one |
| 120 | // its census reader applies before reading status/consoles/<id>.json. |
| 121 | func consoleSegment(s string) bool { |
| 122 | return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00") |
| 123 | } |
| 124 | |
| 125 | func requireCreateOnly(t MutationTarget) error { |
| 126 | if t.ETag != "" { |
| 127 | return fmt.Errorf("%w: %s is create-only", ErrIdentity, t.Key) |
| 128 | } |
| 129 | return nil |
| 130 | } |
| 131 | |
| 132 | func checkEconomicsActive(_ *ControlWriter, t MutationTarget) (string, error) { |
| 133 | var a economics.Active |
| 134 | if err := strictJSON(t.After, &a); err != nil { |
| 135 | return "", err |
| 136 | } |
| 137 | if a.Schema != 1 || !economics.ValidScenarioName(a.Name) { |
| 138 | return "", ErrIdentity |
| 139 | } |
| 140 | return "operator economics: which pricing scenario the fleet dashboards load; holds no app record", nil |
| 141 | } |
| 142 | |
| 143 | func checkBill(month string, t MutationTarget) (string, error) { |
| 144 | var b economics.Bill |
| 145 | if err := strictJSON(t.After, &b); err != nil { |
| 146 | return "", err |
| 147 | } |
| 148 | if b.Schema != 1 || b.Month != month || (b.Source != economics.SourceManual && b.Source != economics.SourceCostExplorer) { |
| 149 | return "", ErrIdentity |
| 150 | } |
| 151 | return "operator economics: the deployment's own cloud bill for one month, by service; holds no app record", nil |
| 152 | } |
| 153 | |
| 154 | func checkScenario(name string, t MutationTarget) (string, error) { |
| 155 | var sc economics.Scenario |
| 156 | if err := strictJSON(t.After, &sc); err != nil { |
| 157 | return "", err |
| 158 | } |
| 159 | if sc.Name != name || sc.Validate() != nil { |
| 160 | return "", ErrIdentity |
| 161 | } |
| 162 | return "operator economics: a named pricing knob-set for the dashboards; holds no app record", nil |
| 163 | } |
| 164 | |
| 165 | func checkInventory(_ *ControlWriter, t MutationTarget) (string, error) { |
| 166 | var inv infraview.Inventory |
| 167 | if err := strictJSON(t.After, &inv); err != nil { |
| 168 | return "", err |
| 169 | } |
| 170 | return "operator infrastructure inventory snapshot of the deployment's cloud estate; holds no app record", nil |
| 171 | } |
| 172 | |
| 173 | // Census and storage days are create-only history: a day once written is |
| 174 | // never rewritten, by its writer or anything riding its key. |
| 175 | func checkCensusDay(date string, t MutationTarget) (string, error) { |
| 176 | if err := requireCreateOnly(t); err != nil { |
| 177 | return "", err |
| 178 | } |
| 179 | var d usage.CensusDay |
| 180 | if err := strictJSON(t.After, &d); err != nil { |
| 181 | return "", err |
| 182 | } |
| 183 | if d.V != 1 || d.Date != date { |
| 184 | return "", ErrIdentity |
| 185 | } |
| 186 | return "usage metering: one day's per-account app and route counts, create-only; holds no app record", nil |
| 187 | } |
| 188 | |
| 189 | func checkStorageDay(date string, t MutationTarget) (string, error) { |
| 190 | if err := requireCreateOnly(t); err != nil { |
| 191 | return "", err |
| 192 | } |
| 193 | var d usage.StorageDay |
| 194 | if err := strictJSON(t.After, &d); err != nil { |
| 195 | return "", err |
| 196 | } |
| 197 | if d.V != 1 || d.Date != date { |
| 198 | return "", ErrIdentity |
| 199 | } |
| 200 | return "usage metering: one day's measured storage sizes, create-only; a measurement of apps, not app state", nil |
| 201 | } |
| 202 | |
| 203 | // checkScanDay admits one day's on-demand scan counts. NOT create-only, unlike |
| 204 | // census and storage days: the drain folds every tick's deltas into the day |
| 205 | // with a compare-and-swap (usage.UpsertScanDay), so the same key is rewritten |
| 206 | // all day long. The body is still bound to its key and schema. |
| 207 | func checkScanDay(date string, t MutationTarget) (string, error) { |
| 208 | var d usage.ScanDay |
| 209 | if err := strictJSON(t.After, &d); err != nil { |
| 210 | return "", err |
| 211 | } |
| 212 | if d.V != 1 || d.Date != date { |
| 213 | return "", ErrIdentity |
| 214 | } |
| 215 | return "usage metering: one day's per-account on-demand scan counts, folded per drain tick; a measurement of apps, not app state", nil |
| 216 | } |
| 217 | |
| 218 | func checkStatement(acct, month string, t MutationTarget) (string, error) { |
| 219 | var st usage.Statement |
| 220 | if err := strictJSON(t.After, &st); err != nil { |
| 221 | return "", err |
| 222 | } |
| 223 | if st.V != 1 || st.Account != acct || st.Month != month { |
| 224 | return "", ErrIdentity |
| 225 | } |
| 226 | return "usage metering: one account's month-to-date meter totals for billing; holds no app record", nil |
| 227 | } |
| 228 | |
| 229 | func checkConsoleReport(id string, t MutationTarget) (string, error) { |
| 230 | var r hostactors.Report |
| 231 | if err := strictJSON(t.After, &r); err != nil { |
| 232 | return "", err |
| 233 | } |
| 234 | if r.Component != "console" || r.Actor != id || r.InstanceID != id { |
| 235 | return "", ErrIdentity |
| 236 | } |
| 237 | return "console process report keyed by console id, the console analogue of status/boxes/; holds no app record", nil |
| 238 | } |
| 239 | |
| 240 | func checkMovesConsole(_ *ControlWriter, t MutationTarget) (string, error) { |
| 241 | var rec moves.ConsoleRecord |
| 242 | if err := strictJSON(t.After, &rec); err != nil { |
| 243 | return "", err |
| 244 | } |
| 245 | if rec.V != 1 || rec.Protocol < 1 { |
| 246 | return "", ErrIdentity |
| 247 | } |
| 248 | return "the console's moves protocol and secrets-key fingerprint; deployment-wide, holds no app record", nil |
| 249 | } |
| 250 | |
| 251 | // checkAccountTelemetry admits the account-wide default only: the analytics |
| 252 | // and errors halves with their closed vocabularies. Apps inherit it, but an |
| 253 | // app's own setting lives at apps/<sqid>/<app>/telemetry.json, which is app |
| 254 | // scope; this object can carry nothing app-keyed. |
| 255 | func checkAccountTelemetry(_ *ControlWriter, t MutationTarget) (string, error) { |
| 256 | var st telemetry.Settings |
| 257 | if err := strictJSON(t.After, &st); err != nil { |
| 258 | return "", err |
| 259 | } |
| 260 | if a := st.Analytics; a != nil { |
| 261 | if a.Count != "" && a.Count != telemetry.CountEdge && a.Count != telemetry.CountOff { |
| 262 | return "", ErrIdentity |
| 263 | } |
| 264 | if a.Door != "" && a.Door != telemetry.DoorOn && a.Door != telemetry.DoorOff { |
| 265 | return "", ErrIdentity |
| 266 | } |
| 267 | } |
| 268 | if e := st.Errors; e != nil && e.LogLevel != "" && !telemetry.ValidLogLevel(e.LogLevel) { |
| 269 | return "", ErrIdentity |
| 270 | } |
| 271 | return "account-wide telemetry default (analytics and error level); names no app, and an app's own setting is app scope", nil |
| 272 | } |
| 273 | |
| 274 | func checkAntivirus(_ *ControlWriter, t MutationTarget) (string, error) { |
| 275 | var av release.AntivirusSettings |
| 276 | if err := strictJSON(t.After, &av); err != nil { |
| 277 | return "", err |
| 278 | } |
| 279 | if av.Schema != release.AntivirusSchema { |
| 280 | return "", ErrIdentity |
| 281 | } |
| 282 | return "deployment-wide object scanning switch; holds no app record", nil |
| 283 | } |
| 284 | |
| 285 | // checkSystemArtifact admits a mirrored platform binary. Versions are |
| 286 | // immutable, so the write is create-only; nothing adopts the bytes until a |
| 287 | // root-signed system pointer names their sha256. |
| 288 | func checkSystemArtifact(_ *ControlWriter, t MutationTarget) (string, error) { |
| 289 | if err := requireCreateOnly(t); err != nil { |
| 290 | return "", err |
| 291 | } |
| 292 | if len(t.After) == 0 { |
| 293 | return "", ErrIdentity |
| 294 | } |
| 295 | return "mirrored platform binary for one version and platform, create-only; not app code", nil |
| 296 | } |
| 297 | |
| 298 | // checkSystemPointer admits the platform's own channel pointer only as the |
| 299 | // deployment root signed it, for exactly the component and channel of its |
| 300 | // key. A promotion or rollback must also pass release's full pointer |
| 301 | // validation; a cancellation fence is the root-signed prior pointer. |
| 302 | func checkSystemPointer(w *ControlWriter, channel string, t MutationTarget) (string, error) { |
| 303 | if w == nil { |
| 304 | return "", ErrContract |
| 305 | } |
| 306 | var p release.SystemPointer |
| 307 | if release.VerifyRootEnvelope(w.pub, t.After, &p) != nil { |
| 308 | return "", ErrIdentity |
| 309 | } |
| 310 | if p.Schema != 1 || p.Component != release.SystemComponentRouter || p.Channel != channel { |
| 311 | return "", ErrIdentity |
| 312 | } |
| 313 | if p.CancellationID == "" { |
| 314 | pub := release.SystemPublication{Component: p.Component, Channel: channel, ID: p.ActivationID, After: t.After} |
| 315 | if _, err := pub.Verify(w.pub); err != nil { |
| 316 | return "", ErrIdentity |
| 317 | } |
| 318 | } |
| 319 | return "root-signed platform channel pointer; names a platform release, never an app's", nil |
| 320 | } |
| 321 | |