315 lines
14 KiB
Go
315 lines
14 KiB
Go
package gates
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"textmachine/platform/internal/httpapi"
|
|
"textmachine/platform/internal/pgstore"
|
|
"textmachine/platform/internal/runner"
|
|
)
|
|
|
|
// canonPath is the ratified API contract, from this package's directory. It is outside the zone on
|
|
// purpose: the contract belongs to `docs/` and this zone only serves it.
|
|
const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml"
|
|
|
|
// The constant a deployment announces itself with must be the version the ratified canon carries.
|
|
//
|
|
// Every other test of this constant compares the WIRE to the constant, which is self-consistent and
|
|
// passes at any value — the class PD-1 names. This is the only check with an independent second
|
|
// source, and it is the reason the constant went two ratifications without moving: `0.3.0` was
|
|
// served while `0.4.0` was the canon, so a client generated against what the deployment ANNOUNCES
|
|
// would have refused the shapes it actually returns (while the major is `0`, a differing minor
|
|
// carries breaking changes by design — canon §Versioning).
|
|
//
|
|
// It reads the canon rather than a copy of the number: a gate that holds a second copy of the fact
|
|
// is one more place to forget.
|
|
func TestTheAnnouncedContractVersionIsTheOneTheCanonRatified(t *testing.T) {
|
|
src, err := os.ReadFile(filepath.Join(canonPath))
|
|
if err != nil {
|
|
// Not skipped. A missing canon leaves this constant with no check at all except a human
|
|
// noticing, which is exactly what failed twice.
|
|
t.Fatalf("the ratified canon could not be read, so the served contract version has nothing to be checked against: %v", err)
|
|
}
|
|
// `info.version` and not any other `version:` in the document: the file carries several.
|
|
m := regexp.MustCompile(`(?m)^info:\n(?:[ \t]+.*\n)*?[ \t]+version: (\S+)`).FindSubmatch(src)
|
|
if m == nil {
|
|
t.Fatal("the canon carries no info.version: this gate reads the wrong document or the wrong shape")
|
|
}
|
|
if got, want := httpapi.ContractVersion, string(m[1]); got != want {
|
|
t.Errorf("this build announces contract %s and the ratified canon is %s; the constant is raised in the same change as the code that implements a minor, never afterwards", got, want)
|
|
}
|
|
}
|
|
|
|
// The `blocked` vocabulary this deployment serves must be the one the canon enumerates.
|
|
//
|
|
// ⛔ IT IS THE SECOND CHECK IN THIS ZONE WITH AN INDEPENDENT SOURCE, and it exists because the first
|
|
// one — the version number above — cannot see a SHAPE. Every other test of these values compares the
|
|
// wire to the same Go constant it was rendered from, which is self-consistent and passes at any
|
|
// value: rename both of them and the whole module stays green (measured by the pack's own adversarial
|
|
// pass, 11.09). That is the class PD-1 names, and `Blocked.code` is the one place it costs a
|
|
// contract: the canon declares a CLOSED enum there, so a value this build serves and the canon does
|
|
// not is a client generated against the canon meeting a word its stubs do not carry.
|
|
//
|
|
// It reads the canon rather than a copy of it, like its neighbour, and it asserts BOTH directions:
|
|
// a value served and not ratified is a wire ahead of the contract, a value ratified and not served
|
|
// is a contract ahead of the wire — and which of the two is a defect depends on the day, so the gate
|
|
// names what it saw instead of guessing.
|
|
func TestTheBlockedVocabularyServedIsTheOneTheCanonEnumerates(t *testing.T) {
|
|
src, err := os.ReadFile(filepath.Join(canonPath))
|
|
if err != nil {
|
|
t.Fatalf("the ratified canon could not be read, so the served `blocked` vocabulary has nothing to be checked against: %v", err)
|
|
}
|
|
ratified, err := enumOfSchema(string(src), "Blocked")
|
|
if err != nil {
|
|
t.Fatalf("the canon's Blocked schema: %v — this gate reads the wrong document or the wrong shape", err)
|
|
}
|
|
// What this build can put in that member, written out rather than derived: a list a test walks is
|
|
// a list somebody has to extend deliberately, which is the whole point of a closed vocabulary.
|
|
served := map[string]bool{
|
|
httpapi.CauseCreditHeld: true,
|
|
httpapi.CauseRunInFlight: true,
|
|
}
|
|
for v := range served {
|
|
if !ratified[v] {
|
|
t.Errorf("this build serves `blocked.code: %s` and the canon's enum does not carry it: %v", v, keys(ratified))
|
|
}
|
|
}
|
|
for v := range ratified {
|
|
if !served[v] {
|
|
t.Errorf("the canon ratifies `blocked.code: %s` and this build never serves it: the wire is behind the contract", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func keys(m map[string]bool) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// enumOfSchema returns the values of the one `enum:` inside the named schema of the canon.
|
|
//
|
|
// ⛔ THE SEARCH IS BOUNDED BY THE SCHEMA'S OWN BODY, and the bound is the whole of this function. The
|
|
// first edition was one regexp — `\n Blocked:\n.*?\n +enum: \[…\]` — whose `.*?` was stopped by
|
|
// nothing: with the enum deleted from the canon it walked on and read the NEXT schema's, so a missing
|
|
// closed vocabulary came back as `[exhausted low ok]` from `Usage.state` seventy lines below. Red, and
|
|
// red for the wrong reason; move those two words under the following schema instead and the gate goes
|
|
// GREEN over a canon that no longer ratifies them at all. A gate that can read a neighbour's answer is
|
|
// not a second source, it is a coin.
|
|
//
|
|
// The body is every line indented deeper than the schema's own name, which is what YAML means by
|
|
// nesting and what the canon does everywhere. Pinned by TestTheSchemaEnumReaderStopsAtTheSchemasOwnBody,
|
|
// whose fixtures are exactly the two shapes above.
|
|
func enumOfSchema(canon, schema string) (map[string]bool, error) {
|
|
head := "\n " + schema + ":\n"
|
|
i := strings.Index(canon, head)
|
|
if i < 0 {
|
|
return nil, fmt.Errorf("no schema %q in the document", schema)
|
|
}
|
|
body := canon[i+len(head):]
|
|
// …up to the next line that is indented no deeper than the schema's name: the next schema, or the
|
|
// end of the section.
|
|
if end := regexp.MustCompile(`(?m)^ {0,4}\S`).FindStringIndex(body); end != nil {
|
|
body = body[:end[0]]
|
|
}
|
|
m := regexp.MustCompile(`(?m)^ +enum: \[([^\]]*)\]`).FindStringSubmatch(body)
|
|
if m == nil {
|
|
return nil, fmt.Errorf("schema %q carries no enum", schema)
|
|
}
|
|
out := map[string]bool{}
|
|
for _, v := range strings.Split(m[1], ",") {
|
|
if v = strings.TrimSpace(v); v != "" {
|
|
out[v] = true
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// The reader stops at the schema's own body — asserted on documents built for the question, because
|
|
// the live canon cannot show the failure this exists to prevent.
|
|
//
|
|
// Both fixtures are the adversarial pass's own posting: delete the enum and see whether a neighbour's
|
|
// is read instead. The first says the reader must REFUSE; the second says it must refuse even when the
|
|
// missing words are sitting a few lines below under another name, which is the shape that came back
|
|
// GREEN from the first edition.
|
|
func TestTheSchemaEnumReaderStopsAtTheSchemasOwnBody(t *testing.T) {
|
|
const withIt = `
|
|
Blocked:
|
|
type: object
|
|
properties:
|
|
code:
|
|
type: string
|
|
enum: [credit_held, run_in_flight]
|
|
Usage:
|
|
properties:
|
|
state:
|
|
enum: [exhausted, low, ok]
|
|
`
|
|
got, err := enumOfSchema(withIt, "Blocked")
|
|
if err != nil || !got["credit_held"] || !got["run_in_flight"] || len(got) != 2 {
|
|
t.Fatalf("the reader did not read the schema's own enum: %v (%v)", keys(got), err)
|
|
}
|
|
// The enum is GONE from Blocked and a neighbour still has one.
|
|
const withoutIt = `
|
|
Blocked:
|
|
type: object
|
|
properties:
|
|
code:
|
|
type: string
|
|
Usage:
|
|
properties:
|
|
state:
|
|
enum: [exhausted, low, ok]
|
|
`
|
|
if got, err := enumOfSchema(withoutIt, "Blocked"); err == nil {
|
|
t.Errorf("a schema with no enum answered %v: the reader walked into the next schema", keys(got))
|
|
}
|
|
// And the same, with the missing words present further down under another name — the shape that
|
|
// makes a lenient reader answer RIGHT for the wrong document.
|
|
const movedAway = `
|
|
Blocked:
|
|
type: object
|
|
properties:
|
|
code:
|
|
type: string
|
|
Somewhere:
|
|
properties:
|
|
code:
|
|
enum: [credit_held, run_in_flight]
|
|
`
|
|
if got, err := enumOfSchema(movedAway, "Blocked"); err == nil {
|
|
t.Errorf("the words were read from the wrong schema: %v", keys(got))
|
|
}
|
|
}
|
|
|
|
// ⛔ THE FAILURE-REASON VOCABULARY IS CLOSED IN THREE PLACES AND WAS COMPARED IN NONE.
|
|
//
|
|
// `RunFailureReason` is a closed enum in the canon, a switch in the store's validator and a CHECK
|
|
// constraint in the DDL. Nothing held the three together: measured 17.09, this package had zero hits
|
|
// for the name (and zero for its neighbours `PausedReason`, `BookStatus`, `ContractHaltReason` — the
|
|
// control that says the instrument was reading the right place). So a fourth value added to the code
|
|
// alone passed the whole battery GREEN on a host where the database tests are gated off, and met the
|
|
// constraint in production; one added to the canon alone left the wire behind the contract with
|
|
// nothing red.
|
|
//
|
|
// It reads the canon and the migrations rather than copies of them, like the two gates above. The
|
|
// SERVED list is written out here on purpose — the form its neighbour uses and for the same reason:
|
|
// a list a test walks is a list somebody has to extend deliberately, which is the whole point of a
|
|
// closed vocabulary. Adding a value now takes four deliberate edits, and that is the gate.
|
|
//
|
|
// ⚠ `attempts-exhausted` is checked to be OUTSIDE it, and that is not pedantry: the platform's own
|
|
// word for «we stopped restarting this run» lives in `run_attempts.exit_result`, a column the canon
|
|
// does not know and no constraint bounds, precisely so that the axis «who ended this run» can grow
|
|
// without touching the axis «is a retry worth offering». A word that leaked from one into the other
|
|
// would be a contract change nobody ratified.
|
|
func TestTheFailureReasonVocabularyIsTheSameInAllThreeOfItsCarriers(t *testing.T) {
|
|
src, err := os.ReadFile(canonPath)
|
|
if err != nil {
|
|
t.Fatalf("the ratified canon could not be read, so the failure-reason vocabulary has nothing to be checked against: %v", err)
|
|
}
|
|
ratified, err := enumOfSchema(string(src), "RunFailureReason")
|
|
if err != nil {
|
|
t.Fatalf("the canon's RunFailureReason schema: %v — this gate reads the wrong document or the wrong shape", err)
|
|
}
|
|
served := map[string]bool{"source_unreadable": true, "service_error": true, "interrupted": true}
|
|
for v := range served {
|
|
if !ratified[v] {
|
|
t.Errorf("this build serves failure_reason %q and the canon's enum does not carry it: %v", v, keys(ratified))
|
|
}
|
|
}
|
|
for v := range ratified {
|
|
if !served[v] {
|
|
t.Errorf("the canon ratifies failure_reason %q and this build never serves it: the wire is behind the contract", v)
|
|
}
|
|
}
|
|
// The VALIDATOR is asked, not read: a third list here would be the drift this gate exists to stop.
|
|
for v := range ratified {
|
|
if !pgstore.ValidFailureReason(v) {
|
|
t.Errorf("the canon ratifies failure_reason %q and the store's validator refuses it: a run that "+
|
|
"ended that way could not be written down at all", v)
|
|
}
|
|
}
|
|
if pgstore.ValidFailureReason(runner.AttemptsExhaustedResult) {
|
|
t.Errorf("%q is accepted as a contract failure reason: the platform's own word for an ending it "+
|
|
"chose has leaked into the client's retry vocabulary", runner.AttemptsExhaustedResult)
|
|
}
|
|
// The DDL, from the LAST migration that constrains the column — migrations are append-only, so a
|
|
// later one is the one in force. Reading a file by name would go stale the day it is narrowed.
|
|
//
|
|
// ⚠ THAT PREMISE HAS A CONDITION, and it is written ONCE, at `failureReasonsInDDL` below rather
|
|
// than restated here: a migration whose `Up` narrows this vocabulary while its `Down` restores the
|
|
// older list would be read WHOLE, and the rule this gate compares the canon against would be the
|
|
// cancelled one. Said by pointer on purpose — a second copy of a condition is a second thing to go
|
|
// stale, which is how this very comment came to state a conclusion its own mechanism had qualified.
|
|
inDDL, from, err := failureReasonsInDDL()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for v := range ratified {
|
|
if !inDDL[v] {
|
|
t.Errorf("the canon ratifies failure_reason %q and %s does not allow it: a run that ended that "+
|
|
"way is refused by the database", v, from)
|
|
}
|
|
}
|
|
for v := range inDDL {
|
|
if !ratified[v] {
|
|
t.Errorf("%s allows failure_reason %q and the canon does not ratify it: the schema is ahead of "+
|
|
"the contract", from, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// failureReasonsInDDL returns the values the newest constraint on `runs.failure_reason` allows, and
|
|
// which migration it came from.
|
|
func failureReasonsInDDL() (map[string]bool, string, error) {
|
|
dir := filepath.Join(zoneRoot, "internal", "pgstore", "migrations")
|
|
files, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
// Append-only, so the LAST file that mentions the constraint is the one in force. Names sort by
|
|
// their number, which is what makes «last» well defined.
|
|
//
|
|
// ⛔ THE CONDITION UNDER WHICH THAT STOPS HOLDING, written down because the conclusion above is
|
|
// load-bearing: this reads a migration WHOLE and does not tell `+goose Up` from `+goose Down`.
|
|
// Today the two cannot disagree — exactly one migration constrains this column and its down path
|
|
// drops the column rather than re-listing values — so «the last file» and «the rule in force» are
|
|
// the same thing. The day a migration NARROWS the vocabulary in its Up while its Down restores
|
|
// the older list, this function would read the Down's list as current and the gate would compare
|
|
// the canon against a rule nothing enforces. The cure then is to cut each file at its `+goose
|
|
// Down` marker, not to re-aim this at a file by name.
|
|
re := regexp.MustCompile(`failure_reason in \(([^)]*)\)`)
|
|
var newest string
|
|
var match []string
|
|
for _, f := range files {
|
|
body, err := os.ReadFile(filepath.Join(dir, f.Name()))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if m := re.FindStringSubmatch(string(body)); m != nil {
|
|
newest, match = f.Name(), m
|
|
}
|
|
}
|
|
if match == nil {
|
|
// Not «no values»: a zero and a missing constraint are indistinguishable in a result, and one
|
|
// of them means this gate measured nothing.
|
|
return nil, "", fmt.Errorf("no migration constrains runs.failure_reason: this gate is reading the "+
|
|
"wrong directory (%s) or the constraint has been dropped", dir)
|
|
}
|
|
out := map[string]bool{}
|
|
for _, v := range strings.Split(match[1], ",") {
|
|
if v = strings.Trim(strings.TrimSpace(v), "'"); v != "" {
|
|
out[v] = true
|
|
}
|
|
}
|
|
return out, newest, nil
|
|
}
|