diff --git a/pkg/wire/testdata/health_response.json b/pkg/wire/testdata/health_response.json new file mode 100644 index 0000000..1990a0d --- /dev/null +++ b/pkg/wire/testdata/health_response.json @@ -0,0 +1,5 @@ +{ + "uptime_seconds": 3600, + "artefact_sha256": "1111111122222222333333334444444455555555666666667777777788888888", + "deployed_commit": "0abcdef" +} diff --git a/pkg/wire/testdata/health_response_zero.json b/pkg/wire/testdata/health_response_zero.json new file mode 100644 index 0000000..b93b166 --- /dev/null +++ b/pkg/wire/testdata/health_response_zero.json @@ -0,0 +1,5 @@ +{ + "uptime_seconds": 0, + "artefact_sha256": "", + "deployed_commit": "" +} diff --git a/pkg/wire/wire.go b/pkg/wire/wire.go index 66afbc5..88322d3 100644 --- a/pkg/wire/wire.go +++ b/pkg/wire/wire.go @@ -411,6 +411,52 @@ type DeployPublishResponse struct { ExpiresAt time.Time `json:"expires_at"` } +// HealthResponse is the body of GET /v1/health: a LIVENESS answer, and +// deliberately nothing else. The handler reads no datastore and reaches no +// other service, so a 200 means "this process is up and serving requests" +// and never "its dependencies are healthy". Those are two different +// questions, and one endpoint answering both can only ever answer the +// weaker one — while reporting the stronger. +// +// This route is UNAUTHENTICATED, because a liveness check that needs a +// credential cannot be used by the thing that starts the process, which is +// the first caller that needs it. It carries nothing a stranger could not +// already infer from the service answering at all. +// +// No client behaviour depends on this type, and the CLI never calls this +// route. It is here because the contract is defined once, in this package, +// for whoever does call it — a supervisor deciding whether to proceed, or a +// person establishing that a port which accepts connections is attached to +// a server that answers them. +// +// # Both string fields may be empty, and empty is not an error +// +// ArtefactSHA256 is what the running binary computes of ITSELF at startup: +// its own identity rather than an assertion about it, which is the one form +// of this fact that cannot disagree with reality. A process that cannot +// read its own file reports nothing here and still serves — a liveness +// endpoint that failed on self-inspection would not be a liveness endpoint. +// +// DeployedCommit is CONFIGURATION, and the field name says so rather than +// implying the binary knows. Whatever installed this binary is the only +// thing that knows which source revision produced it, so the value is +// exactly as trustworthy as that installation and no more. It is not +// compiled in: stamping a revision at build time would make the binary's +// bytes depend on where it came from, and the build that produces it is +// pinned precisely so its output is reproducible from its inputs alone. +// Empty means nobody told this process, which is a fact worth reporting +// and not a reason to refuse. +// +// UptimeSeconds is measured from one reading taken at startup. It is a +// count rather than a timestamp deliberately: at zero it reads as zero, +// where an unset instant renders as a date in year one that every reader +// has to decode before dismissing. +type HealthResponse struct { + UptimeSeconds int `json:"uptime_seconds"` + ArtefactSHA256 string `json:"artefact_sha256"` + DeployedCommit string `json:"deployed_commit"` +} + // DeployStatus is the lifecycle state of a deploy, as recorded in the // deploy record's status attribute. It is what the record says the deploy IS — // contrast Phase, which is what the build pipeline is DOING. The two diff --git a/pkg/wire/wire_test.go b/pkg/wire/wire_test.go index 2280e19..bf65928 100644 --- a/pkg/wire/wire_test.go +++ b/pkg/wire/wire_test.go @@ -99,8 +99,11 @@ func goldenCases() []goldenCase { }, { // The closed state is the whole point of the endpoint — the - // step-zero gate — and the only fixture where the meaningful - // values are zero values. + // step-zero gate — and the first fixture here whose meaningful + // values are zero values. HealthResponseZero below is the + // second, and for the same reason: a state the endpoint is + // EXPECTED to report needs a fixture of its own, or the only + // pinned shape is the populated one. name: "CapacityResponseClosed", fixture: "capacity_response_closed.json", value: &CapacityResponse{ @@ -110,6 +113,32 @@ func goldenCases() []goldenCase { }, newEmpty: func() any { return &CapacityResponse{} }, }, + { + // Values are SYNTHETIC on purpose. A fixture's job is to pin + // field names, not to record a measurement, and this repository + // is world-readable: a real digest and a real revision id here + // would publish two facts about a private deployment in a file + // that only needed three keys. + name: "HealthResponse", + fixture: "health_response.json", + value: &HealthResponse{ + UptimeSeconds: 3600, + ArtefactSHA256: "1111111122222222333333334444444455555555666666667777777788888888", + DeployedCommit: "0abcdef", + }, + newEmpty: func() any { return &HealthResponse{} }, + }, + { + // The all-zero state is REACHABLE and not a degenerate case: a + // supervisor's first poll arrives before a second of uptime has + // accrued, a binary that cannot read its own file reports no + // digest, and nothing obliges an installation to declare a + // revision. All three are 200s, so all three need pinning. + name: "HealthResponseZero", + fixture: "health_response_zero.json", + value: &HealthResponse{}, + newEmpty: func() any { return &HealthResponse{} }, + }, { name: "WaitlistRequest", fixture: "waitlist_request.json",