diff --git a/README.md b/README.md index 756d577..dbc30cc 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ npm run verify # lint, format, typecheck, ADR contract, tests + coverage `npm run lint:adrs` alone runs the decision-record contract, and `npm test` runs the suite without enforcing coverage. `npm run test:coverage` (part of `npm run verify`) enforces the ratchet in `vitest.config.ts`: statements -98.6%, branches 93.8%, functions 100%, lines 98.51%. +98.67%, branches 94.3%, functions 100%, lines 98.58%. ## Conventions diff --git a/docs/requirements.md b/docs/requirements.md index 890b168..73c90fc 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -19,7 +19,7 @@ test file and holds at least one test; ids are unique; the count this document states matches the number of rows it holds; and every id cited anywhere in the tracked tree resolves to a row here. -This ledger holds **23** rows. The compiler's behaviours join it as they land. +This ledger holds **24** rows. The compiler's behaviours join it as they land. | id | a contributor or a consumer can rely on | proved by | |---|---|---| @@ -46,3 +46,4 @@ This ledger holds **23** rows. The compiler's behaviours join it as they land. | REQ-021 | An authored Project Intent file parses to its committed intent oracle byte for byte, and YAML outside the one-document, anchor-free subset or a field outside the language is refused with a diagnostic rather than guessed at | [test/model/project-intent.test.ts](../test/model/project-intent.test.ts) | | REQ-022 | Every module under `src/` is mutation-tested, and a surviving mutant that takes the score below the measured threshold fails the build | [test/mutation-contract.test.ts](../test/mutation-contract.test.ts) | | REQ-023 | The Project Intent metamodel's structure is committed as a descriptor both implementations are held to, and the JSON Schema an editor completes a project file against regenerates from the metamodel without a diff | [test/model/descriptor.test.ts](../test/model/descriptor.test.ts) | +| REQ-024 | A Project Intent document that breaks a model constraint is refused with the code and the JSON Pointer its committed diagnostics oracle names, and every refusal fixture carries one | [test/model/refusals.test.ts](../test/model/refusals.test.ts) | diff --git a/emf/cli/META-INF/MANIFEST.MF b/emf/cli/META-INF/MANIFEST.MF index ee459e6..c98bdf8 100644 --- a/emf/cli/META-INF/MANIFEST.MF +++ b/emf/cli/META-INF/MANIFEST.MF @@ -9,4 +9,6 @@ Export-Package: dev.jorisjonkers.deploykit.emf.cli Require-Bundle: dev.jorisjonkers.deploykit.emf.metamodel, dev.jorisjonkers.deploykit.emf.syntax, org.eclipse.emf.ecore, - org.eclipse.xtext + org.eclipse.xtext, + org.eclipse.ocl.pivot, + org.eclipse.ocl.xtext.completeocl diff --git a/emf/cli/pom.xml b/emf/cli/pom.xml index 3697356..34b3cdc 100644 --- a/emf/cli/pom.xml +++ b/emf/cli/pom.xml @@ -13,4 +13,23 @@ dev.jorisjonkers.deploykit.emf.cli eclipse-plugin deploy-kit model-driven cli + + + + + org.pitest + pitest-maven + + + + org.eclipse.ocl.xtext.completeocl.CompleteOCLStandaloneSetup + + + + + diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Constraints.java b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Constraints.java new file mode 100644 index 0000000..bdcedf0 --- /dev/null +++ b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Constraints.java @@ -0,0 +1,61 @@ +package dev.jorisjonkers.deploykit.emf.cli; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.eclipse.emf.common.util.Diagnostic; +import org.eclipse.emf.common.util.URI; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.emf.ecore.EPackage; +import org.eclipse.emf.ecore.EValidator; +import org.eclipse.emf.ecore.util.Diagnostician; +import org.eclipse.ocl.xtext.completeocl.CompleteOCLStandaloneSetup; +import org.eclipse.ocl.xtext.completeocl.validation.CompleteOCLEObjectValidator; + +/** + * The Complete OCL constraints of a metamodel, evaluated over one parsed document. An invariant is + * named by the diagnostic code it emits and its context is the object the diagnostic points at, so a + * violation becomes a {@link dev.jorisjonkers.deploykit.emf.cli.Diagnostic} without a lookup table + * (emf/docs/architecture.md#constraints). Every failed invariant is reported, never only the first. + */ +public final class Constraints { + + /** The invariant name inside the message Eclipse OCL builds for a violation. */ + private static final Pattern VIOLATED = Pattern.compile("'[^']*::([A-Za-z0-9_]+)' constraint is violated"); + + /** The constraints the metamodel carries, as the build puts them beside its classes. */ + public static URI beside(Class metamodel, String file) { + return URI.createURI(metamodel.getResource("/" + file).toString()); + } + + private Constraints() {} + + /** Evaluates {@code document} against the constraints at {@code constraints}, in document order. */ + public static List check(EObject document, URI constraints) { + CompleteOCLStandaloneSetup.doSetup(); + EPackage metamodel = document.eClass().getEPackage(); + EValidator previous = EValidator.Registry.INSTANCE.getEValidator(metamodel); + EValidator.Registry.INSTANCE.put(metamodel, new CompleteOCLEObjectValidator(metamodel, constraints)); + try { + return refusals(Diagnostician.INSTANCE.validate(document)); + } finally { + EValidator.Registry.INSTANCE.put(metamodel, previous); + } + } + + private static List refusals(Diagnostic diagnostic) { + List refusals = new ArrayList<>(); + Matcher violated = VIOLATED.matcher(diagnostic.getMessage()); + if (violated.find()) { + // A violation carries the object it refused as its first datum. + EObject refused = (EObject) diagnostic.getData().get(0); + refusals.add(new dev.jorisjonkers.deploykit.emf.cli.Diagnostic( + violated.group(1), Pointer.of(refused), diagnostic.getMessage())); + } + for (Diagnostic child : diagnostic.getChildren()) { + refusals.addAll(refusals(child)); + } + return refusals; + } +} diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java index adc854c..fb6a584 100644 --- a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java +++ b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.List; import org.eclipse.emf.common.util.URI; +import org.eclipse.emf.ecore.EObject; import org.eclipse.emf.ecore.EPackage; import org.eclipse.emf.ecore.resource.Resource; import org.eclipse.xtext.resource.XtextResourceSet; @@ -16,6 +17,9 @@ */ public final class Pipeline { + /** The Complete OCL file the metamodel carries, beside its classes. */ + private static final String CONSTRAINTS = "project-intent.ocl"; + private Pipeline() {} /** The parsed intent of the project file at {@code path}, or the diagnostics refusing it. */ @@ -34,8 +38,12 @@ public static Parsed intent(Path path) { if (resource.getContents().isEmpty()) { refusals.add(new Diagnostic(Diagnostic.SCHEMA, "", path.getFileName() + " holds no document")); } - return refusals.isEmpty() - ? Parsed.of(IntentJson.of(resource.getContents().get(0))) - : Parsed.refused(refusals); + if (!refusals.isEmpty()) { + return Parsed.refused(refusals); + } + EObject document = resource.getContents().get(0); + List broken = + Constraints.check(document, Constraints.beside(ProjectIntentPackage.class, CONSTRAINTS)); + return broken.isEmpty() ? Parsed.of(IntentJson.of(document)) : Parsed.refused(broken); } } diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pointer.java b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pointer.java new file mode 100644 index 0000000..4b62d80 --- /dev/null +++ b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pointer.java @@ -0,0 +1,34 @@ +package dev.jorisjonkers.deploykit.emf.cli; + +import java.util.List; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.emf.ecore.EStructuralFeature; + +/** + * The RFC 6901 JSON Pointer of an object inside its document, read off its containment chain: the + * name of the feature that holds it, and its index where the feature holds many + * (docs/architecture.md#the-parity-contract). The root is the empty pointer. + */ +public final class Pointer { + + private Pointer() {} + + /** The pointer of {@code object} in the document it belongs to. */ + public static String of(EObject object) { + EObject owner = object.eContainer(); + if (owner == null) { + return ""; + } + EStructuralFeature feature = object.eContainingFeature(); + String step = escape(feature.getName()); + if (feature.isMany()) { + step = step + "/" + ((List) owner.eGet(feature)).indexOf(object); + } + return of(owner) + "/" + step; + } + + /** A feature name as a pointer segment: the two characters a pointer spells differently. */ + private static String escape(String name) { + return name.replace("~", "~0").replace("/", "~1"); + } +} diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/ConstraintsTest.java b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/ConstraintsTest.java new file mode 100644 index 0000000..9edb036 --- /dev/null +++ b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/ConstraintsTest.java @@ -0,0 +1,96 @@ +package dev.jorisjonkers.deploykit.emf.cli; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentPackage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** The constraints a parsed document answers, and the diagnostics a violation becomes. */ +class ConstraintsTest { + + private static final String REFUSED = """ + apiVersion: intent.jorisjonkers.dev/v1 + kind: Project + schemaVersion: 1.0.0 + project: refusals + owner: joris + applications: + - id: unwired + observability: + alertClass: page + processes: + - name: unwired-worker + lifecycle: application + image: unwired-worker + runtime: node + placement: + memory: 128Mi + cpu: 25m + cutover: rolling + """; + + private static Path file(Path directory, String text) throws IOException { + Path file = directory.resolve("unwired.project.yml"); + Files.writeString(file, text); + return file; + } + + @Test + void aViolationCarriesTheInvariantsNameAndThePointerOfWhatItRefused(@TempDir Path directory) throws IOException { + Parsed parsed = Pipeline.intent(file(directory, REFUSED)); + + assertThat(parsed.ok()).isFalse(); + assertThat(parsed.intent()).isEmpty(); + assertThat(parsed.diagnostics()).singleElement().satisfies(diagnostic -> { + assertThat(diagnostic.code()).isEqualTo("E_ALERT_CLASS_WITHOUT_SIGNAL"); + assertThat(diagnostic.path()).isEqualTo("/applications/0/observability"); + assertThat(diagnostic.message()).contains("E_ALERT_CLASS_WITHOUT_SIGNAL"); + }); + } + + private static final String ACCEPTED = """ + apiVersion: intent.jorisjonkers.dev/v1 + kind: Project + schemaVersion: 1.0.0 + project: refusals + owner: joris + applications: + - id: wired + observability: + alertClass: page + scrape: + process: wired-worker + surface: http + path: /metrics + processes: + - name: wired-worker + lifecycle: application + image: wired-worker + runtime: node + provides: + http: 8080 + placement: + memory: 128Mi + cpu: 25m + cutover: rolling + """; + + @Test + void aDocumentThatBreaksNoConstraintCarriesNoDiagnostic(@TempDir Path directory) throws IOException { + Parsed parsed = Pipeline.intent(file(directory, ACCEPTED)); + + assertThat(parsed.diagnostics()).isEmpty(); + assertThat(parsed.intent()).containsKey("applications"); + } + + @Test + void theConstraintsAreTheFileTheMetamodelCarries() { + assertThat(Constraints.beside(ProjectIntentPackage.class, "project-intent.ocl") + .toString()) + .endsWith("project-intent.ocl"); + } +} diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PointerTest.java b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PointerTest.java new file mode 100644 index 0000000..a4f969c --- /dev/null +++ b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PointerTest.java @@ -0,0 +1,47 @@ +package dev.jorisjonkers.deploykit.emf.cli; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Application; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Observability; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Process; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Project; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentFactory; +import org.junit.jupiter.api.Test; + +/** Where an object sits in its document, as the pointer a diagnostic carries. */ +class PointerTest { + + private static final ProjectIntentFactory MODEL = ProjectIntentFactory.eINSTANCE; + + @Test + void theRootIsTheEmptyPointer() { + assertThat(Pointer.of(MODEL.createProject())).isEmpty(); + } + + @Test + void aFeatureHoldingManyValuesCarriesTheIndex() { + Project project = MODEL.createProject(); + Application first = MODEL.createApplication(); + Application second = MODEL.createApplication(); + project.getApplications().add(first); + project.getApplications().add(second); + Process process = MODEL.createProcess(); + second.getProcesses().add(process); + + assertThat(Pointer.of(first)).isEqualTo("/applications/0"); + assertThat(Pointer.of(second)).isEqualTo("/applications/1"); + assertThat(Pointer.of(process)).isEqualTo("/applications/1/processes/0"); + } + + @Test + void aFeatureHoldingOneValueCarriesItsNameAlone() { + Project project = MODEL.createProject(); + Application application = MODEL.createApplication(); + Observability observability = MODEL.createObservability(); + project.getApplications().add(application); + application.setObservability(observability); + + assertThat(Pointer.of(observability)).isEqualTo("/applications/0/observability"); + } +} diff --git a/emf/docs/architecture.md b/emf/docs/architecture.md index 82b6bb2..7f2db5e 100644 --- a/emf/docs/architecture.md +++ b/emf/docs/architecture.md @@ -136,6 +136,15 @@ offending object, computed from its containment chain: each containing feature's name, and the index for a many-valued feature. Validation reports every failed invariant, never only the first. +Two consequences of evaluating OCL over Ecore, both recorded here because they +shaped the metamodel. The constraints import the metamodel by its `nsURI`, not +by file, so they bind to the classes the parser instantiates rather than to a +second copy. And EMF reads an unset enumeration as its first literal, so a +vocabulary an invariant tests for absence carries a literal with no spelling: +`Engine::absent` is what an unset `engine` reads as, a document cannot write it, +and the descriptor leaves it out because a literal the language cannot write is +not part of the vocabulary. + The constraint ledger's OCL column lives in `emf/`: a table mapping each `CONS-NNN` id to the OCL invariant that enforces it. `parity/` fails when a ledger constraint has no invariant, or an invariant names a code no ledger row diff --git a/emf/docs/witnesses.md b/emf/docs/witnesses.md index e3104b3..eb2b68d 100644 --- a/emf/docs/witnesses.md +++ b/emf/docs/witnesses.md @@ -10,9 +10,10 @@ names the JUnit test that proves the same behaviour here has no witness here, when a witness names an id that is not a model row, or when it names a test method that does not exist. -This list holds **2** witnesses. +This list holds **3** witnesses. | id | JUnit test | |---|---| | REQ-021 | `ParityTest#theParsedIntentEqualsTheCommittedOracle` | | REQ-023 | `ParityTest#theMetamodelsStructureEqualsTheCommittedDescriptor` | +| REQ-024 | `ParityTest#aRefusedDocumentEqualsItsCommittedDiagnostics` | diff --git a/emf/metamodel/build.properties b/emf/metamodel/build.properties index 765502e..ccdc6bd 100644 --- a/emf/metamodel/build.properties +++ b/emf/metamodel/build.properties @@ -1,4 +1,5 @@ source.. = src/main/java/,\ + model/,\ target/generated-sources/emf/ bin.includes = META-INF/,\ .,\ diff --git a/emf/metamodel/model/project-intent.ecore b/emf/metamodel/model/project-intent.ecore index 698450f..5eca3d0 100644 --- a/emf/metamodel/model/project-intent.ecore +++ b/emf/metamodel/model/project-intent.ecore @@ -26,7 +26,7 @@ - + @@ -166,10 +166,14 @@ - - - - + +
+ + + + + + @@ -231,7 +235,7 @@ - + diff --git a/emf/metamodel/model/project-intent.ocl b/emf/metamodel/model/project-intent.ocl new file mode 100644 index 0000000..c148ddf --- /dev/null +++ b/emf/metamodel/model/project-intent.ocl @@ -0,0 +1,66 @@ +-- The document-level constraints of the Project Intent model, in the graded +-- constraint language (docs/adr/emf/0110). An invariant is named by the +-- diagnostic code it emits, and its context is the object the diagnostic points +-- at, so a violation maps to a (code, path) pair without a lookup table. +-- +-- Constraints that need more than one document belong to composition. + +-- The registered package, not the file: the constraints must bind to the same +-- classes the parser instantiates. +import 'https://jorisjonkers.dev/deploy-kit/project-intent/1' + +package projectintent + +context Observability + -- The block is whole or absent: a class states how loudly to wake someone, + -- and means nothing without a signal to wake them about. + inv E_ALERT_CLASS_WITHOUT_SIGNAL: scrape <> null + +context Process + -- A volume cannot attach to the surge a rolling cutover needs, and a silent + -- downgrade is what makes an owner believe in continuity they do not have. + inv E_CUTOVER_UNHONOURABLE: cutover = Cutover::rolling implies volumes->isEmpty() + + -- The engine keys the backup method, so it names one nothing asks for when no + -- volume derives a backup. `Engine::absent` is the literal a document cannot + -- write: EMF reads an unset enumeration as its first literal, so the model + -- gives absence one of its own. + inv E_ENGINE_WITHOUT_DURABILITY: + engine <> Engine::absent implies + volumes->exists(durability = DurabilityClass::recoverable or durability = DurabilityClass::irreplaceable) + +context Volume + -- The backup method comes from the Process's engine, and a guessed backup is + -- the kind nobody finds out about until a restore. + inv E_DURABILITY_WITHOUT_ENGINE: + (durability = DurabilityClass::recoverable or durability = DurabilityClass::irreplaceable) implies + oclContainer().oclAsType(Process).engine <> Engine::absent + +context Rotation + -- An environment variable is read once, when the process starts. + inv E_ENV_CANNOT_RELOAD: + tolerates = Tolerance::reload implies oclContainer().oclAsType(Grant).delivery <> Delivery::env + +context KvGrant + -- Custody creates and deletes paths at runtime, and a renewal tier holds no + -- capability on its path: neither has anything to project. + inv E_ILLEGAL_DELIVERY_FOR_ACCESS: + not ((access = AccessTier::custody and (delivery = Delivery::env or delivery = Delivery::file)) + or (access = AccessTier::selfRenew and delivery = Delivery::env)) + +context TransitGrant + -- A transit key is used, never read: there is no value to write anywhere. + inv E_NON_KV_DELIVERY: delivery = Delivery::selfDelivery + +context DatabaseGrant + -- A database credential is minted per lease and re-read at runtime. + inv E_NON_KV_DELIVERY: delivery = Delivery::selfDelivery + +context Route + -- Precedence is derived, and two routes sharing a path and a match cannot be + -- ordered by it. + inv E_DUPLICATE_ROUTE_MATCH: + oclContainer().oclAsType(Exposure).routes + ->select(r | r.path = self.path and r.match = self.match)->first() = self + +endpackage diff --git a/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java b/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java index 344820c..9381449 100644 --- a/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java +++ b/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java @@ -69,9 +69,14 @@ private static boolean isMapEntry(EClassifier classifier) { private static Map vocabulary(EEnum vocabulary) { Map json = new LinkedHashMap<>(); json.put("name", vocabulary.getName()); + // A literal with no spelling is the model's way of saying "unset", which is + // not a value the language can write and not part of the vocabulary. json.put( "literals", - vocabulary.getELiterals().stream().map(EEnumLiteral::getLiteral).toList()); + vocabulary.getELiterals().stream() + .map(EEnumLiteral::getLiteral) + .filter(literal -> !literal.isEmpty()) + .toList()); return json; } diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java index f68c354..43e4966 100644 --- a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java +++ b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java @@ -11,7 +11,10 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Comparator; import java.util.List; +import java.util.Map; +import java.util.TreeMap; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -44,6 +47,31 @@ void theParsedIntentEqualsTheCommittedOracle(Path directory) throws IOException assertThat(CanonicalJson.write(parsed.intent())).isEqualTo(read(directory.resolve("expected/intent.json"))); } + private static List refusalsWithADiagnosticsOracle() { + Path refusals = repository().resolve("spec/v1/examples/refusals"); + try (Stream files = Files.list(refusals)) { + return files.filter(path -> path.getFileName().toString().endsWith(".diagnostics.json")) + .sorted() + .toList(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("refusalsWithADiagnosticsOracle") + void aRefusedDocumentEqualsItsCommittedDiagnostics(Path oracle) throws IOException { + String stem = oracle.getFileName().toString().replace(".diagnostics.json", ""); + Parsed parsed = Pipeline.intent(oracle.resolveSibling(stem + ".project.yml")); + + assertThat(CanonicalJson.write(parsed.diagnostics().stream() + .map(diagnostic -> + (Object) new TreeMap<>(Map.of("code", diagnostic.code(), "path", diagnostic.path()))) + .sorted(Comparator.comparing(Object::toString)) + .toList())) + .isEqualTo(read(oracle)); + } + @Test void theMetamodelsStructureEqualsTheCommittedDescriptor() throws IOException { assertThat(CanonicalJson.write(Descriptor.of(ProjectIntentPackage.eINSTANCE))) diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntent.xtext b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntent.xtext index 51dbc38..1531e09 100644 --- a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntent.xtext +++ b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntent.xtext @@ -26,7 +26,7 @@ Application returns Application: Observability returns Observability: (('alertClass' ':' alertClass=AlertClass) - & ('scrape' ':' BEGIN scrape=Scrape END)); + & ('scrape' ':' BEGIN scrape=Scrape END)?); Scrape returns Scrape: (('process' ':' process=Text) @@ -241,7 +241,7 @@ enum TransitOp returns TransitOp: sign='sign' | verify='verify' | encrypt='encrypt' | decrypt='decrypt' | rotate='rotate'; enum Delivery returns Delivery: - env='env' | file='file' | self='self'; + env='env' | file='file' | selfDelivery='self'; enum Tolerance returns Tolerance: restart='restart' | reload='reload'; diff --git a/scripts/lint-codes.ts b/scripts/lint-codes.ts index 1cb9748..6ea40dc 100644 --- a/scripts/lint-codes.ts +++ b/scripts/lint-codes.ts @@ -45,19 +45,6 @@ export const RETIRED: Readonly> = { }; export const PENDING: readonly Pending[] = [ - { - ticket: "#38", - reason: - "a rule over one Project Intent document, registered with the metamodel", - codes: [ - "E_DURABILITY_WITHOUT_ENGINE", - "E_ENGINE_WITHOUT_DURABILITY", - "E_ENV_CANNOT_RELOAD", - "E_ILLEGAL_DELIVERY_FOR_ACCESS", - "E_NON_KV_DELIVERY", - "E_DUPLICATE_ROUTE_MATCH", - ], - }, { ticket: "#39", reason: "a reference resolved by name, which needs the linking step", diff --git a/spec/v1/10-project-intent.md b/spec/v1/10-project-intent.md index f572cef..b53207d 100644 --- a/spec/v1/10-project-intent.md +++ b/spec/v1/10-project-intent.md @@ -322,8 +322,8 @@ Vault role are called (chapter 16). never a digest here. `lifecycle` is `application` or `job`. Not `deployment` / `statefulset` / `job`, -because those are mechanisms; the object kind derives from `lifecycle`, `stateful` -and `volumes`. +because those are mechanisms; the object kind derives from `lifecycle` and +`volumes`. `runtime` selects the Runtime Profile: `jvm`, `python`, `node`, `static`, `none`. `none` is correct for a third-party image and injects no profile values at all. @@ -1741,7 +1741,7 @@ startupBudget: 600s # knowledge-api: JVM cold start measured at ~250-300s cutover: rolling # required: continuity during the cutover, or an accepted stop-then-start ``` -Derived from these plus `stateful`, `placement` and `volumes`: rollout strategy, +Derived from these plus `placement` and `volumes`: rollout strategy, surge and unavailability, startup probe period and threshold, the progress deadline, and the health-gate deadline the Application's switchover waits on. @@ -2008,7 +2008,6 @@ classDiagram +Engine engine +Duration startupBudget +Cutover cutover - +bool stateful +Path[] writablePaths } class Capacity { diff --git a/spec/v1/20-resolved-deployment.md b/spec/v1/20-resolved-deployment.md index 7ba9ce4..9021f8e 100644 --- a/spec/v1/20-resolved-deployment.md +++ b/spec/v1/20-resolved-deployment.md @@ -138,7 +138,7 @@ field's placement link to this anchor rather than copying rows. | process `name` | Application | unique, checked | unique within the **project**; `E_DUPLICATE_PROCESS_NAME`, and it names the derived identity | | `provides` surface names and ports | Application | no contention | declared on the Process, because a port is a property of a process; written once, there | | `dependsOn` edges | Application | no contention | provider, surface, necessity ([chapter 16](16-dependencies.md#dependency-edges)) | -| `image`, `runtime`, `lifecycle`, `stateful` | Application | no contention | what the Process is | +| `image`, `runtime`, `lifecycle` | Application | no contention | what the Process is | | env files, `assets` | Application | no contention | per Process; derived values appear only as placeholders | | `secrets` grants: `path`, `keys`, `access`, `delivery`, `rotation` | Application | no contention to declare | per Application and never raised; the *path* is arbitrated (below), what an Application asks of a path is its own | | `exposure[].name` | Application | unique, checked | required; unique **within the Application**, `E_DUPLICATE_EXPOSURE_NAME` at composition. It is the half `${exposure:.#url}` addresses | @@ -184,7 +184,7 @@ field's placement link to this anchor rather than copying rows. | container probe timings | derived | - | the startup probe's target from the **liveness** declaration and its period from `startupBudget`; readiness and liveness cadence from the Platform Intent's probe policy ([0088](../../docs/adr/model/0088-startup-probe-targets-liveness.md)) | | `progressDeadlineSeconds` | derived | - | from `startupBudget` | | rollout strategy, surge, unavailability | derived | - | from `cutover` and `volumes`; `cutover: rolling` over an RWO volume is `E_CUTOVER_UNHONOURABLE`, not a silent downgrade | -| object kind | derived | - | from `lifecycle`, `stateful` and `volumes` | +| object kind | derived | - | from `lifecycle` and `volumes` | | the Application's release-gate deadline | derived | - | `max` over the Application's Processes of `progressDeadlineSeconds` ([The release gate](#the-release-gate)) | | the object label set | derived | - | fixed, from Process name, Application Id and the images lock ([chapter 10](10-project-intent.md#the-label-set)) | | Secret and VSO sync objects | derived | - | from grants with `delivery: env` or `file`, plus `rolloutRestartTargets` from `rotation`; a grant with `delivery: self` and `tolerates: reload` derives **no** restart target, which is what makes its rotation zero-downtime ([chapter 10](10-project-intent.md#zero-downtime-rotation)) | diff --git a/spec/v1/diagrams/10-project-intent-model.drawio.svg b/spec/v1/diagrams/10-project-intent-model.drawio.svg index ff93018..4008dff 100644 --- a/spec/v1/diagrams/10-project-intent-model.drawio.svg +++ b/spec/v1/diagrams/10-project-intent-model.drawio.svg @@ -1,4 +1,4 @@ -Asset+ Path from+ Path mountAt+ map substituteCapacity+ int count+ string reasonDependencyEdge+ ApplicationId application+ string surface+ bool requiredDiskRequest+ Media[] mediaProject+ ProjectName project+ string owner+ SemVer schemaVersionEnvFile+ ClusterTarget cluster+ dotenv entriesExposure+ ExposureName name+ Fqdn host+ Audience audience+ ContentPolicy contentPolicyGpuRequest+ GpuClassName class+ Quantity memoryGrant+ SecretEngine engine+ VaultPath path+ string[] keys+ AccessTier access+ string role+ string key+ TransitOp[] operations+ Delivery delivery+ Path mountAt+ FileMode fileModeObservability+ AlertClass alertClassPlaceholder+ PlaceholderKind kind+ string sourcePlacement+ Quantity memory+ Quantity cpu+ Arch[] arch+ Site site+ Capability[] capabilitiesProbe+ Path path+ int port+ int tcpRotation+ Tolerance tolerates+ Duration maxAgeRoute+ Path path+ Match match+ string process+ string surface+ Audience audience+ Path redirectToScrape+ string process+ string surface+ Path pathService+ ApplicationId idSidecar+ string name+ ImageAlias image+ Quantity memory+ Quantity cpuSurface+ string name+ int portVolume+ string claim+ Path mountAt+ Quantity size+ DurabilityClass durabilityProcess+ string name+ Lifecycle lifecycle+ ImageAlias image+ Runtime runtime+ Engine engine+ Duration startupBudget+ Cutover cutover+ bool stateful+ Path[] writablePaths1..* applications1..* processes0..* provides0..* sidecars0..* dependsOn0..1 readiness0..1 liveness0..* assets0..* volumes1 placement0..1 observability1 scrape0..1 replicas0..1 disk0..1 gpu0..* exposure1..* routes1..* env per process0..* resolves0..* secrets0..1 rotation0..* secrets«resolves by name» \ No newline at end of file +Asset+ Path from+ Path mountAt+ map substituteCapacity+ int count+ string reasonDependencyEdge+ ApplicationId application+ string surface+ bool requiredDiskRequest+ Media[] mediaProject+ ProjectName project+ string owner+ SemVer schemaVersionEnvFile+ ClusterTarget cluster+ dotenv entriesExposure+ ExposureName name+ Fqdn host+ Audience audience+ ContentPolicy contentPolicyGpuRequest+ GpuClassName class+ Quantity memoryGrant+ SecretEngine engine+ VaultPath path+ string[] keys+ AccessTier access+ string role+ string key+ TransitOp[] operations+ Delivery delivery+ Path mountAt+ FileMode fileModeObservability+ AlertClass alertClassPlaceholder+ PlaceholderKind kind+ string sourcePlacement+ Quantity memory+ Quantity cpu+ Arch[] arch+ Site site+ Capability[] capabilitiesProbe+ Path path+ int port+ int tcpRotation+ Tolerance tolerates+ Duration maxAgeRoute+ Path path+ Match match+ string process+ string surface+ Audience audience+ Path redirectToScrape+ string process+ string surface+ Path pathService+ ApplicationId idSidecar+ string name+ ImageAlias image+ Quantity memory+ Quantity cpuSurface+ string name+ int portVolume+ string claim+ Path mountAt+ Quantity size+ DurabilityClass durabilityProcess+ string name+ Lifecycle lifecycle+ ImageAlias image+ Runtime runtime+ Engine engine+ Duration startupBudget+ Cutover cutover+ Path[] writablePaths1..* applications1..* processes0..* provides0..* sidecars0..* dependsOn0..1 readiness0..1 liveness0..* assets0..* volumes1 placement0..1 observability1 scrape0..1 replicas0..1 disk0..1 gpu0..* exposure1..* routes1..* env per process0..* resolves0..* secrets0..1 rotation0..* secrets«resolves by name» \ No newline at end of file diff --git a/spec/v1/examples/expected/descriptor.json b/spec/v1/examples/expected/descriptor.json index 2f44a8e..43af0c1 100644 --- a/spec/v1/examples/expected/descriptor.json +++ b/spec/v1/examples/expected/descriptor.json @@ -1 +1 @@ -{"classes":[{"features":[{"many":true,"map":false,"name":"exposure","required":false,"types":["Exposure"]},{"many":false,"map":false,"name":"id","required":true,"types":["string"]},{"many":false,"map":false,"name":"observability","required":false,"types":["Observability"]},{"many":true,"map":false,"name":"processes","required":true,"types":["Process"]},{"many":true,"map":false,"name":"secrets","required":false,"types":["DatabaseGrant","KvGrant","TransitGrant"]}],"name":"Application"},{"features":[{"many":false,"map":false,"name":"from","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":true,"types":["string"]}],"name":"Asset"},{"features":[{"many":false,"map":false,"name":"count","required":true,"types":["int"]},{"many":false,"map":false,"name":"reason","required":true,"types":["string"]}],"name":"Capacity"},{"features":[{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"engine","required":true,"types":["DatabaseEngine"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":false,"map":false,"name":"role","required":true,"types":["string"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"DatabaseGrant"},{"features":[{"many":false,"map":false,"name":"application","required":true,"types":["string"]},{"many":false,"map":false,"name":"required","required":false,"types":["boolean"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"DependencyEdge"},{"features":[{"many":true,"map":false,"name":"media","required":true,"types":["Media"]}],"name":"DiskRequest"},{"features":[{"many":false,"map":false,"name":"audience","required":true,"types":["Audience"]},{"many":false,"map":false,"name":"contentPolicy","required":false,"types":["ContentPolicy"]},{"many":false,"map":false,"name":"host","required":true,"types":["string"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]},{"many":true,"map":false,"name":"routes","required":true,"types":["Route"]}],"name":"Exposure"},{"features":[{"many":false,"map":false,"name":"class","required":true,"types":["string"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]}],"name":"GpuRequest"},{"features":[{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"port","required":true,"types":["int"]}],"name":"HttpProbe"},{"features":[{"many":false,"map":false,"name":"access","required":true,"types":["AccessTier"]},{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":true,"map":false,"name":"keys","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"KvGrant"},{"features":[],"name":"NoProbes","scalar":"none"},{"features":[{"many":false,"map":false,"name":"alertClass","required":true,"types":["AlertClass"]},{"many":false,"map":false,"name":"scrape","required":true,"types":["Scrape"]}],"name":"Observability"},{"features":[{"many":true,"map":false,"name":"arch","required":false,"types":["Arch"]},{"many":true,"map":false,"name":"capabilities","required":false,"types":["string"]},{"many":false,"map":false,"name":"cpu","required":true,"types":["string"]},{"many":false,"map":false,"name":"disk","required":false,"types":["DiskRequest"]},{"many":false,"map":false,"name":"gpu","required":false,"types":["GpuRequest"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]},{"many":false,"map":false,"name":"site","required":false,"types":["string"]}],"name":"Placement"},{"features":[{"many":false,"map":false,"name":"liveness","required":false,"types":["HttpProbe","TcpProbe"]},{"many":false,"map":false,"name":"readiness","required":false,"types":["HttpProbe","TcpProbe"]}],"name":"Probes"},{"features":[{"many":true,"map":false,"name":"assets","required":false,"types":["Asset"]},{"many":false,"map":false,"name":"cutover","required":true,"types":["Cutover"]},{"many":true,"map":false,"name":"dependsOn","required":false,"types":["DependencyEdge"]},{"many":false,"map":false,"name":"engine","required":false,"types":["Engine"]},{"many":false,"map":false,"name":"image","required":true,"types":["string"]},{"many":false,"map":false,"name":"lifecycle","required":true,"types":["Lifecycle"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]},{"many":false,"map":false,"name":"placement","required":true,"types":["Placement"]},{"many":false,"map":false,"name":"probes","required":false,"types":["NoProbes","Probes"]},{"many":false,"map":true,"name":"provides","required":false,"types":["int"]},{"many":false,"map":false,"name":"replicas","required":false,"types":["Capacity"]},{"many":false,"map":false,"name":"runtime","required":true,"types":["Runtime"]},{"many":true,"map":false,"name":"secrets","required":false,"types":["DatabaseGrant","KvGrant","TransitGrant"]},{"many":true,"map":false,"name":"sidecars","required":false,"types":["Sidecar"]},{"many":false,"map":false,"name":"startupBudget","required":false,"types":["string"]},{"many":true,"map":false,"name":"volumes","required":false,"types":["Volume"]},{"many":true,"map":false,"name":"writablePaths","required":false,"types":["string"]}],"name":"Process"},{"features":[{"many":false,"map":false,"name":"apiVersion","required":true,"types":["string"]},{"many":true,"map":false,"name":"applications","required":true,"types":["Application"]},{"many":false,"map":false,"name":"kind","required":true,"types":["string"]},{"many":false,"map":false,"name":"owner","required":true,"types":["string"]},{"many":false,"map":false,"name":"project","required":true,"types":["string"]},{"many":false,"map":false,"name":"schemaVersion","required":true,"types":["string"]}],"name":"Project"},{"features":[{"many":false,"map":false,"name":"maxAge","required":false,"types":["string"]},{"many":false,"map":false,"name":"tolerates","required":true,"types":["Tolerance"]}],"name":"Rotation"},{"features":[{"many":false,"map":false,"name":"audience","required":false,"types":["Audience"]},{"many":false,"map":false,"name":"match","required":true,"types":["Match"]},{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"process","required":true,"types":["string"]},{"many":false,"map":false,"name":"redirectTo","required":false,"types":["string"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"Route"},{"features":[{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"process","required":true,"types":["string"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"Scrape"},{"features":[{"many":false,"map":false,"name":"cpu","required":true,"types":["string"]},{"many":false,"map":false,"name":"image","required":true,"types":["string"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]}],"name":"Sidecar"},{"features":[{"many":false,"map":false,"name":"tcp","required":true,"types":["int"]}],"name":"TcpProbe"},{"features":[{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"engine","required":true,"types":["TransitEngine"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":false,"map":false,"name":"key","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":true,"map":false,"name":"operations","required":true,"types":["TransitOp"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"TransitGrant"},{"features":[{"many":false,"map":false,"name":"claim","required":true,"types":["string"]},{"many":false,"map":false,"name":"durability","required":true,"types":["DurabilityClass"]},{"many":false,"map":false,"name":"mountAt","required":true,"types":["string"]},{"many":false,"map":false,"name":"size","required":false,"types":["string"]}],"name":"Volume"}],"vocabularies":[{"literals":["read","self-renew","self-roll","custody"],"name":"AccessTier"},{"literals":["business-hours","urgent","page"],"name":"AlertClass"},{"literals":["amd64","arm64"],"name":"Arch"},{"literals":["anonymous","authenticated","internal","lan"],"name":"Audience"},{"literals":["strict","admin","workflow"],"name":"ContentPolicy"},{"literals":["rolling","recreate"],"name":"Cutover"},{"literals":["database"],"name":"DatabaseEngine"},{"literals":["env","file","self"],"name":"Delivery"},{"literals":["reconstructible","recoverable","irreplaceable"],"name":"DurabilityClass"},{"literals":["postgres","rabbitmq","valkey","files"],"name":"Engine"},{"literals":["application","job"],"name":"Lifecycle"},{"literals":["prefix","exact"],"name":"Match"},{"literals":["nvme","ssd","hdd"],"name":"Media"},{"literals":["jvm","python","node","static","none"],"name":"Runtime"},{"literals":["restart","reload"],"name":"Tolerance"},{"literals":["transit"],"name":"TransitEngine"},{"literals":["sign","verify","encrypt","decrypt","rotate"],"name":"TransitOp"}]} \ No newline at end of file +{"classes":[{"features":[{"many":true,"map":false,"name":"exposure","required":false,"types":["Exposure"]},{"many":false,"map":false,"name":"id","required":true,"types":["string"]},{"many":false,"map":false,"name":"observability","required":false,"types":["Observability"]},{"many":true,"map":false,"name":"processes","required":true,"types":["Process"]},{"many":true,"map":false,"name":"secrets","required":false,"types":["DatabaseGrant","KvGrant","TransitGrant"]}],"name":"Application"},{"features":[{"many":false,"map":false,"name":"from","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":true,"types":["string"]}],"name":"Asset"},{"features":[{"many":false,"map":false,"name":"count","required":true,"types":["int"]},{"many":false,"map":false,"name":"reason","required":true,"types":["string"]}],"name":"Capacity"},{"features":[{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"engine","required":true,"types":["DatabaseEngine"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":false,"map":false,"name":"role","required":true,"types":["string"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"DatabaseGrant"},{"features":[{"many":false,"map":false,"name":"application","required":true,"types":["string"]},{"many":false,"map":false,"name":"required","required":false,"types":["boolean"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"DependencyEdge"},{"features":[{"many":true,"map":false,"name":"media","required":true,"types":["Media"]}],"name":"DiskRequest"},{"features":[{"many":false,"map":false,"name":"audience","required":true,"types":["Audience"]},{"many":false,"map":false,"name":"contentPolicy","required":false,"types":["ContentPolicy"]},{"many":false,"map":false,"name":"host","required":true,"types":["string"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]},{"many":true,"map":false,"name":"routes","required":true,"types":["Route"]}],"name":"Exposure"},{"features":[{"many":false,"map":false,"name":"class","required":true,"types":["string"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]}],"name":"GpuRequest"},{"features":[{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"port","required":true,"types":["int"]}],"name":"HttpProbe"},{"features":[{"many":false,"map":false,"name":"access","required":true,"types":["AccessTier"]},{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":true,"map":false,"name":"keys","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"KvGrant"},{"features":[],"name":"NoProbes","scalar":"none"},{"features":[{"many":false,"map":false,"name":"alertClass","required":true,"types":["AlertClass"]},{"many":false,"map":false,"name":"scrape","required":false,"types":["Scrape"]}],"name":"Observability"},{"features":[{"many":true,"map":false,"name":"arch","required":false,"types":["Arch"]},{"many":true,"map":false,"name":"capabilities","required":false,"types":["string"]},{"many":false,"map":false,"name":"cpu","required":true,"types":["string"]},{"many":false,"map":false,"name":"disk","required":false,"types":["DiskRequest"]},{"many":false,"map":false,"name":"gpu","required":false,"types":["GpuRequest"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]},{"many":false,"map":false,"name":"site","required":false,"types":["string"]}],"name":"Placement"},{"features":[{"many":false,"map":false,"name":"liveness","required":false,"types":["HttpProbe","TcpProbe"]},{"many":false,"map":false,"name":"readiness","required":false,"types":["HttpProbe","TcpProbe"]}],"name":"Probes"},{"features":[{"many":true,"map":false,"name":"assets","required":false,"types":["Asset"]},{"many":false,"map":false,"name":"cutover","required":true,"types":["Cutover"]},{"many":true,"map":false,"name":"dependsOn","required":false,"types":["DependencyEdge"]},{"many":false,"map":false,"name":"engine","required":false,"types":["Engine"]},{"many":false,"map":false,"name":"image","required":true,"types":["string"]},{"many":false,"map":false,"name":"lifecycle","required":true,"types":["Lifecycle"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]},{"many":false,"map":false,"name":"placement","required":true,"types":["Placement"]},{"many":false,"map":false,"name":"probes","required":false,"types":["NoProbes","Probes"]},{"many":false,"map":true,"name":"provides","required":false,"types":["int"]},{"many":false,"map":false,"name":"replicas","required":false,"types":["Capacity"]},{"many":false,"map":false,"name":"runtime","required":true,"types":["Runtime"]},{"many":true,"map":false,"name":"secrets","required":false,"types":["DatabaseGrant","KvGrant","TransitGrant"]},{"many":true,"map":false,"name":"sidecars","required":false,"types":["Sidecar"]},{"many":false,"map":false,"name":"startupBudget","required":false,"types":["string"]},{"many":true,"map":false,"name":"volumes","required":false,"types":["Volume"]},{"many":true,"map":false,"name":"writablePaths","required":false,"types":["string"]}],"name":"Process"},{"features":[{"many":false,"map":false,"name":"apiVersion","required":true,"types":["string"]},{"many":true,"map":false,"name":"applications","required":true,"types":["Application"]},{"many":false,"map":false,"name":"kind","required":true,"types":["string"]},{"many":false,"map":false,"name":"owner","required":true,"types":["string"]},{"many":false,"map":false,"name":"project","required":true,"types":["string"]},{"many":false,"map":false,"name":"schemaVersion","required":true,"types":["string"]}],"name":"Project"},{"features":[{"many":false,"map":false,"name":"maxAge","required":false,"types":["string"]},{"many":false,"map":false,"name":"tolerates","required":true,"types":["Tolerance"]}],"name":"Rotation"},{"features":[{"many":false,"map":false,"name":"audience","required":false,"types":["Audience"]},{"many":false,"map":false,"name":"match","required":true,"types":["Match"]},{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"process","required":true,"types":["string"]},{"many":false,"map":false,"name":"redirectTo","required":false,"types":["string"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"Route"},{"features":[{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"process","required":true,"types":["string"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"Scrape"},{"features":[{"many":false,"map":false,"name":"cpu","required":true,"types":["string"]},{"many":false,"map":false,"name":"image","required":true,"types":["string"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]}],"name":"Sidecar"},{"features":[{"many":false,"map":false,"name":"tcp","required":true,"types":["int"]}],"name":"TcpProbe"},{"features":[{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"engine","required":true,"types":["TransitEngine"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":false,"map":false,"name":"key","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":true,"map":false,"name":"operations","required":true,"types":["TransitOp"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"TransitGrant"},{"features":[{"many":false,"map":false,"name":"claim","required":true,"types":["string"]},{"many":false,"map":false,"name":"durability","required":true,"types":["DurabilityClass"]},{"many":false,"map":false,"name":"mountAt","required":true,"types":["string"]},{"many":false,"map":false,"name":"size","required":false,"types":["string"]}],"name":"Volume"}],"vocabularies":[{"literals":["read","self-renew","self-roll","custody"],"name":"AccessTier"},{"literals":["business-hours","urgent","page"],"name":"AlertClass"},{"literals":["amd64","arm64"],"name":"Arch"},{"literals":["anonymous","authenticated","internal","lan"],"name":"Audience"},{"literals":["strict","admin","workflow"],"name":"ContentPolicy"},{"literals":["rolling","recreate"],"name":"Cutover"},{"literals":["database"],"name":"DatabaseEngine"},{"literals":["env","file","self"],"name":"Delivery"},{"literals":["reconstructible","recoverable","irreplaceable"],"name":"DurabilityClass"},{"literals":["postgres","rabbitmq","valkey","files"],"name":"Engine"},{"literals":["application","job"],"name":"Lifecycle"},{"literals":["prefix","exact"],"name":"Match"},{"literals":["nvme","ssd","hdd"],"name":"Media"},{"literals":["jvm","python","node","static","none"],"name":"Runtime"},{"literals":["restart","reload"],"name":"Tolerance"},{"literals":["transit"],"name":"TransitEngine"},{"literals":["sign","verify","encrypt","decrypt","rotate"],"name":"TransitOp"}]} \ No newline at end of file diff --git a/spec/v1/examples/refusals/README.md b/spec/v1/examples/refusals/README.md index cf999f4..bc737b7 100644 --- a/spec/v1/examples/refusals/README.md +++ b/spec/v1/examples/refusals/README.md @@ -9,17 +9,36 @@ one defect so the refusal has a single cause. | fixture | expects | why | |---|---|---| | [`alert-class-without-signal.project.yml`](alert-class-without-signal.project.yml) | `E_ALERT_CLASS_WITHOUT_SIGNAL` | an `observability` block carrying a class and no `scrape`. A class states how loudly to wake someone and means nothing without a signal to wake them about ([chapter 10](../../10-project-intent.md#observability)) | -| [`alert-class-unknown.project.yml`](alert-class-unknown.project.yml) | schema validation | a value outside the closed `AlertClass` vocabulary, refused before composition runs, so no new error code carries it | +| [`alert-class-unknown.project.yml`](alert-class-unknown.project.yml) | schema validation | a value outside the closed `AlertClass` vocabulary, refused before any rule runs, so no error code carries it | | [`cutover-rolling-over-rwo.project.yml`](cutover-rolling-over-rwo.project.yml) | `E_CUTOVER_UNHONOURABLE` | `cutover: rolling` over an RWO volume, which cannot surge ([chapter 10](../../10-project-intent.md#cutover-is-declared-not-promised)) | | [`cutover-recreate-over-rwo.project.yml`](cutover-recreate-over-rwo.project.yml) | accepted | the same Process and storage with the cutover it can honour, the pair that makes the refusal above meaningful | +| [`engine-without-durability.project.yml`](engine-without-durability.project.yml) | `E_ENGINE_WITHOUT_DURABILITY` | an `engine` over a volume whose durability derives no backup, so it names a method nothing asks for ([chapter 10](../../10-project-intent.md#process)) | +| [`durability-without-engine.project.yml`](durability-without-engine.project.yml) | `E_DURABILITY_WITHOUT_ENGINE` | a volume that asks for a backup on a Process that names no engine, so the method would have to be guessed ([chapter 10](../../10-project-intent.md#process)) | +| [`env-cannot-reload.project.yml`](env-cannot-reload.project.yml) | `E_ENV_CANNOT_RELOAD` | a grant delivered as an environment variable that tolerates a reload, which the process cannot see ([chapter 10](../../10-project-intent.md#zero-downtime-rotation)) | +| [`illegal-delivery-for-access.project.yml`](illegal-delivery-for-access.project.yml) | `E_ILLEGAL_DELIVERY_FOR_ACCESS` | `custody` asked for as a file: there is nothing to project at render time ([chapter 10](../../10-project-intent.md#which-tier-may-use-which-delivery)) | +| [`non-kv-delivery.project.yml`](non-kv-delivery.project.yml) | `E_NON_KV_DELIVERY` | a transit grant delivered as an environment variable, when a transit key is used rather than read ([chapter 10](../../10-project-intent.md#delivery)) | +| [`duplicate-route-match.project.yml`](duplicate-route-match.project.yml) | `E_DUPLICATE_ROUTE_MATCH` | two routes of one exposure sharing a `path` and a `match`, which derived precedence cannot order ([chapter 10](../../10-project-intent.md#what-is-checked)) | + +Every refused fixture carries a committed `.diagnostics.json` beside it: +the set of `(code, path)` pairs the model emits, where the path is the RFC 6901 +JSON Pointer of the object refused. Both implementations are held to that file +([the parity contract](../../../../docs/architecture.md#the-parity-contract)), +and it replaces the `expect:` header these fixtures used to carry. + +`alert-class-unknown.project.yml` carries no diagnostics oracle. A value outside +a closed vocabulary is refused by each implementation's own front end, before a +rule runs: the production parser can point at the field, and the model-driven +parser refuses the token. The code is the same and the place it can name is not, +so the case is not a parity oracle. There is no fixture for "no monitoring". An Application that wants none omits the `observability` block, which is an accepted input and appears in the worked set as `platform-valkey` rather than here. -These are **fixtures, not proof of rendered behaviour.** The compiler does not -exist yet, so `test/simplification-contract.test.ts` asserts them at the layer -that does: the shape of the input. +These are **fixtures, not proof of rendered behaviour.** No renderer exists yet, +so what is proven is the refusal itself: `test/model/refusals.test.ts` runs each +through the production parser, and `emf/parity`'s `ParityTest` runs each through +the model-driven one, both against the committed diagnostics. Two things therefore remain **unproven until a renderer exists**, and are named as blockers rather than described as verified: @@ -31,5 +50,3 @@ as blockers rather than described as verified: surface it points at, and that a block missing its `scrape` is refused at composition rather than merely being absent from the input. -The `expect:` key is fixture metadata. It is not part of the Project schema, and -no accepted worked example carries it. diff --git a/spec/v1/examples/refusals/alert-class-unknown.project.yml b/spec/v1/examples/refusals/alert-class-unknown.project.yml index e51c224..4e4e1ab 100644 --- a/spec/v1/examples/refusals/alert-class-unknown.project.yml +++ b/spec/v1/examples/refusals/alert-class-unknown.project.yml @@ -13,7 +13,6 @@ apiVersion: intent.jorisjonkers.dev/v1 kind: Project schemaVersion: 1.0.0 -expect: schema, alertClass is not a member of AlertClass project: refusals owner: joris diff --git a/spec/v1/examples/refusals/alert-class-without-signal.diagnostics.json b/spec/v1/examples/refusals/alert-class-without-signal.diagnostics.json new file mode 100644 index 0000000..6a15306 --- /dev/null +++ b/spec/v1/examples/refusals/alert-class-without-signal.diagnostics.json @@ -0,0 +1 @@ +[{"code":"E_ALERT_CLASS_WITHOUT_SIGNAL","path":"/applications/0/observability"}] \ No newline at end of file diff --git a/spec/v1/examples/refusals/alert-class-without-signal.project.yml b/spec/v1/examples/refusals/alert-class-without-signal.project.yml index b104a36..bf6852d 100644 --- a/spec/v1/examples/refusals/alert-class-without-signal.project.yml +++ b/spec/v1/examples/refusals/alert-class-without-signal.project.yml @@ -12,7 +12,6 @@ apiVersion: intent.jorisjonkers.dev/v1 kind: Project schemaVersion: 1.0.0 -expect: E_ALERT_CLASS_WITHOUT_SIGNAL project: refusals owner: joris diff --git a/spec/v1/examples/refusals/cutover-recreate-over-rwo.project.yml b/spec/v1/examples/refusals/cutover-recreate-over-rwo.project.yml index 32b9b67..0577f70 100644 --- a/spec/v1/examples/refusals/cutover-recreate-over-rwo.project.yml +++ b/spec/v1/examples/refusals/cutover-recreate-over-rwo.project.yml @@ -15,7 +15,6 @@ apiVersion: intent.jorisjonkers.dev/v1 kind: Project schemaVersion: 1.0.0 -expect: accepted project: refusals owner: joris @@ -29,7 +28,6 @@ applications: lifecycle: application image: recreate-over-rwo-store runtime: static - engine: valkey provides: redis: 6379 diff --git a/spec/v1/examples/refusals/cutover-rolling-over-rwo.diagnostics.json b/spec/v1/examples/refusals/cutover-rolling-over-rwo.diagnostics.json new file mode 100644 index 0000000..0fc0e91 --- /dev/null +++ b/spec/v1/examples/refusals/cutover-rolling-over-rwo.diagnostics.json @@ -0,0 +1 @@ +[{"code":"E_CUTOVER_UNHONOURABLE","path":"/applications/0/processes/0"}] \ No newline at end of file diff --git a/spec/v1/examples/refusals/cutover-rolling-over-rwo.project.yml b/spec/v1/examples/refusals/cutover-rolling-over-rwo.project.yml index 3848c85..2d55251 100644 --- a/spec/v1/examples/refusals/cutover-rolling-over-rwo.project.yml +++ b/spec/v1/examples/refusals/cutover-rolling-over-rwo.project.yml @@ -17,7 +17,6 @@ apiVersion: intent.jorisjonkers.dev/v1 kind: Project schemaVersion: 1.0.0 -expect: E_CUTOVER_UNHONOURABLE project: refusals owner: joris @@ -31,7 +30,6 @@ applications: lifecycle: application image: rolling-over-rwo-store runtime: static - engine: valkey provides: redis: 6379 diff --git a/spec/v1/examples/refusals/duplicate-route-match.diagnostics.json b/spec/v1/examples/refusals/duplicate-route-match.diagnostics.json new file mode 100644 index 0000000..516c335 --- /dev/null +++ b/spec/v1/examples/refusals/duplicate-route-match.diagnostics.json @@ -0,0 +1 @@ +[{"code":"E_DUPLICATE_ROUTE_MATCH","path":"/applications/0/exposure/0/routes/1"}] \ No newline at end of file diff --git a/spec/v1/examples/refusals/duplicate-route-match.project.yml b/spec/v1/examples/refusals/duplicate-route-match.project.yml new file mode 100644 index 0000000..28b65b5 --- /dev/null +++ b/spec/v1/examples/refusals/duplicate-route-match.project.yml @@ -0,0 +1,37 @@ +# REFUSED: E_DUPLICATE_ROUTE_MATCH +# +# Route precedence is derived: exact before prefix, longer prefix before +# shorter. Two routes sharing a path and a match cannot be ordered by that rule, +# so the pair is refused rather than resolved by document order +# (spec/v1/10-project-intent.md #what-is-checked). + +apiVersion: intent.jorisjonkers.dev/v1 +kind: Project +schemaVersion: 1.0.0 +project: refusals +owner: joris +applications: + - id: duplicate-route-match + exposure: + - name: public + host: duplicate-route-match.jorisjonkers.dev + audience: anonymous + contentPolicy: strict + routes: + - { path: /api, match: prefix, process: duplicate-route-match-api, surface: http } + - { path: /api, match: prefix, process: duplicate-route-match-api, surface: http } + processes: + - name: duplicate-route-match-api + lifecycle: application + image: duplicate-route-match-api + runtime: node + provides: + http: 8080 + placement: + memory: 128Mi + cpu: 25m + probes: + readiness: { path: /healthz/ready, port: 8080 } + liveness: { path: /healthz/live, port: 8080 } + startupBudget: 20s + cutover: rolling diff --git a/spec/v1/examples/refusals/durability-without-engine.diagnostics.json b/spec/v1/examples/refusals/durability-without-engine.diagnostics.json new file mode 100644 index 0000000..6b7de30 --- /dev/null +++ b/spec/v1/examples/refusals/durability-without-engine.diagnostics.json @@ -0,0 +1 @@ +[{"code":"E_DURABILITY_WITHOUT_ENGINE","path":"/applications/0/processes/0/volumes/0"}] \ No newline at end of file diff --git a/spec/v1/examples/refusals/durability-without-engine.project.yml b/spec/v1/examples/refusals/durability-without-engine.project.yml new file mode 100644 index 0000000..178dd60 --- /dev/null +++ b/spec/v1/examples/refusals/durability-without-engine.project.yml @@ -0,0 +1,34 @@ +# REFUSED: E_DURABILITY_WITHOUT_ENGINE +# +# The volume asks for a backup, and the backup method comes from the Process's +# `engine`, which is absent. The platform would have to guess how to take the +# backup, and a guessed backup is the kind nobody finds out about until a +# restore (spec/v1/10-project-intent.md #process). + +apiVersion: intent.jorisjonkers.dev/v1 +kind: Project +schemaVersion: 1.0.0 +project: refusals +owner: joris +applications: + - id: durability-without-engine + processes: + - name: durability-without-engine-store + lifecycle: application + image: durability-without-engine-store + runtime: static + provides: + http: 8080 + placement: + memory: 128Mi + cpu: 25m + probes: + readiness: { path: /healthz/ready, port: 8080 } + liveness: { path: /healthz/live, port: 8080 } + startupBudget: 20s + cutover: recreate + volumes: + - claim: durability-without-engine-data + mountAt: /data + size: 1Gi + durability: irreplaceable diff --git a/spec/v1/examples/refusals/engine-without-durability.diagnostics.json b/spec/v1/examples/refusals/engine-without-durability.diagnostics.json new file mode 100644 index 0000000..0b5af71 --- /dev/null +++ b/spec/v1/examples/refusals/engine-without-durability.diagnostics.json @@ -0,0 +1 @@ +[{"code":"E_ENGINE_WITHOUT_DURABILITY","path":"/applications/0/processes/0"}] \ No newline at end of file diff --git a/spec/v1/examples/refusals/engine-without-durability.project.yml b/spec/v1/examples/refusals/engine-without-durability.project.yml new file mode 100644 index 0000000..299b477 --- /dev/null +++ b/spec/v1/examples/refusals/engine-without-durability.project.yml @@ -0,0 +1,38 @@ +# REFUSED: E_ENGINE_WITHOUT_DURABILITY +# +# `engine` names what the process is where the platform has to treat it +# specially, and what it keys the backup method off. This Process declares one +# over a volume whose durability derives no backup, so the engine names a method +# nothing asks for (spec/v1/10-project-intent.md #process). +# +# Its counterpart is durability-without-engine.project.yml: the same pair with +# the other half missing. + +apiVersion: intent.jorisjonkers.dev/v1 +kind: Project +schemaVersion: 1.0.0 +project: refusals +owner: joris +applications: + - id: engine-without-durability + processes: + - name: engine-without-durability-store + lifecycle: application + image: engine-without-durability-store + runtime: static + provides: + http: 8080 + placement: + memory: 128Mi + cpu: 25m + probes: + readiness: { path: /healthz/ready, port: 8080 } + liveness: { path: /healthz/live, port: 8080 } + startupBudget: 20s + cutover: recreate + engine: valkey + volumes: + - claim: engine-without-durability-data + mountAt: /data + size: 1Gi + durability: reconstructible diff --git a/spec/v1/examples/refusals/env-cannot-reload.diagnostics.json b/spec/v1/examples/refusals/env-cannot-reload.diagnostics.json new file mode 100644 index 0000000..09c0bd3 --- /dev/null +++ b/spec/v1/examples/refusals/env-cannot-reload.diagnostics.json @@ -0,0 +1 @@ +[{"code":"E_ENV_CANNOT_RELOAD","path":"/applications/0/processes/0/secrets/0/rotation"}] \ No newline at end of file diff --git a/spec/v1/examples/refusals/env-cannot-reload.project.yml b/spec/v1/examples/refusals/env-cannot-reload.project.yml new file mode 100644 index 0000000..de7e392 --- /dev/null +++ b/spec/v1/examples/refusals/env-cannot-reload.project.yml @@ -0,0 +1,34 @@ +# REFUSED: E_ENV_CANNOT_RELOAD +# +# An environment variable is read once, when the process starts. A grant that +# delivers one and tolerates a reload rather than a restart promises a rotation +# the process cannot see (spec/v1/10-project-intent.md #zero-downtime-rotation). + +apiVersion: intent.jorisjonkers.dev/v1 +kind: Project +schemaVersion: 1.0.0 +project: refusals +owner: joris +applications: + - id: env-cannot-reload + processes: + - name: env-cannot-reload-api + lifecycle: application + image: env-cannot-reload-api + runtime: node + provides: + http: 8080 + placement: + memory: 128Mi + cpu: 25m + probes: + readiness: { path: /healthz/ready, port: 8080 } + liveness: { path: /healthz/live, port: 8080 } + startupBudget: 20s + cutover: rolling + secrets: + - path: secret/data/refusals/env-cannot-reload + keys: [password] + access: read + delivery: env + rotation: { tolerates: reload } diff --git a/spec/v1/examples/refusals/illegal-delivery-for-access.diagnostics.json b/spec/v1/examples/refusals/illegal-delivery-for-access.diagnostics.json new file mode 100644 index 0000000..848419f --- /dev/null +++ b/spec/v1/examples/refusals/illegal-delivery-for-access.diagnostics.json @@ -0,0 +1 @@ +[{"code":"E_ILLEGAL_DELIVERY_FOR_ACCESS","path":"/applications/0/processes/0/secrets/0"}] \ No newline at end of file diff --git a/spec/v1/examples/refusals/illegal-delivery-for-access.project.yml b/spec/v1/examples/refusals/illegal-delivery-for-access.project.yml new file mode 100644 index 0000000..4a089ef --- /dev/null +++ b/spec/v1/examples/refusals/illegal-delivery-for-access.project.yml @@ -0,0 +1,37 @@ +# REFUSED: E_ILLEGAL_DELIVERY_FOR_ACCESS +# +# `custody` creates and deletes secrets under a prefix at runtime, so there is +# nothing to project when the render runs. Asking for it as a file asks the +# renderer to sync paths that do not exist yet +# (spec/v1/10-project-intent.md #which-tier-may-use-which-delivery). + +apiVersion: intent.jorisjonkers.dev/v1 +kind: Project +schemaVersion: 1.0.0 +project: refusals +owner: joris +applications: + - id: illegal-delivery-for-access + processes: + - name: illegal-delivery-for-access-api + lifecycle: application + image: illegal-delivery-for-access-api + runtime: node + provides: + http: 8080 + placement: + memory: 128Mi + cpu: 25m + probes: + readiness: { path: /healthz/ready, port: 8080 } + liveness: { path: /healthz/live, port: 8080 } + startupBudget: 20s + cutover: rolling + secrets: + - path: secret/data/refusals/custody + keys: [token] + access: custody + delivery: file + mountAt: /run/secrets/token + fileMode: "0400" + rotation: { tolerates: restart } diff --git a/spec/v1/examples/refusals/non-kv-delivery.diagnostics.json b/spec/v1/examples/refusals/non-kv-delivery.diagnostics.json new file mode 100644 index 0000000..a51e2de --- /dev/null +++ b/spec/v1/examples/refusals/non-kv-delivery.diagnostics.json @@ -0,0 +1 @@ +[{"code":"E_NON_KV_DELIVERY","path":"/applications/0/processes/0/secrets/0"}] \ No newline at end of file diff --git a/spec/v1/examples/refusals/non-kv-delivery.project.yml b/spec/v1/examples/refusals/non-kv-delivery.project.yml new file mode 100644 index 0000000..a7ba326 --- /dev/null +++ b/spec/v1/examples/refusals/non-kv-delivery.project.yml @@ -0,0 +1,35 @@ +# REFUSED: E_NON_KV_DELIVERY +# +# A transit key is used, never read: the engine signs and rotates on the +# caller's behalf, and there is no value to write into a variable or a file. Its +# only legal delivery is `self` +# (spec/v1/10-project-intent.md #delivery). + +apiVersion: intent.jorisjonkers.dev/v1 +kind: Project +schemaVersion: 1.0.0 +project: refusals +owner: joris +applications: + - id: non-kv-delivery + processes: + - name: non-kv-delivery-api + lifecycle: application + image: non-kv-delivery-api + runtime: node + provides: + http: 8080 + placement: + memory: 128Mi + cpu: 25m + probes: + readiness: { path: /healthz/ready, port: 8080 } + liveness: { path: /healthz/live, port: 8080 } + startupBudget: 20s + cutover: rolling + secrets: + - engine: transit + key: non-kv-delivery-jwt + operations: [sign] + delivery: env + rotation: { tolerates: restart } diff --git a/spec/v1/schemas/project-intent.schema.json b/spec/v1/schemas/project-intent.schema.json index 06dce10..238ca4c 100644 --- a/spec/v1/schemas/project-intent.schema.json +++ b/spec/v1/schemas/project-intent.schema.json @@ -102,8 +102,7 @@ } }, "required": [ - "alertClass", - "scrape" + "alertClass" ], "additionalProperties": false }, diff --git a/src/domain/project-intent/model.ts b/src/domain/project-intent/model.ts index 6284f40..e2e4396 100644 --- a/src/domain/project-intent/model.ts +++ b/src/domain/project-intent/model.ts @@ -37,7 +37,8 @@ export interface SurfaceRef { export interface Observability { readonly alertClass: AlertClass; - readonly scrape: SurfaceRef & { readonly path: string }; + /** Whole or absent in the model: a document with a class and no signal is refused. */ + readonly scrape?: SurfaceRef & { readonly path: string }; } export interface Exposure { diff --git a/src/wire/project-intent/map.ts b/src/wire/project-intent/map.ts index 487cb41..f7de953 100644 --- a/src/wire/project-intent/map.ts +++ b/src/wire/project-intent/map.ts @@ -6,6 +6,7 @@ import type { Process, Project, } from "../../domain/project-intent/model.ts"; +import { ruleDiagnostics } from "./rules.ts"; import { projectIntent, type ProjectIntentDocument } from "./schema.ts"; type WireApplication = ProjectIntentDocument["applications"][number]; @@ -83,6 +84,8 @@ export function validateProjectIntent( hint: "Correct the field against spec/v1/10-project-intent.md.", })), }; + const refusals = ruleDiagnostics(parsed.data); + if (refusals.length > 0) return { ok: false, diagnostics: refusals }; const { project, owner, applications } = parsed.data; return { ok: true, diff --git a/src/wire/project-intent/rules.ts b/src/wire/project-intent/rules.ts new file mode 100644 index 0000000..64135c5 --- /dev/null +++ b/src/wire/project-intent/rules.ts @@ -0,0 +1,134 @@ +// The rules one authored document answers on its own: the counterpart of the +// Complete OCL invariants the model-driven implementation evaluates, and the +// same refusals at the same places. Each carries the code the specification +// gives it and the JSON Pointer of the object it refuses +// (docs/architecture.md#the-parity-contract), which is the object an invariant +// takes as its context. A rule that needs more than one document belongs to +// composition, not here. +import type { Diagnostic } from "../../domain/diagnostic.ts"; +import type { ProjectIntentDocument } from "./schema.ts"; + +type Application = ProjectIntentDocument["applications"][number]; +type Process = Application["processes"][number]; +type Grant = NonNullable[number]; + +interface Refusal { + readonly code: string; + readonly path: string; + readonly message: string; + readonly hint: string; +} + +/** The durability classes the platform derives a backup for, which need an engine. */ +const BACKED_UP = new Set(["recoverable", "irreplaceable"]); + +/** The access and delivery pairs chapter 10's matrix refuses. */ +const ILLEGAL_DELIVERY = new Set([ + "self-renew/env", + "custody/env", + "custody/file", +]); + +function grantRefusals(grant: Grant, at: string): Refusal[] { + const refusals: Refusal[] = []; + if (grant.delivery === "env" && grant.rotation?.tolerates === "reload") + refusals.push({ + code: "E_ENV_CANNOT_RELOAD", + path: `${at}/rotation`, + message: "an environment variable cannot be reloaded without a restart", + hint: "Deliver the secret as a file or through the application itself, or tolerate a restart.", + }); + if ( + "access" in grant && + ILLEGAL_DELIVERY.has(`${grant.access}/${grant.delivery}`) + ) + refusals.push({ + code: "E_ILLEGAL_DELIVERY_FOR_ACCESS", + path: at, + message: `access ${grant.access} cannot be delivered as ${grant.delivery}`, + hint: "See the access by delivery matrix in spec/v1/10-project-intent.md#which-tier-may-use-which-delivery.", + }); + if ("engine" in grant && grant.delivery !== "self") + refusals.push({ + code: "E_NON_KV_DELIVERY", + path: at, + message: `a ${grant.engine} grant is delivered by the application itself`, + hint: "Write `delivery: self`: the value is fetched, never projected.", + }); + return refusals; +} + +function processRefusals(process: Process, at: string): Refusal[] { + const refusals: Refusal[] = []; + const volumes = process.volumes ?? []; + if (process.cutover === "rolling" && volumes.length > 0) + refusals.push({ + code: "E_CUTOVER_UNHONOURABLE", + path: at, + message: "a volume cannot attach to the surge a rolling cutover needs", + hint: "Declare `cutover: recreate`, which is what this storage can honour.", + }); + const backedUp = volumes.filter((volume) => BACKED_UP.has(volume.durability)); + if (process.engine !== undefined && backedUp.length === 0) + refusals.push({ + code: "E_ENGINE_WITHOUT_DURABILITY", + path: at, + message: `engine ${process.engine} names a backup method nothing here derives`, + hint: "Declare a volume whose durability derives a backup, or drop the engine.", + }); + if (process.engine === undefined) + for (const [index, volume] of volumes.entries()) + if (BACKED_UP.has(volume.durability)) + refusals.push({ + code: "E_DURABILITY_WITHOUT_ENGINE", + path: `${at}/volumes/${index}`, + message: `durability ${volume.durability} derives a backup, and the backup method comes from the engine`, + hint: "Declare the Process's `engine`, or a durability that derives no backup.", + }); + for (const [index, grant] of (process.secrets ?? []).entries()) + refusals.push(...grantRefusals(grant, `${at}/secrets/${index}`)); + return refusals; +} + +function applicationRefusals(application: Application, at: string): Refusal[] { + const refusals: Refusal[] = []; + if ( + application.observability !== undefined && + application.observability.scrape === undefined + ) + refusals.push({ + code: "E_ALERT_CLASS_WITHOUT_SIGNAL", + path: `${at}/observability`, + message: + "a class states how loudly to wake someone, and no signal says what about", + hint: "Declare the `scrape` that carries the signal, or omit the block.", + }); + for (const [index, exposure] of (application.exposure ?? []).entries()) { + const seen = new Set(); + for (const [route, { path, match }] of exposure.routes.entries()) { + const pair = `${match} ${path}`; + if (seen.has(pair)) + refusals.push({ + code: "E_DUPLICATE_ROUTE_MATCH", + path: `${at}/exposure/${index}/routes/${route}`, + message: `two routes share the ${match} match of ${path}`, + hint: "Two routes that cannot be ordered are one route: delete or narrow one.", + }); + seen.add(pair); + } + } + for (const [index, grant] of (application.secrets ?? []).entries()) + refusals.push(...grantRefusals(grant, `${at}/secrets/${index}`)); + for (const [index, process] of application.processes.entries()) + refusals.push(...processRefusals(process, `${at}/processes/${index}`)); + return refusals; +} + +/** Every rule this document breaks, in the order the document reads. */ +export function ruleDiagnostics( + document: ProjectIntentDocument, +): readonly Diagnostic[] { + return document.applications.flatMap((application, index) => + applicationRefusals(application, `/applications/${index}`), + ); +} diff --git a/src/wire/project-intent/schema.ts b/src/wire/project-intent/schema.ts index d773c5a..24f939c 100644 --- a/src/wire/project-intent/schema.ts +++ b/src/wire/project-intent/schema.ts @@ -200,8 +200,10 @@ const scrape = z .strictObject({ process: text, surface: text, path: text }) .meta({ id: "Scrape" }); +// `scrape` is optional in the shape, not in the model: a block carrying a class +// and no signal is refused by a rule with its own code, not by the schema. const observability = z - .strictObject({ alertClass: alertClass, scrape }) + .strictObject({ alertClass: alertClass, scrape: scrape.exactOptional() }) .meta({ id: "Observability" }); const application = z diff --git a/test/model/project-intent.test.ts b/test/model/project-intent.test.ts index 585670a..1879728 100644 --- a/test/model/project-intent.test.ts +++ b/test/model/project-intent.test.ts @@ -314,6 +314,14 @@ describe("parseProjectIntent", () => { ]); }); + it("accepts a grant that declares no rotation", () => { + const text = withApplications( + ` - id: batch\n processes:\n${PROCESS} secrets:\n - path: secret/data/batch\n keys: [password]\n access: read\n delivery: env\n`, + ); + + expect(parseProjectIntent(text).ok).toBe(true); + }); + it("gives every diagnostic a hint", () => { const diagnostics = ["", `${HEADER}applications: []\n`].flatMap((text) => { const result = parseProjectIntent(text); diff --git a/test/model/refusals.test.ts b/test/model/refusals.test.ts new file mode 100644 index 0000000..50c1c3a --- /dev/null +++ b/test/model/refusals.test.ts @@ -0,0 +1,153 @@ +// REQ-024 (docs/requirements.md): a document that breaks a rule is refused with +// the code and the path its committed diagnostics oracle names. +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { canonicalJson, parseProjectIntent } from "../../src/index.ts"; + +const REFUSALS = join( + import.meta.dirname, + "..", + "..", + "spec", + "v1", + "examples", + "refusals", +); + +const fixtures = readdirSync(REFUSALS) + .filter((name) => name.endsWith(".project.yml")) + .map((name) => name.replace(".project.yml", "")) + .sort(); + +const read = (name: string): string => + readFileSync(join(REFUSALS, name), "utf8"); + +const oracle = (stem: string): string | undefined => { + const file = `${stem}.diagnostics.json`; + return readdirSync(REFUSALS).includes(file) ? read(file) : undefined; +}; + +const refused = fixtures.filter((stem) => oracle(stem) !== undefined); + +const DOCUMENT = `apiVersion: intent.jorisjonkers.dev/v1 +kind: Project +schemaVersion: 1.0.0 +project: refusals +owner: joris +applications: + - id: batch +GRANTS processes: + - name: worker + lifecycle: job + image: worker + runtime: none + placement: { memory: 64Mi, cpu: 10m } + cutover: recreate +`; + +/** The document above with `secrets` on the Application, indented as the file reads. */ +const withApplicationGrant = (grant: string): string => + DOCUMENT.replace("GRANTS", ` secrets:\n${grant}`); + +const refusalsOf = (text: string): { code: string; path: string }[] => { + const result = parseProjectIntent(text); + return result.ok + ? [] + : result.diagnostics.map(({ code, path }) => ({ code, path })); +}; + +describe("the access by delivery matrix", () => { + it.each([ + ["self-renew", "env"], + ["custody", "env"], + ["custody", "file"], + ])("refuses access %s delivered as %s", (access, delivery) => { + const grant = ` - path: secret/data/batch\n keys: [password]\n access: ${access}\n delivery: ${delivery}\n mountAt: /run/secrets/password\n rotation: { tolerates: restart }\n`; + + expect(refusalsOf(withApplicationGrant(grant))).toStrictEqual([ + { + code: "E_ILLEGAL_DELIVERY_FOR_ACCESS", + path: "/applications/0/secrets/0", + }, + ]); + }); + + it.each([ + ["read", "env"], + ["read", "file"], + ["self-renew", "self"], + ["self-roll", "file"], + ])("accepts access %s delivered as %s", (access, delivery) => { + const grant = ` - path: secret/data/batch\n keys: [password]\n access: ${access}\n delivery: ${delivery}\n mountAt: /run/secrets/password\n rotation: { tolerates: restart }\n`; + + expect(refusalsOf(withApplicationGrant(grant))).toStrictEqual([]); + }); + + it("points at the Application's own grant when the grant is the Application's", () => { + const grant = ` - path: secret/data/batch\n keys: [password]\n access: read\n delivery: env\n rotation: { tolerates: reload }\n`; + + expect(refusalsOf(withApplicationGrant(grant))).toStrictEqual([ + { + code: "E_ENV_CANNOT_RELOAD", + path: "/applications/0/secrets/0/rotation", + }, + ]); + }); +}); + +describe("the refusal fixtures", () => { + it("are the ones this chapter carries, refused but for the accepted counterpart", () => { + expect(fixtures).toStrictEqual([ + "alert-class-unknown", + "alert-class-without-signal", + "cutover-recreate-over-rwo", + "cutover-rolling-over-rwo", + "duplicate-route-match", + "durability-without-engine", + "engine-without-durability", + "env-cannot-reload", + "illegal-delivery-for-access", + "non-kv-delivery", + ]); + expect(refused).toHaveLength(8); + expect( + fixtures.length - refused.length, + "the accepted counterpart and the vocabulary case carry no oracle", + ).toBe(2); + }); + + it.each(refused)( + "%s is refused with the codes and paths its oracle names", + (stem) => { + const result = parseProjectIntent(read(`${stem}.project.yml`)); + const pairs = result.ok + ? [] + : result.diagnostics + .map(({ code, path }) => ({ code, path })) + .sort((a, b) => + `${a.code}${a.path}` < `${b.code}${b.path}` ? -1 : 1, + ); + + expect(canonicalJson(pairs)).toBe(oracle(stem)); + }, + ); + + it("accepts the counterpart that declares the cutover its storage can honour", () => { + expect( + parseProjectIntent(read("cutover-recreate-over-rwo.project.yml")).ok, + ).toBe(true); + expect(oracle("cutover-recreate-over-rwo")).toBeUndefined(); + }); + + it.each(refused)("%s says what it refused and how to fix it", (stem) => { + const result = parseProjectIntent(read(`${stem}.project.yml`)); + const diagnostics = result.ok ? [] : result.diagnostics; + + expect(diagnostics).not.toHaveLength(0); + for (const { message, hint } of diagnostics) { + expect(message.trim()).not.toBe(""); + expect(hint.trim()).not.toBe(""); + } + }); +}); diff --git a/test/simplification-contract.test.ts b/test/simplification-contract.test.ts index c86fff1..6733e6a 100644 --- a/test/simplification-contract.test.ts +++ b/test/simplification-contract.test.ts @@ -1,27 +1,31 @@ -// The v1 simplification's fixture-level proof. +// The v1 simplification's proof, over the parsed model. // -// The compiler does not exist yet, so the handoff asks for executable -// fixture-level checks at the narrowest layer available, with any renderer -// proof reported as a blocker. This file is that check, per decision: +// The decisions this file holds the example estate to: // // 1. Observability: one optional `observability` block per Application, whole or // absent. A declared class names a scrape surface that its own Process // provides, and no project file carries monitoring policy. -// 2. Cutover: `zeroDowntime` is gone, every Process declares `cutover`, and an -// RWO Process must declare `recreate` (rolling over RWO is the -// E_CUTOVER_UNHONOURABLE case; there is no renderer yet to run it in). +// 2. Cutover: `zeroDowntime` is gone, every Process declares `cutover`, and a +// Process with a volume declares `recreate`, because rolling over an RWO +// volume is `E_CUTOVER_UNHONOURABLE`. // 3. Overrides: no `overrides` key anywhere, and a `replicas` block always // carries a count above one with a reason. // 4. Hardening: no Process or sidecar authors hardening at all. // +// Every check reads the parsed model rather than the file's indentation: a test +// that reads YAML by column proves the layout, not the language (#38). // spec/v1 is normative; the ADRs justify; this file proves the example estate -// against them at the fixture layer. -import { readFileSync } from "node:fs"; +// against them. +import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { expect, test } from "vitest"; +import { parseProjectIntent } from "../src/index.ts"; +import type { Application, Process, Project } from "../src/index.ts"; const repo = join(import.meta.dirname, ".."); const examples = join(repo, "spec", "v1", "examples"); +const refusals = join(examples, "refusals"); +const platform = join(examples, "platform", "platform.intent.yml"); const read = (path: string): string => readFileSync(path, "utf8"); const projectFiles = [ @@ -31,212 +35,96 @@ const projectFiles = [ "minimal/notes.project.yml", ].map((file) => join(examples, file)); -interface Slice { - readonly name: string; - text: string; -} +const refusalFiles = readdirSync(refusals) + .filter((name) => name.endsWith(".project.yml")) + .map((name) => join(refusals, name)); /** - * The processes of a project file as {name, text} slices. Indentation-keyed: - * a process starts at ` - name:` (six spaces) and runs to the next one. - * Exposure `routes` and negative-fixture files do not reach six spaces with - * `- name:`, but an Application's `exposure` entry is ` - name: public`, which - * collides, so a slice that would open inside an `exposure:` block is skipped. + * The parsed project of a file, or nothing when the file is a fixture the model + * refuses: a refusal fixture's own oracle is what proves its refusal, and what + * is checked here is what the accepted half of the estate declares. */ -const PROCESS_RE = /^ {6}- name: (\S+)/; - -function processesOf(file: string): Slice[] { - const out: Slice[] = []; - let current: Slice | null = null; - let inExposure = false; - for (const line of read(file).split("\n")) { - if (/^ {4}[a-zA-Z]/.test(line)) inExposure = /^ {4}exposure:/.test(line); - const m = PROCESS_RE.exec(line); - // An Application-level `exposure` entry sits at the same indent as a Process - // under `processes:`; only slices opened outside the exposure block are - // processes. - if (m && !inExposure) { - if (current) out.push(current); - current = { name: m[1] ?? "", text: "" }; - } else if (current) { - current.text += `${line}\n`; - } - } - if (current) out.push(current); - return out; +function projectOf(file: string): Project | undefined { + const result = parseProjectIntent(read(file)); + if (!result.ok && projectFiles.includes(file)) + throw new Error(`${file}: ${JSON.stringify(result.diagnostics)}`); + return result.ok ? result.value.project : undefined; } +const applicationsOf = (file: string): readonly Application[] => + projectOf(file)?.applications ?? []; + +const processesOf = (file: string): readonly Process[] => + applicationsOf(file).flatMap((application) => application.processes); + +/** A file's declared lines, with whole-line and trailing comments removed. */ +const declarationsOf = (file: string): string => + read(file) + .split("\n") + .map((line) => line.replace(/(^|\s)#.*$/, "")) + .join("\n"); + test("every worked Process declares cutover, and zeroDowntime is gone", () => { for (const file of projectFiles) { const processes = processesOf(file); + expect(processes.length, `${file}: no processes parsed`).toBeGreaterThan(0); - for (const w of processes) { - const rel = `${file.split("/").pop() ?? file}#${w.name}`; - expect(w.text, `${rel}: no cutover declaration`).toMatch( - /^\s+cutover: (rolling|recreate)$/m, - ); - expect(w.text, `${rel}: zeroDowntime present`).not.toMatch( - /zeroDowntime/, + for (const process of processes) + expect(["rolling", "recreate"], `${process.name}: cutover`).toContain( + process.cutover, ); - } + expect(declarationsOf(file), `${file}: zeroDowntime present`).not.toMatch( + /zeroDowntime/, + ); } }); -test("RWO Processes declare recreate; volume-free Processes declare rolling", () => { - for (const file of projectFiles) { - for (const w of processesOf(file)) { - const hasVolume = /^\s+volumes:$/m.test(w.text); - const cutover = /^\s+cutover: (rolling|recreate)$/m.exec(w.text)?.[1]; - expect(cutover, `${w.name}: cutover missing`).toBeDefined(); - if (hasVolume) - expect( - cutover, - `${w.name}: an RWO volume cannot surge, so rolling is E_CUTOVER_UNHONOURABLE`, - ).toBe("recreate"); - } - } +test("a Process with a volume declares recreate, because RWO cannot surge", () => { + for (const file of [...projectFiles, ...refusalFiles]) + for (const process of processesOf(file).filter( + (candidate) => candidate.volumes.length > 0, + )) + expect( + process.cutover, + `${process.name}: an RWO volume cannot surge, so rolling is E_CUTOVER_UNHONOURABLE`, + ).toBe("recreate"); }); test("no project file carries overrides, and replicas is the sole capacity exception", () => { for (const file of projectFiles) { - const text = read(file); + const text = declarationsOf(file); + expect(text, `${file}: overrides key present`).not.toMatch(/^overrides:/m); expect(text, `${file}: override entry syntax present`).not.toMatch( /derivation:/, ); - for (const w of processesOf(file)) { - const replicas = - /^\s+replicas:\s*$\n\s+count: (\d+)(?:\n\s+reason: (.+))?/m.exec( - w.text, - ); - if (!replicas) continue; - expect( - Number(replicas[1]), - `${w.name}: count must exceed one`, - ).toBeGreaterThan(1); - expect( - replicas[2]?.trim() ?? "", - `${w.name}: reason required with replicas`, - ).not.toBe(""); + for (const { name, replicas } of processesOf(file)) { + if (replicas === undefined) continue; + expect(replicas.count, `${name}: count must exceed one`).toBeGreaterThan( + 1, + ); + expect(replicas.reason, `${name}: reason required`).not.toBe(""); } } }); -/** A file's declared lines, with whole-line and trailing comments removed. */ -const declarationsOf = (file: string): string => - read(file) - .split("\n") - .map((line) => line.replace(/(^|\s)#.*$/, "")) - .join("\n"); - -const refusals = join(examples, "refusals"); -const platform = join(examples, "platform", "platform.intent.yml"); - -interface Application { - readonly id: string; - text: string; -} - -/** - * The Applications of a project file as {id, text} slices. An Application starts at - * ` - id:` (two spaces) and runs to the next one, so an Application's - * `observability` block and its Processes are read together. - */ -function applicationsOf(file: string): Application[] { - const out: Application[] = []; - let current: Application | null = null; - for (const line of read(file).split("\n")) { - const m = /^ {2}- id: (\S+)/.exec(line); - if (m) { - if (current) out.push(current); - current = { id: m[1] ?? "", text: "" }; - } else if (current) { - current.text += `${line}\n`; - } - } - if (current) out.push(current); - return out; -} - -interface Observability { - readonly alertClass: string | null; - readonly hasScrape: boolean; - readonly process: string | null; - readonly surface: string | null; - readonly path: string | null; -} - -/** The `observability` block of an Application slice, or null when it declares none. */ -function observabilityOf(applicationText: string): Observability | null { - const lines = applicationText.split("\n"); - const start = lines.findIndex((line) => /^ {4}observability:\s*$/.test(line)); - if (start === -1) return null; - const body: string[] = []; - for (const line of lines.slice(start + 1)) { - if (line.trim() === "" || line.trim().startsWith("#")) continue; - if (!/^ {6}/.test(line)) break; - body.push(line); - } - const value = (key: string): string | null => { - const line = body.find((l) => l.trim().startsWith(`${key}:`)); - return line === undefined - ? null - : line.split(":").slice(1).join(":").replace(/#.*$/, "").trim(); - }; - return { - alertClass: value("alertClass"), - hasScrape: body.some((line) => /^ {6}scrape:\s*$/.test(line)), - process: value("process"), - surface: value("surface"), - path: value("path"), - }; -} - -/** The surface names a Process slice declares under `provides`. */ -function surfacesOf(processText: string): string[] { - const lines = processText.split("\n"); - const start = lines.findIndex((line) => /^ {8}provides:\s*$/.test(line)); - if (start === -1) return []; - const out: string[] = []; - for (const line of lines.slice(start + 1)) { - if (line.trim() === "" || line.trim().startsWith("#")) continue; - const m = /^ {10}([a-zA-Z0-9-]+):\s*(\d+)/.exec(line); - if (!m) break; - out.push(m[1] ?? ""); - } - return out; -} - -const ALERT_CLASSES = ["business-hours", "urgent", "page"]; - test("the observability block is whole or absent, and never partial", () => { let declared = 0; let omitted = 0; - for (const file of projectFiles) { - for (const s of applicationsOf(file)) { - const o = observabilityOf(s.text); - if (o === null) { - expect( - s.text, - `${s.id}: alertClass outside an observability block`, - ).not.toMatch(/^\s+alertClass:/m); + + for (const file of projectFiles) + for (const application of applicationsOf(file)) { + if (application.observability === undefined) { omitted += 1; continue; } expect( - o.alertClass, - `${s.id}: observability block with no alertClass`, - ).toBeTruthy(); - expect( - o.hasScrape, - `${s.id}: a class with no scrape is E_ALERT_CLASS_WITHOUT_SIGNAL`, - ).toBe(true); - expect(ALERT_CLASSES, `${s.id}: not a member of AlertClass`).toContain( - o.alertClass, - ); + application.observability.scrape, + `${application.id}: a class with no scrape is E_ALERT_CLASS_WITHOUT_SIGNAL`, + ).toBeDefined(); declared += 1; } - } + expect(declared, "no Application declares observability").toBeGreaterThan(0); expect( omitted, @@ -253,29 +141,32 @@ test("`none` is gone: an omitted block is the opt-out", () => { }); test("a scrape names a surface its own Process provides, never a port", () => { - for (const file of projectFiles) { - for (const s of applicationsOf(file)) { - const o = observabilityOf(s.text); - if (o === null) continue; - expect(o.process, `${s.id}: scrape names no process`).toBeTruthy(); - expect(o.surface, `${s.id}: scrape names no surface`).toBeTruthy(); - expect(o.path, `${s.id}: scrape names no path`).toBeTruthy(); - const w = processesOf(file).find((x) => x.name === o.process); + for (const file of projectFiles) + for (const application of applicationsOf(file)) { + const scrape = application.observability?.scrape; + if (scrape === undefined) continue; + const process = application.processes.find( + (candidate) => candidate.name === scrape.process, + ); + expect( - w, - `${s.id}: scrape names a Process that does not exist`, + process, + `${application.id}: scrape names a Process that does not exist`, ).toBeDefined(); expect( - surfacesOf(w?.text ?? ""), - `${s.id}: its Process provides no surface of that name`, - ).toContain(o.surface); + [...(process?.provides.keys() ?? [])], + `${application.id}: its Process provides no surface of that name`, + ).toContain(scrape.surface); + expect(scrape.path, `${application.id}: scrape names no path`).not.toBe( + "", + ); } - } }); test("no Process restates a scrape port, and no project carries alerting policy", () => { for (const file of [...projectFiles, platform]) { - const text = read(file); + const text = declarationsOf(file); + expect( text, `${file}: a scrape restates a port that provides already declares`, @@ -291,6 +182,7 @@ test("no Process restates a scrape port, and no project carries alerting policy" test("the monitor cadence is one estate-wide value in the Platform document", () => { const text = read(platform); + expect(text, "platform intent declares no monitor cadence").toMatch( /^monitors:$/m, ); @@ -298,64 +190,13 @@ test("the monitor cadence is one estate-wide value in the Platform document", () expect(text, "no monitor timeout").toMatch(/^\s+timeout: \S+$/m); for (const file of projectFiles) expect( - read(file), + declarationsOf(file), `${file}: a project file restates the cadence`, ).not.toMatch(/interval:|scrapeTimeout:/); }); -test("a class with no signal is refused, and an unknown class is not a member", () => { - const noSignal = join(refusals, "alert-class-without-signal.project.yml"); - expect(read(noSignal)).toMatch(/^expect: E_ALERT_CLASS_WITHOUT_SIGNAL$/m); - const a = observabilityOf(applicationsOf(noSignal)[0]?.text ?? ""); - expect(a?.alertClass, "the fixture must declare a class").toBeTruthy(); - expect( - a?.hasScrape, - "the fixture must declare no scrape: that is the refusal", - ).toBe(false); - expect( - ALERT_CLASSES, - "the class must be a valid member, so the missing signal is the only defect", - ).toContain(a?.alertClass); - - const unknown = join(refusals, "alert-class-unknown.project.yml"); - expect(read(unknown)).toMatch(/^expect: schema\b/m); - const b = observabilityOf(applicationsOf(unknown)[0]?.text ?? ""); - expect(b?.hasScrape, "the fixture must publish a signal").toBe(true); - expect( - ALERT_CLASSES, - "a valid member would make this something other than the unknown-class case", - ).not.toContain(b?.alertClass); -}); - -test("rolling over RWO is refused and recreate over RWO is accepted", () => { - const refused = join(refusals, "cutover-rolling-over-rwo.project.yml"); - const accepted = join(refusals, "cutover-recreate-over-rwo.project.yml"); - expect(read(refused)).toMatch(/^expect: E_CUTOVER_UNHONOURABLE$/m); - expect(read(accepted)).toMatch(/^expect: accepted$/m); - - const only = (file: string): Slice => { - const processes = processesOf(file); - expect( - processes, - `${file}: a refusal fixture carries one Process`, - ).toHaveLength(1); - const [process] = processes; - if (process === undefined) throw new Error(`${file}: no Process`); - return process; - }; - const bad = only(refused); - const good = only(accepted); - for (const w of [bad, good]) - expect(w.text, `${w.name}: the pair must both hold an RWO volume`).toMatch( - /^\s+volumes:$/m, - ); - expect(bad.text).toMatch(/^\s+cutover: rolling$/m); - expect(good.text).toMatch(/^\s+cutover: recreate$/m); - - // The refusal is the model's, not Kubernetes'. No Kubernetes rollout token - // may appear as a declared value in either file: the adapter derives the - // strategy. Comments may name the tokens to say who owns them. - for (const file of [refused, accepted]) +test("no Kubernetes rollout token reaches Project Intent", () => { + for (const file of [...projectFiles, ...refusalFiles]) expect( declarationsOf(file), `${file}: a Kubernetes rollout token leaked into Project Intent`, @@ -363,17 +204,9 @@ test("rolling over RWO is refused and recreate over RWO is accepted", () => { }); test("no Process or sidecar authors hardening", () => { - const inputs = [ - ...projectFiles, - ...[ - "alert-class-without-signal.project.yml", - "alert-class-unknown.project.yml", - "cutover-rolling-over-rwo.project.yml", - "cutover-recreate-over-rwo.project.yml", - ].map((file) => join(refusals, file)), - ]; - for (const file of inputs) { + for (const file of [...projectFiles, ...refusalFiles]) { const text = declarationsOf(file); + expect(text, `${file}: a Process authors hardening`).not.toMatch( /^\s+hardening:/m, ); @@ -384,25 +217,17 @@ test("no Process or sidecar authors hardening", () => { /^\s+- allow:/m, ); } + // The posture itself is the platform's, and stays exactly one value. expect(read(platform)).toMatch(/^hardening: restricted$/m); }); test("no provides port below 1024, because there is no capability to declare", () => { - for (const file of projectFiles) { - for (const w of processesOf(file)) { - const lines = w.text.split("\n"); - const start = lines.findIndex((line) => /^ {8}provides:\s*$/.test(line)); - if (start === -1) continue; - for (const line of lines.slice(start + 1)) { - if (line.trim() === "" || line.trim().startsWith("#")) continue; - const m = /^ {10}([a-zA-Z0-9-]+):\s*(\d+)/.exec(line); - if (!m) break; + for (const file of projectFiles) + for (const process of processesOf(file)) + for (const [surface, port] of process.provides) expect( - Number(m[2]), - `${w.name}: ${m[1] ?? ""} on ${m[2] ?? ""} is E_PRIVILEGED_PORT_UNDER_NONROOT`, + port, + `${process.name}: ${surface} on ${port} is E_PRIVILEGED_PORT_UNDER_NONROOT`, ).toBeGreaterThanOrEqual(1024); - } - } - } }); diff --git a/vitest.config.ts b/vitest.config.ts index f9aab16..9c54bdd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,15 +23,16 @@ export default defineConfig({ // A ratchet, per docs/adr/architecture/0101-coverage-is-a-ratchet.md: // set from what the suite reaches, and only ever raised. // - // Measured 2026-09-15, with the Project Intent metamodel under src/ at - // 100%: statements 98.6%, branches 93.8%, functions 100%, lines 98.51%. What is left uncovered is the one-line command guard at the + // Measured 2026-09-15, with the Project Intent metamodel and its rules + // under src/ at 100%: statements 98.67%, branches 94.3%, functions 100%, + // lines 98.58%. What is left uncovered is the one-line command guard at the // bottom of each gate and the branches for a tool that cannot be started // at all. thresholds: { - statements: 98.6, - branches: 93.8, + statements: 98.67, + branches: 94.3, functions: 100, - lines: 98.51, + lines: 98.58, }, }, }, diff --git a/vitest.mutation.config.ts b/vitest.mutation.config.ts index d5d2c15..af2b0b9 100644 --- a/vitest.mutation.config.ts +++ b/vitest.mutation.config.ts @@ -5,7 +5,11 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["test/model/**/*.test.ts", "test/canonical-json.test.ts"], + include: [ + "test/model/**/*.test.ts", + "test/canonical-json.test.ts", + "test/simplification-contract.test.ts", + ], setupFiles: ["./test/setup.ts"], restoreMocks: true, unstubEnvs: true,