HIP-1230: Platform — The Container Plane
Abstract
platform is the per-org container platform: projects, applications, builds,
deploys, environments, releases, logs and verified custom domains, each
application reconciled as an operator Service CR into the caller's own
tenant-<org> Kubernetes namespace (apps/platform/platform.go:1-27). It is
implemented in hanzoai/cloud at apps/platform. This HIP states the target
surface — one address, /v1/platform — and carries two pieces previously
specified apart: the forge push endpoint (formerly HIP-1070) and the pipelines
board (formerly HIP-1071), both platform addresses because their
implementation is this package.
Motivation
The capability's routes grew at eight top-level addresses while its store,
its process and its name were one: a generated client offered a BuildsApi,
a RunnerApi and a PlatformApi for one subsystem, and one root carried a
name the grammar refuses outright (git-webhook). One store is one
capability however many nouns it answers for (HIP-0139 §7.1).
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
One store
The capability owns one store: the system-namespace platform SQLite
database (apps/platform/store.go:131), holding project, application, build,
deployment, environment and release rows. Tenancy is the org column on
every table, enforced in every WHERE clause; the store exposes no method that
reads a row without the org (apps/platform/store.go:22-28).
The addresses
Every route is under /v1/platform: apps, builds, cd, ci,
environments, fleet, health, hook, pipelines, projects,
releases, run and runner. /v1/platform/ci answers 501 naming what is
missing — an address the fleet publishes and routes nowhere is the defect the
manifest exists to prevent (manifest/apps.go:140-145). The router today
still serves seven of these families at the root (/v1/builds,
/v1/environments, /v1/git-webhook, /v1/pipelines, /v1/releases,
/v1/run, /v1/runner); each pair is a line in cloud's
openapi/misfiled.txt and folds here, with git-webhook — a hyphenated
compound — renamed to hook in the same move.
Operations are typed zip operations except the push endpoint, which cannot be:
its authentication is an HMAC over the raw bytes checked before the payload
is parsed, and a typed operation decodes first (apps/platform/hook.go:19-22).
The push endpoint
POST /v1/platform/hook is where the forge delivers a push. The receiver
MUST live in this process because the deploy trigger has exactly one
registrant, and it is platform's: the endpoint once lived in git's process, where
that registrant is nil, so every delivery was signed, accepted, answered 204
and built nothing (apps/platform/hook.go:1-17).
The forge holds no platform session, so the address is public at the identity layer and authenticated by the HMAC signature; the bytes verified MUST be the bytes acted on, and the event kind is taken from the payload, never a header. Four bounds keep an unauthenticated endpoint from being a lever: an encoded body is refused 415 before it is touched, a body cap bounds what is hashed, the verifying secret refreshes on a bounded window from KMS (HIP-0027), and that key-store read times out below the forge's delivery timeout. An unreadable secret answers 503, not 401 — the fault is ours. Duplicates are remembered by fact (namespace, repository, ref, commit) for a bounded window, and a failed dispatch gives the memory back. The forge half is one forge-wide system webhook signed with the value at the configured KMS reference; a repository opts in by having an application that tracks it, not by owning a hook.
The pipelines board
GET /v1/platform/pipelines is a projection, not a record: one application's
build and deploy configuration joined with its latest run
(apps/platform/console.go:227). The surface MUST be read-only — a pipeline
comes into existence only through the one existing write path — and where an
application has a latest deployment, that deployment's status replaces the
configuration's. The same rule governs the sibling boards it sits beside:
an environment is a scope derived from the applications that target it, a
build row is a real record or an honest empty, a release is a deployment that
was actually applied.
Tenancy
The org is the gateway-minted, IAM-validated claim (HIP-0026), read as
c.Org(); the deploy namespace is derived from it as tenant-<org>, never
taken from the request, and cross-tenant identifiers are structurally not
inputs to any handler (apps/platform/platform.go:20-26). Console reads
additionally require the identity to carry a user, refusing the one forgeable
path — a caller reaching a pod directly with an asserted org and no bearer.
Two endpoints differ by audience: /v1/platform/fleet is the operator's drift
board, admitted only for a SuperAdmin or an org-confined OrgAdmin
(apps/platform/fleet.go:31-34, HIP-0135), and the push endpoint authenticates
by signature as above. POST /v1/platform/runner — the privileged build
trigger hanzo build and the push hook call — is gated by a constant-time
shared token plus an image-ref allowlist confined to the registries we own
(apps/platform/runner.go:1-13).
Metered
The capability is metered (plugin/platform/main.go:21), and every debit
lands in integer cents through the shared cloud.ResourceMeter on the
caller's org ledger:
- build minutes — wall-clock from build creation to observed Job
completion, once per completed build, at
CLOUD_BUILD_MINUTE_CENTS(policy default $1.00/min; 0 makes builds free) (apps/platform/buildmeter.go:1-20); - running compute — each tick charges every live app's org for the span
since its compare-and-set watermark, at the app's SBOM compute rate, so a
double-tick or restart never double-charges
(
apps/platform/computemeter.go:1-30); - the run fee — a flat gate-then-meter-on-success debit per
/v1/platform/rundeploy atCLOUD_PLATFORM_RUN_FEE_CENTS(apps/platform/run.go:36).
Events, observability, stage
The capability publishes no events on the bus, so a customer's webhooks
receive nothing from it. It emits lifecycle facts — push.landed,
build.started, deploy.live, deploy.failed (build.go:582-585) — on the
in-process stream (apps/platform/hook.go:628, apps/platform/deploy.go:290,
apps/platform/applylive.go:94), fanned to registered reactors: the deploy
subscriber, mirror-out, and chat notification. Beyond the request span it
emits structured log lines only (apps/platform/run.go:227). Its stage is
ga: the manifest row carries no stage field, and absent means ga
(HIP-0139 §8).
Upstreams
The capability forks nothing. It links go-git/go-git v5 (Apache-2.0) to
read pushed repositories in memory, k8s.io/apimachinery (Apache-2.0) to
write operator CRs, and Masterminds/sprig v3 (MIT) for template functions.
Builds execute in an in-cluster BuildKit (Apache-2.0) Job launched by image;
nothing of BuildKit is linked into the binary.
Rationale
The alternative to carrying the hook and the board in this HIP is two more specification files for two things that are single routes of this package — one capability sliced into three specs, the inversion of the defect HIP-0139 §6 names.
Security Considerations
The wrong implementation hands an attacker the build plane. An unsigned or parse-before-verify hook lets an unauthenticated caller mint builds in the process that owns deploys; an encoded body accepted at that endpoint buys megabytes of allocation for kilobytes on the wire. A deploy namespace taken from the request is a cross-tenant deploy; it is derived from the validated org instead. A leaked runner token without the image-ref allowlist pushes to an arbitrary registry; with it, only to ours. The fleet board without its admin guard is a fleet-wide rollout lever exposed to every tenant.
References
- HIP-0026 — Identity and Access Management
- HIP-0027 — Secrets Management
- HIP-0106 — The Hanzo Plugin Contract
- HIP-0135 — What Is Public
- HIP-0139 — Capability
Copyright
Released under CC0 1.0 Universal Public Domain Dedication.