diff --git a/README.md b/README.md index 29a16c1..756d577 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.46%, branches 93.44%, functions 100%, lines 98.35%. +98.6%, branches 93.8%, functions 100%, lines 98.51%. ## Conventions diff --git a/docs/architecture-rules.md b/docs/architecture-rules.md index 26cb8a5..3952b72 100644 --- a/docs/architecture-rules.md +++ b/docs/architecture-rules.md @@ -57,7 +57,7 @@ fails the gate, so the taxonomy cannot grow entries nothing stands behind. ## Rules -This ledger holds **64** rules, **12** of them pending. +This ledger holds **64** rules, **11** of them pending. A row is enforced or pending, never both. An enforced row names its enforcer as `kind:value`: `depcruise:` a rule in @@ -118,7 +118,7 @@ moving a live rule to pending fails the gate rather than quietly retiring it. | RULE-040 | toolchain | Coverage is a ratchet over an explicit include list, and no ignore comment exempts a line from it | `file:vitest.config.ts` | [test/harness.test.ts](../test/harness.test.ts) `an ignore is slack nobody decided` | | RULE-041 | toolchain | No default export outside a tool configuration file | `eslint:no-restricted-exports` | [test/seams.test.ts](../test/seams.test.ts) `RULE-041 refuses a default export` | | RULE-042 | toolchain | Shipped code is ESM, and the one CommonJS file is the dependency-cruiser configuration that cannot be anything else | pending (#105): stated by `type: module`; no configured rule refuses a second CommonJS file | pending | -| RULE-043 | toolchain | Generated artifacts are committed, and CI fails when regenerating one produces a diff | pending (#38): the first generator is the JSON Schema #38 derives from the Project Intent metamodel, and its diff check lands with it | pending | +| RULE-043 | toolchain | Generated artifacts are committed, and CI fails when regenerating one produces a diff | `file:src/wire/project-intent/json-schema.ts` | [test/model/descriptor.test.ts](../test/model/descriptor.test.ts) `regenerates without a diff` | | RULE-044 | cli | The CLI prints help on `--help` and `-h`, data on stdout and diagnostics on stderr, emits only data under `--json`, maps failures through one exit-code enum, honours `NO_COLOR`, and never prompts | pending (#105): each clause needs a process-level test, and the CLI ring does not exist yet | pending | | RULE-045 | registry | Every registered adapter satisfies the adapter port, attributes every Deliverable to itself, and renders deterministically | pending (#97): the adapter contract suite arrives with the first adapter | pending | | RULE-046 | registry | Every estate-wide invariant is registered with its code, its spec anchor and its test, so an unregistered one is detectable rather than merely absent | pending (#44): the invariant registry is that ticket's deliverable | pending | diff --git a/docs/architecture.md b/docs/architecture.md index 45fdcf8..6535399 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -284,7 +284,20 @@ feature's type and multiplicity, and every closed vocabulary with its literals. Target structures are compared through what they generate, not structurally. The TypeScript side builds it from the Zod schemas with Zod's native `z.toJSONSchema()` and a normaliser; the descriptor's shape is fixed here, not -by either source format. +by either source format: + +- `classes`, sorted by name. Each carries its `name` and its `features`, sorted + by name; a class the language writes as one word carries that word as + `scalar`. +- A **feature** carries its `name`, the `types` it admits sorted by name, + whether it is `required`, whether it holds `many` values, and whether it is a + `map` keyed by string. A type is a class name, a vocabulary name, or one of + `string`, `int` and `boolean`. +- A union is not a class: a feature whose value may be one of several classes + names them all, so an abstract class on one side and a union on the other + describe the same model. +- `vocabularies`, sorted by name, each with its `literals` in the order the + model declares them. **The constraint ledger** gives every model constraint a `CONS-NNN` id, the diagnostic code it emits, the check that enforces it in `src/`, and a refused diff --git a/docs/requirements.md b/docs/requirements.md index fb591ca..890b168 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 **22** rows. The compiler's behaviours join it as they land. +This ledger holds **23** rows. The compiler's behaviours join it as they land. | id | a contributor or a consumer can rely on | proved by | |---|---|---| @@ -45,3 +45,4 @@ This ledger holds **22** rows. The compiler's behaviours join it as they land. | REQ-020 | The production implementation's canonical JSON writer sorts keys by UTF-16 code units, formats numbers as ECMAScript does, and refuses null, non-finite numbers, non-JSON values and lone surrogates, on the same cases as the model-driven writer | [test/canonical-json.test.ts](../test/canonical-json.test.ts) | | 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) | diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/IntentJson.java b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/IntentJson.java index caec009..23c3307 100644 --- a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/IntentJson.java +++ b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/IntentJson.java @@ -5,19 +5,25 @@ import java.util.List; import java.util.Map; import org.eclipse.emf.common.util.Enumerator; +import org.eclipse.emf.ecore.EAnnotation; import org.eclipse.emf.ecore.EObject; import org.eclipse.emf.ecore.EStructuralFeature; /** * Reads a parsed model as the JSON value the parity contract compares: every feature named as the * authored key it holds, a map entry as an object, an enumeration as its literal, and an optional - * feature absent when the document left it out. The metamodel is walked reflectively, so a feature - * added to the {@code .ecore} reaches the intent without a line here. + * feature absent when the document left it out. A class the metamodel annotates as a scalar is + * written as that scalar, which is how {@code probes: none} stays the word it was authored as. The + * metamodel is walked reflectively, so a feature added to the {@code .ecore} reaches the intent + * without a line here. */ public final class IntentJson { private IntentJson() {} + /** The annotation a class carries when the language writes it as one word rather than a block. */ + private static final String JSON = "https://jorisjonkers.dev/deploy-kit/json"; + /** The JSON value of {@code root}, as a map of authored key to value. */ public static Map of(EObject root) { Map json = new LinkedHashMap<>(); @@ -58,6 +64,12 @@ private static Object many(EStructuralFeature feature, List values) { return items; } + /** The word a class is written as, or {@code null} when it is written as a block. */ + private static String scalar(EObject object) { + EAnnotation annotation = object.eClass().getEAnnotation(JSON); + return annotation == null ? null : annotation.getDetails().get("scalar"); + } + /** Whether {@code feature} holds map entries, which are written as one object rather than a list. */ private static boolean isMapEntry(EStructuralFeature feature) { return Map.Entry.class.getName().equals(feature.getEType().getInstanceClassName()); @@ -65,7 +77,7 @@ private static boolean isMapEntry(EStructuralFeature feature) { private static Object single(Object value) { return switch (value) { - case EObject child -> of(child); + case EObject child -> scalar(child) == null ? of(child) : scalar(child); case Enumerator literal -> literal.getLiteral(); default -> value; }; diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentJsonTest.java b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentJsonTest.java index 07eebb6..7f43a47 100644 --- a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentJsonTest.java +++ b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentJsonTest.java @@ -3,9 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.entry; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.HttpProbe; import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Lifecycle; import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Placement; -import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Probe; import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Probes; import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Process; import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentFactory; @@ -52,7 +52,7 @@ void anOptionalFeatureTheDocumentSetIsWritten() { Process process = process(); process.setStartupBudget("20s"); Probes probes = MODEL.createProbes(); - Probe readiness = MODEL.createProbe(); + HttpProbe readiness = MODEL.createHttpProbe(); readiness.setPath("/healthz/ready"); readiness.setPort(8080); probes.setReadiness(readiness); diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/WorkedExamplesTest.java b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/WorkedExamplesTest.java new file mode 100644 index 0000000..f99263f --- /dev/null +++ b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/WorkedExamplesTest.java @@ -0,0 +1,44 @@ +package dev.jorisjonkers.deploykit.emf.cli; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** Scratch: every worked example parses. */ +class WorkedExamplesTest { + + private static List projectFiles() { + Path examples = repository().resolve("spec/v1/examples"); + try (Stream tree = Files.walk(examples)) { + return tree.filter(path -> path.getFileName().toString().endsWith(".project.yml")) + .filter(path -> !path.toString().contains("refusals")) + .sorted() + .toList(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("projectFiles") + void everyWorkedExampleParses(Path file) { + Parsed parsed = Pipeline.intent(file); + + assertThat(parsed.diagnostics()).isEmpty(); + } + + private static Path repository() { + Path dir = Path.of("").toAbsolutePath(); + while (!Files.isRegularFile(dir.resolve("emf/pom.xml"))) { + dir = dir.getParent(); + } + return dir; + } +} diff --git a/emf/docs/architecture.md b/emf/docs/architecture.md index d07fbc4..82b6bb2 100644 --- a/emf/docs/architecture.md +++ b/emf/docs/architecture.md @@ -118,7 +118,10 @@ of formatting, coverage and mutation. The descriptor exporter walks the source `EPackage` reflectively and writes the descriptor the parity contract fixes. It is the only place the Ecore structure -is compared with anything. +is compared with anything. Two shapes it normalises: an abstract class is a +union, so it is not listed and a feature that points at one names its concrete +classes; and a map entry is not a class, so the feature that holds the entries +is a map. ## Constraints @@ -146,11 +149,17 @@ subset those files use, with indentation handled by synthetic block tokens, and refuses anything outside the subset with a diagnostic rather than a guess. The grammar imports the hand-written source metamodel, so the parser produces -instances of the graded metamodel directly. There is no inferred syntax +instances of the graded metamodel directly. A union in the model is a union in +the grammar: a probe is HTTP or TCP, a probe policy is a block or the word +`none`, and a grant is keyed by its engine, which a grant that has one writes +first. The language binds the `.yml` extension, because EMF resolves a resource +factory by the last extension alone; which document a file holds is the file +name's to say, and that lands with the Platform document. There is no inferred syntax metamodel and no mapping step between parsing and validation. Indentation is not the grammar's concern: a token source turns the block -structure into the synthetic `BEGIN` and `END` tokens the rules read. A line +structure into the synthetic `BEGIN` and `END` tokens the rules read, and folds +a scalar written over several lines into one token. A line indented further than the one before it opens a block, a dash opens one around the item that follows it, and a flow collection opens and closes one on a single line, so `{ path: /, match: prefix }` and the same keys written as an indented diff --git a/emf/docs/witnesses.md b/emf/docs/witnesses.md index 8c1330e..e3104b3 100644 --- a/emf/docs/witnesses.md +++ b/emf/docs/witnesses.md @@ -10,8 +10,9 @@ 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 **1** witness. +This list holds **2** witnesses. | id | JUnit test | |---|---| | REQ-021 | `ParityTest#theParsedIntentEqualsTheCommittedOracle` | +| REQ-023 | `ParityTest#theMetamodelsStructureEqualsTheCommittedDescriptor` | diff --git a/emf/metamodel/META-INF/MANIFEST.MF b/emf/metamodel/META-INF/MANIFEST.MF index 0ae5c8d..b0b0ef0 100644 --- a/emf/metamodel/META-INF/MANIFEST.MF +++ b/emf/metamodel/META-INF/MANIFEST.MF @@ -5,7 +5,8 @@ Bundle-SymbolicName: dev.jorisjonkers.deploykit.emf.metamodel;singleton:=true Bundle-Version: 0.1.0.qualifier Bundle-RequiredExecutionEnvironment: JavaSE-21 Automatic-Module-Name: dev.jorisjonkers.deploykit.emf.metamodel -Export-Package: dev.jorisjonkers.deploykit.emf.metamodel.projectintent, +Export-Package: dev.jorisjonkers.deploykit.emf.metamodel.descriptor, + dev.jorisjonkers.deploykit.emf.metamodel.projectintent, dev.jorisjonkers.deploykit.emf.metamodel.projectintent.impl, dev.jorisjonkers.deploykit.emf.metamodel.projectintent.util Require-Bundle: org.eclipse.emf.ecore;visibility:=reexport, diff --git a/emf/metamodel/build.properties b/emf/metamodel/build.properties index 2c524df..765502e 100644 --- a/emf/metamodel/build.properties +++ b/emf/metamodel/build.properties @@ -1,4 +1,5 @@ -source.. = target/generated-sources/emf/ +source.. = src/main/java/,\ + target/generated-sources/emf/ bin.includes = META-INF/,\ .,\ plugin.xml,\ diff --git a/emf/metamodel/model/project-intent.ecore b/emf/metamodel/model/project-intent.ecore index 70ab7da..698450f 100644 --- a/emf/metamodel/model/project-intent.ecore +++ b/emf/metamodel/model/project-intent.ecore @@ -2,8 +2,9 @@ + @@ -35,7 +37,7 @@ - + @@ -43,16 +45,26 @@ + + + - - + + + + + + + + + @@ -62,14 +74,85 @@ + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + - + + +
+ + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -82,10 +165,30 @@ + + + + + + + + + + + + + + + + + + + + @@ -106,4 +209,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..344820c --- /dev/null +++ b/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java @@ -0,0 +1,133 @@ +package dev.jorisjonkers.deploykit.emf.metamodel.descriptor; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.eclipse.emf.ecore.EAttribute; +import org.eclipse.emf.ecore.EClass; +import org.eclipse.emf.ecore.EClassifier; +import org.eclipse.emf.ecore.EEnum; +import org.eclipse.emf.ecore.EEnumLiteral; +import org.eclipse.emf.ecore.EPackage; +import org.eclipse.emf.ecore.EReference; +import org.eclipse.emf.ecore.EStructuralFeature; + +/** + * The structure of a metamodel, in the shape the parity contract fixes: every class with its + * features, each feature's types and multiplicity, and every closed vocabulary with its literals + * (docs/architecture.md#the-parity-contract). + * + *

The shape is neither Ecore's nor the wire schema's. An abstract class is the union of what can + * stand in its place, so it is not listed and a feature that points at one names its concrete + * classes. A map entry is not a class either: the feature that holds the entries is a map. + */ +public final class Descriptor { + + private static final String JSON = "https://jorisjonkers.dev/deploy-kit/json"; + private static final Map PRIMITIVES = + Map.of("java.lang.String", "string", "java.lang.Integer", "int", "java.lang.Boolean", "boolean"); + + private Descriptor() {} + + /** The descriptor of {@code metamodel}, as a JSON value. */ + public static Map of(EPackage metamodel) { + List classes = new ArrayList<>(); + List vocabularies = new ArrayList<>(); + for (EClassifier classifier : sorted(metamodel.getEClassifiers())) { + switch (classifier) { + case EEnum vocabulary -> vocabularies.add(vocabulary(vocabulary)); + case EClass owner -> { + if (isListed(owner)) { + classes.add(owner(owner)); + } + } + default -> + throw new IllegalArgumentException( + classifier.getName() + " is neither a class nor a closed vocabulary"); + } + } + return Map.of("classes", classes, "vocabularies", vocabularies); + } + + private static List sorted(List classifiers) { + return classifiers.stream() + .sorted(Comparator.comparing(EClassifier::getName)) + .toList(); + } + + /** Whether a class is one the descriptor lists: an abstract class is a union, a map entry a map. */ + private static boolean isListed(EClass owner) { + return !owner.isAbstract() && !isMapEntry(owner); + } + + private static boolean isMapEntry(EClassifier classifier) { + return Map.Entry.class.getName().equals(classifier.getInstanceClassName()); + } + + private static Map vocabulary(EEnum vocabulary) { + Map json = new LinkedHashMap<>(); + json.put("name", vocabulary.getName()); + json.put( + "literals", + vocabulary.getELiterals().stream().map(EEnumLiteral::getLiteral).toList()); + return json; + } + + private static Map owner(EClass owner) { + Map json = new LinkedHashMap<>(); + json.put("name", owner.getName()); + json.put( + "features", + owner.getEAllStructuralFeatures().stream() + .sorted(Comparator.comparing(EStructuralFeature::getName)) + .map(Descriptor::feature) + .toList()); + String scalar = scalar(owner); + if (scalar != null) { + json.put("scalar", scalar); + } + return json; + } + + private static String scalar(EClass owner) { + return owner.getEAnnotation(JSON) == null + ? null + : owner.getEAnnotation(JSON).getDetails().get("scalar"); + } + + private static Map feature(EStructuralFeature feature) { + boolean map = feature instanceof EReference reference && isMapEntry(reference.getEReferenceType()); + Map json = new LinkedHashMap<>(); + json.put("name", feature.getName()); + json.put("types", types(feature, map)); + json.put("required", feature.isRequired()); + json.put("many", feature.isMany() && !map); + json.put("map", map); + return json; + } + + /** The types a feature admits: a vocabulary, a primitive, a class, or the classes a union stands for. */ + private static List types(EStructuralFeature feature, boolean map) { + if (map) { + EClass entry = ((EReference) feature).getEReferenceType(); + return types(entry.getEStructuralFeature("value"), false); + } + EClassifier type = feature.getEType(); + if (feature instanceof EAttribute) { + return List.of(type instanceof EEnum ? type.getName() : PRIMITIVES.get(type.getInstanceClassName())); + } + EClass referenced = (EClass) type; + if (!referenced.isAbstract()) { + return List.of(referenced.getName()); + } + return referenced.getEPackage().getEClassifiers().stream() + .filter(EClass.class::isInstance) + .map(EClass.class::cast) + .filter(candidate -> !candidate.isAbstract() && referenced.isSuperTypeOf(candidate)) + .map(EClass::getName) + .sorted() + .toList(); + } +} diff --git a/emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorTest.java b/emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorTest.java new file mode 100644 index 0000000..1455a6e --- /dev/null +++ b/emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorTest.java @@ -0,0 +1,114 @@ +package dev.jorisjonkers.deploykit.emf.metamodel.descriptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentPackage; +import java.util.List; +import java.util.Map; +import org.eclipse.emf.ecore.EDataType; +import org.eclipse.emf.ecore.EPackage; +import org.eclipse.emf.ecore.EcoreFactory; +import org.junit.jupiter.api.Test; + +/** What the descriptor says about the Project Intent metamodel. */ +class DescriptorTest { + + @SuppressWarnings("unchecked") + private static List> entries(String kind) { + return (List>) + Descriptor.of(ProjectIntentPackage.eINSTANCE).get(kind); + } + + private static Map named(String kind, String name) { + return entries(kind).stream() + .filter(entry -> name.equals(entry.get("name"))) + .findFirst() + .orElseThrow(); + } + + @SuppressWarnings("unchecked") + private static Map feature(String owner, String name) { + return ((List>) named("classes", owner).get("features")) + .stream() + .filter(feature -> name.equals(feature.get("name"))) + .findFirst() + .orElseThrow(); + } + + @Test + void everyClassAndVocabularyIsListedOnceAndSorted() { + List classes = entries("classes").stream() + .map(entry -> (String) entry.get("name")) + .toList(); + List vocabularies = entries("vocabularies").stream() + .map(entry -> (String) entry.get("name")) + .toList(); + + assertThat(classes).isSorted().doesNotHaveDuplicates().contains("Project", "Process", "KvGrant"); + assertThat(vocabularies).isSorted().doesNotHaveDuplicates().contains("AlertClass", "TransitOp"); + } + + @Test + void anAbstractClassIsAUnionRatherThanAClassOfItsOwn() { + assertThat(entries("classes").stream().map(entry -> entry.get("name"))) + .doesNotContain("Grant", "Probe", "ProbePolicy"); + assertThat(feature("Process", "secrets").get("types")) + .isEqualTo(List.of("DatabaseGrant", "KvGrant", "TransitGrant")); + assertThat(feature("Process", "probes").get("types")).isEqualTo(List.of("NoProbes", "Probes")); + } + + @Test + void aMapEntryIsAMapRatherThanAClassOfItsOwn() { + assertThat(entries("classes").stream().map(entry -> entry.get("name"))).doesNotContain("SurfacePort"); + assertThat(feature("Process", "provides")) + .isEqualTo(Map.of( + "name", "provides", "types", List.of("int"), "required", false, "many", false, "map", true)); + } + + @Test + void aFeatureCarriesItsTypeAndItsMultiplicity() { + assertThat(feature("Project", "applications")) + .isEqualTo(Map.of( + "name", + "applications", + "types", + List.of("Application"), + "required", + true, + "many", + true, + "map", + false)); + assertThat(feature("Process", "startupBudget").get("types")).isEqualTo(List.of("string")); + assertThat(feature("Process", "cutover").get("types")).isEqualTo(List.of("Cutover")); + assertThat(feature("DependencyEdge", "required").get("types")).isEqualTo(List.of("boolean")); + assertThat(feature("HttpProbe", "port").get("types")).isEqualTo(List.of("int")); + assertThat(feature("Exposure", "contentPolicy").get("required")).isEqualTo(false); + } + + @Test + void aClassWrittenAsOneWordCarriesThatWord() { + assertThat(named("classes", "NoProbes")) + .isEqualTo(Map.of("name", "NoProbes", "features", List.of(), "scalar", "none")); + assertThat(named("classes", "Probes")).doesNotContainKey("scalar"); + } + + @Test + void aClassifierThatIsNeitherAClassNorAVocabularyIsRefused() { + EPackage metamodel = EcoreFactory.eINSTANCE.createEPackage(); + EDataType stray = EcoreFactory.eINSTANCE.createEDataType(); + stray.setName("Quantity"); + metamodel.getEClassifiers().add(stray); + + assertThatThrownBy(() -> Descriptor.of(metamodel)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Quantity is neither a class nor a closed vocabulary"); + } + + @Test + void aVocabularyCarriesItsLiteralsInOrder() { + assertThat(named("vocabularies", "AlertClass")) + .isEqualTo(Map.of("name", "AlertClass", "literals", List.of("business-hours", "urgent", "page"))); + } +} diff --git a/emf/parity/META-INF/MANIFEST.MF b/emf/parity/META-INF/MANIFEST.MF index d35b51c..87b789a 100644 --- a/emf/parity/META-INF/MANIFEST.MF +++ b/emf/parity/META-INF/MANIFEST.MF @@ -6,4 +6,5 @@ Bundle-Version: 0.1.0.qualifier Bundle-RequiredExecutionEnvironment: JavaSE-21 Automatic-Module-Name: dev.jorisjonkers.deploykit.emf.parity Export-Package: dev.jorisjonkers.deploykit.emf.parity -Require-Bundle: dev.jorisjonkers.deploykit.emf.cli +Require-Bundle: dev.jorisjonkers.deploykit.emf.cli, + dev.jorisjonkers.deploykit.emf.metamodel 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 0134f67..f68c354 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 @@ -4,6 +4,8 @@ import dev.jorisjonkers.deploykit.emf.cli.Parsed; import dev.jorisjonkers.deploykit.emf.cli.Pipeline; +import dev.jorisjonkers.deploykit.emf.metamodel.descriptor.Descriptor; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentPackage; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; @@ -42,12 +44,18 @@ void theParsedIntentEqualsTheCommittedOracle(Path directory) throws IOException assertThat(CanonicalJson.write(parsed.intent())).isEqualTo(read(directory.resolve("expected/intent.json"))); } + @Test + void theMetamodelsStructureEqualsTheCommittedDescriptor() throws IOException { + assertThat(CanonicalJson.write(Descriptor.of(ProjectIntentPackage.eINSTANCE))) + .isEqualTo(read(repository().resolve("spec/v1/examples/expected/descriptor.json"))); + } + @Test void oneChangedFieldNoLongerMatchesTheOracle() throws IOException { Path directory = casesWithAnIntentOracle().get(0); Path project = projectFile(directory); Path changed = Files.createTempDirectory("parity").resolve(project.getFileName()); - Files.writeString(changed, read(project).replace("cpu: 50m", "cpu: 60m")); + Files.writeString(changed, read(project).replace("owner: joris", "owner: someone-else")); assertThat(CanonicalJson.write(Pipeline.intent(changed).intent())) .isNotEqualTo(read(directory.resolve("expected/intent.json"))); diff --git a/emf/syntax/META-INF/MANIFEST.MF b/emf/syntax/META-INF/MANIFEST.MF index acc160a..b20fd93 100644 --- a/emf/syntax/META-INF/MANIFEST.MF +++ b/emf/syntax/META-INF/MANIFEST.MF @@ -7,6 +7,7 @@ Bundle-RequiredExecutionEnvironment: JavaSE-21 Automatic-Module-Name: dev.jorisjonkers.deploykit.emf.syntax Export-Package: dev.jorisjonkers.deploykit.emf.syntax, dev.jorisjonkers.deploykit.emf.syntax.blocks, + dev.jorisjonkers.deploykit.emf.syntax.values, dev.jorisjonkers.deploykit.emf.syntax.parser.antlr, dev.jorisjonkers.deploykit.emf.syntax.services Require-Bundle: dev.jorisjonkers.deploykit.emf.metamodel, 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 439e89e..51dbc38 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 @@ -5,7 +5,9 @@ import "http://www.eclipse.org/emf/2002/Ecore" as ecore // The YAML subset the authored project files use. Block structure arrives as // the synthetic BEGIN and END tokens the blocks package produces, so an -// indented block and a flow mapping parse through one rule. +// indented block, a flow mapping and a flow sequence parse through one rule. +// Keys are unordered groups, because the order of keys in a mapping is not +// meaning. A union in the model is a union here. Project returns Project: (('apiVersion' ':' apiVersion=Text) @@ -19,6 +21,7 @@ Application returns Application: (('id' ':' id=Text) & ('observability' ':' BEGIN observability=Observability END)? & ('exposure' ':' BEGIN (DASH BEGIN exposure+=Exposure END)+ END)? + & ('secrets' ':' BEGIN (DASH BEGIN secrets+=Grant END)+ END)? & ('processes' ':' BEGIN (DASH BEGIN processes+=Process END)+ END)); Observability returns Observability: @@ -34,23 +37,33 @@ Exposure returns Exposure: (('name' ':' name=Text) & ('host' ':' host=Text) & ('audience' ':' audience=Audience) - & ('contentPolicy' ':' contentPolicy=ContentPolicy) + & ('contentPolicy' ':' contentPolicy=ContentPolicy)? & ('routes' ':' BEGIN (DASH BEGIN routes+=Route END)+ END)); Route returns Route: (('path' ':' path=Text) & ('match' ':' match=Match) & ('process' ':' process=Text) - & ('surface' ':' surface=Text)); + & ('surface' ':' surface=Text) + & ('audience' ':' audience=Audience)? + & ('redirectTo' ':' redirectTo=Text)?); Process returns Process: (('name' ':' name=Text) & ('lifecycle' ':' lifecycle=Lifecycle) & ('image' ':' image=Text) & ('runtime' ':' runtime=Runtime) + & ('engine' ':' engine=Engine)? & ('provides' ':' BEGIN provides+=SurfacePort+ END)? & ('placement' ':' BEGIN placement=Placement END) - & ('probes' ':' BEGIN probes=Probes END)? + & ('writablePaths' ':' BEGIN (DASH BEGIN writablePaths+=Text END | writablePaths+=Text)+ END)? + & ('sidecars' ':' BEGIN (DASH BEGIN sidecars+=Sidecar END)+ END)? + & ('dependsOn' ':' BEGIN (DASH BEGIN dependsOn+=DependencyEdge END)+ END)? + & ('assets' ':' BEGIN (DASH BEGIN assets+=Asset END)+ END)? + & ('probes' ':' probes=ProbePolicy)? + & ('volumes' ':' BEGIN (DASH BEGIN volumes+=Volume END)+ END)? + & ('replicas' ':' BEGIN replicas=Capacity END)? + & ('secrets' ':' BEGIN (DASH BEGIN secrets+=Grant END)+ END)? & ('startupBudget' ':' startupBudget=Text)? & ('cutover' ':' cutover=Cutover)); @@ -59,33 +72,128 @@ SurfacePort returns SurfacePort: Placement returns Placement: (('memory' ':' memory=Text) + & ('cpu' ':' cpu=Text) + & ('arch' ':' BEGIN (DASH BEGIN arch+=Arch END | arch+=Arch)+ END)? + & ('site' ':' site=Text)? + & ('disk' ':' BEGIN disk=DiskRequest END)? + & ('gpu' ':' BEGIN gpu=GpuRequest END)? + & ('capabilities' ':' BEGIN (DASH BEGIN capabilities+=Text END | capabilities+=Text)+ END)?); + +DiskRequest returns DiskRequest: + 'media' ':' BEGIN (DASH BEGIN media+=Media END | media+=Media)+ END; + +GpuRequest returns GpuRequest: + (('class' ':' class=Text) + & ('memory' ':' memory=Text)); + +Sidecar returns Sidecar: + (('name' ':' name=Text) + & ('image' ':' image=Text) + & ('memory' ':' memory=Text) & ('cpu' ':' cpu=Text)); +DependencyEdge returns DependencyEdge: + (('application' ':' application=Text) + & ('surface' ':' surface=Text) + & ('required' ':' required=Bool)?); + +Asset returns Asset: + (('from' ':' from=Text) + & ('mountAt' ':' mountAt=Text)); + +ProbePolicy returns ProbePolicy: + NoProbes | Probes; + +NoProbes returns NoProbes: + {NoProbes} 'none'; + Probes returns Probes: + BEGIN (('readiness' ':' BEGIN readiness=Probe END)? - & ('liveness' ':' BEGIN liveness=Probe END)?); + & ('liveness' ':' BEGIN liveness=Probe END)?) + END; Probe returns Probe: + HttpProbe | TcpProbe; + +HttpProbe returns HttpProbe: (('path' ':' path=Text) & ('port' ':' port=Port)); +TcpProbe returns TcpProbe: + 'tcp' ':' tcp=Port; + +Volume returns Volume: + (('claim' ':' claim=Text) + & ('mountAt' ':' mountAt=Text) + & ('size' ':' size=Text)? + & ('durability' ':' durability=DurabilityClass)); + +Capacity returns Capacity: + (('count' ':' count=Port) + & ('reason' ':' reason=Text)); + +Grant returns Grant: + KvGrant | DatabaseGrant | TransitGrant; + +KvGrant returns KvGrant: + (('path' ':' path=Text) + & ('keys' ':' BEGIN (DASH BEGIN keys+=Text END | keys+=Text)+ END) + & ('access' ':' access=AccessTier) + & ('delivery' ':' delivery=Delivery) + & ('mountAt' ':' mountAt=Text)? + & ('fileMode' ':' fileMode=Text)? + & ('rotation' ':' BEGIN rotation=Rotation END)?); + +DatabaseGrant returns DatabaseGrant: + 'engine' ':' engine=DatabaseEngine + (('role' ':' role=Text) + & ('delivery' ':' delivery=Delivery) + & ('mountAt' ':' mountAt=Text)? + & ('fileMode' ':' fileMode=Text)? + & ('rotation' ':' BEGIN rotation=Rotation END)?); + +TransitGrant returns TransitGrant: + 'engine' ':' engine=TransitEngine + (('key' ':' key=Text) + & ('operations' ':' BEGIN (DASH BEGIN operations+=TransitOp END | operations+=TransitOp)+ END) + & ('delivery' ':' delivery=Delivery) + & ('mountAt' ':' mountAt=Text)? + & ('fileMode' ':' fileMode=Text)? + & ('rotation' ':' BEGIN rotation=Rotation END)?); + +Rotation returns Rotation: + (('tolerates' ':' tolerates=Tolerance) + & ('maxAge' ':' maxAge=Text)?); + Port returns ecore::EInt: INT; -// A scalar, quoted or not. Every keyword above is also a scalar, so a value -// that happens to read like a key is still a value. +Bool returns ecore::EBooleanObject: + 'true' | 'false'; + +// A scalar, quoted, folded or plain. Every keyword above is also a scalar, so a +// value that happens to read like a key is still a value. Text returns ecore::EString: SCALAR | STRING | INT | Keyword; Keyword: - 'apiVersion' | 'kind' | 'schemaVersion' | 'project' | 'owner' | 'applications' | 'id' | 'observability' | - 'exposure' | 'processes' | 'alertClass' | 'scrape' | 'name' | 'host' | 'audience' | 'contentPolicy' | - 'routes' | 'match' | 'lifecycle' | 'image' | 'runtime' | 'provides' | 'placement' | 'probes' | - 'startupBudget' | 'cutover' | 'memory' | 'cpu' | 'readiness' | 'liveness' | 'path' | 'port' | - 'process' | 'surface' | - 'application' | 'job' | 'jvm' | 'python' | 'node' | 'static' | 'none' | 'rolling' | 'recreate' | - 'business-hours' | 'urgent' | 'page' | 'anonymous' | 'authenticated' | 'internal' | 'lan' | - 'strict' | 'admin' | 'workflow' | 'prefix' | 'exact'; + 'access' | 'admin' | 'alertClass' | 'amd64' | 'anonymous' | 'apiVersion' | 'application' | 'applications' | + 'arch' | 'arm64' | 'assets' | 'audience' | 'authenticated' | 'business-hours' | 'capabilities' | 'claim' | + 'class' | 'contentPolicy' | 'count' | 'cpu' | 'custody' | 'cutover' | 'database' | 'decrypt' | + 'delivery' | 'dependsOn' | 'disk' | 'durability' | 'encrypt' | 'engine' | 'env' | 'exact' | + 'exposure' | 'false' | 'file' | 'fileMode' | 'files' | 'from' | 'gpu' | 'hdd' | + 'host' | 'id' | 'image' | 'internal' | 'irreplaceable' | 'job' | 'jvm' | 'key' | + 'keys' | 'kind' | 'kv' | 'lan' | 'lifecycle' | 'liveness' | 'match' | 'maxAge' | + 'media' | 'memory' | 'mountAt' | 'name' | 'node' | 'none' | 'nvme' | 'observability' | + 'operations' | 'owner' | 'page' | 'path' | 'placement' | 'port' | 'postgres' | 'prefix' | + 'probes' | 'process' | 'processes' | 'project' | 'provides' | 'python' | 'rabbitmq' | 'read' | + 'readiness' | 'reason' | 'reconstructible' | 'recoverable' | 'recreate' | 'redirectTo' | 'reload' | 'replicas' | + 'required' | 'restart' | 'role' | 'rolling' | 'rotate' | 'rotation' | 'routes' | 'runtime' | + 'schemaVersion' | 'scrape' | 'secrets' | 'self' | 'self-renew' | 'self-roll' | 'sidecars' | 'sign' | + 'site' | 'size' | 'ssd' | 'startupBudget' | 'static' | 'strict' | 'surface' | 'tcp' | + 'tolerates' | 'transit' | 'true' | 'urgent' | 'valkey' | 'verify' | 'volumes' | 'workflow' | + 'writablePaths'; enum Lifecycle returns Lifecycle: application='application' | job='job'; @@ -93,9 +201,21 @@ enum Lifecycle returns Lifecycle: enum Runtime returns Runtime: jvm='jvm' | python='python' | node='node' | static='static' | none='none'; +enum Engine returns Engine: + postgres='postgres' | rabbitmq='rabbitmq' | valkey='valkey' | files='files'; + enum Cutover returns Cutover: rolling='rolling' | recreate='recreate'; +enum DurabilityClass returns DurabilityClass: + reconstructible='reconstructible' | recoverable='recoverable' | irreplaceable='irreplaceable'; + +enum Arch returns Arch: + amd64='amd64' | arm64='arm64'; + +enum Media returns Media: + nvme='nvme' | ssd='ssd' | hdd='hdd'; + enum AlertClass returns AlertClass: businessHours='business-hours' | urgent='urgent' | page='page'; @@ -108,23 +228,43 @@ enum ContentPolicy returns ContentPolicy: enum Match returns Match: prefix='prefix' | exact='exact'; +enum DatabaseEngine returns DatabaseEngine: + database='database'; + +enum TransitEngine returns TransitEngine: + transit='transit'; + +enum AccessTier returns AccessTier: + read='read' | selfRenew='self-renew' | selfRoll='self-roll' | custody='custody'; + +enum TransitOp returns TransitOp: + sign='sign' | verify='verify' | encrypt='encrypt' | decrypt='decrypt' | rotate='rotate'; + +enum Delivery returns Delivery: + env='env' | file='file' | self='self'; + +enum Tolerance returns Tolerance: + restart='restart' | reload='reload'; + terminal BEGIN: 'synthetic:BEGIN'; terminal END: 'synthetic:END'; -// The block markers the token source rewrites: a dash opens a sequence item, -// and a flow collection opens and closes a block on one line. The parser never -// sees FLOW_ or LIST_ tokens; they arrive as BEGIN and END. +// The block markers the token source rewrites: a dash opens a sequence item, a +// flow collection opens and closes a block on one line, and a fold introduces a +// scalar written over several lines. The parser sees none of them: they arrive +// as BEGIN, END and SCALAR. terminal DASH: '-'; terminal FLOW_BEGIN: '{'; terminal FLOW_END: '}'; terminal FLOW_SEP: ','; terminal LIST_BEGIN: '['; terminal LIST_END: ']'; +terminal FOLD: ('>' | '|') '-'?; terminal INT returns ecore::EInt: ('0'..'9')+; terminal SCALAR: - !(' ' | '\t' | '\r' | '\n' | ':' | ',' | '{' | '}' | '[' | ']' | '#' | '"' | "'" | '-') + !(' ' | '\t' | '\r' | '\n' | ':' | ',' | '{' | '}' | '[' | ']' | '#' | '"' | "'" | '-' | '>' | '|') (!(' ' | '\t' | '\r' | '\n' | ':' | ',' | '{' | '}' | '[' | ']' | '#'))*; terminal STRING: diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentRuntimeModule.java b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentRuntimeModule.java index 5f3fe65..8488255 100644 --- a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentRuntimeModule.java +++ b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentRuntimeModule.java @@ -1,9 +1,17 @@ /* - * generated by Xtext + * generated by Xtext, then owned here: the value converters are this language's. */ package dev.jorisjonkers.deploykit.emf.syntax; -/** - * Use this class to register components to be used at runtime / without the Equinox extension registry. - */ -public class ProjectIntentRuntimeModule extends AbstractProjectIntentRuntimeModule {} +import dev.jorisjonkers.deploykit.emf.syntax.values.ProjectIntentValueConverters; +import org.eclipse.xtext.conversion.IValueConverterService; + +/** Registers the components this language runs with, outside the Eclipse extension registry. */ +public class ProjectIntentRuntimeModule extends AbstractProjectIntentRuntimeModule { + + /** Quotes are syntax and a boolean is a boolean: see {@link ProjectIntentValueConverters}. */ + @Override + public Class bindIValueConverterService() { + return ProjectIntentValueConverters.class; + } +} diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSource.java b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSource.java index 7867a89..0eeb948 100644 --- a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSource.java +++ b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSource.java @@ -1,8 +1,11 @@ package dev.jorisjonkers.deploykit.emf.syntax.blocks; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; +import java.util.List; import java.util.Queue; +import java.util.stream.Collectors; import org.antlr.runtime.CommonToken; import org.antlr.runtime.Token; import org.antlr.runtime.TokenSource; @@ -12,6 +15,9 @@ * reads, so the grammar states the model's shape rather than the file's layout * (emf/docs/architecture.md#concrete-syntax). * + *

A scalar introduced by {@code >} or {@code |} is folded: the indented lines under it become one + * scalar token, so the grammar reads a value rather than a layout. + * *

A line indented further than the line before it opens a block, and every level it falls back * closes one. A dash opens a block of its own around the item that follows it, so the first key of * an item sits in the same block as the keys under it. A flow collection opens and closes a block on @@ -27,6 +33,7 @@ public class BlockTokenSource implements TokenSource { private boolean lineStart = true; private boolean afterDash = false; private int flowDepth = 0; + private int lineColumn = 0; public BlockTokenSource(TokenSource delegate, BlockTokens types) { this.delegate = delegate; @@ -64,8 +71,13 @@ private void read(Token token) { private void significant(Token token) { if (lineStart && flowDepth == 0) { indent(token); + lineColumn = token.getCharPositionInLine(); } lineStart = false; + if (token.getType() == types.fold()) { + folded(token); + return; + } if (types.opensFlow(token.getType())) { afterDash = false; flowDepth++; @@ -84,6 +96,70 @@ private void significant(Token token) { } } + /** + * One scalar from the lines under a fold marker: every line indented further than the key's own + * line, with the indentation removed, joined by a space for {@code >} and by a line break for + * {@code |}. A trailing {@code -} drops the final line break, as YAML says it does. + */ + private void folded(Token fold) { + CommonToken marker = (CommonToken) fold; + List trailing = new ArrayList<>(); + Token last = marker; + Token next = delegate.nextToken(); + while (next.getType() != Token.EOF && inBlock(next)) { + if (next.getType() == types.whitespace()) { + trailing.add(next); + } else { + trailing.clear(); + last = next; + } + next = delegate.nextToken(); + } + int end = ((CommonToken) last).getStopIndex(); + String raw = marker.getInputStream().substring(marker.getStartIndex(), end); + CommonToken scalar = new CommonToken(types.scalar(), fold(marker.getText(), raw)); + scalar.setLine(marker.getLine()); + scalar.setCharPositionInLine(marker.getCharPositionInLine()); + scalar.setStartIndex(marker.getStartIndex()); + scalar.setStopIndex(end); + pending.add(scalar); + lineStart = true; + trailing.forEach(this::read); + read(next); + } + + /** + * Whether {@code token} still belongs to the block under the fold marker: whitespace always, and + * anything else while it is indented further than the key that opened the block. A {@code #} inside + * the block is content, as YAML says it is; one at or left of the key is a comment on what follows. + */ + private boolean inBlock(Token token) { + return token.getType() == types.whitespace() || token.getCharPositionInLine() > lineColumn; + } + + /** + * The value of a block scalar, from the marker's own line onwards: the lines under it with their + * common indentation removed, + * joined by a line break under {@code |} and by a space under {@code >}, where a blank line is + * the paragraph break the subset does not carry. A trailing {@code -} drops the final line break. + */ + private static String fold(String marker, String raw) { + List lines = new ArrayList<>(List.of(raw.split("\n", -1))); + lines.remove(0); // the marker's own line, which carries no content + int indent = lines.stream() + .filter(line -> !line.isBlank()) + .mapToInt(line -> line.length() - line.stripLeading().length()) + .min() + .orElse(0); + List content = lines.stream() + .map(line -> line.isBlank() ? "" : line.substring(indent).stripTrailing()) + .toList(); + String joined = marker.startsWith("|") + ? String.join("\n", content) + : content.stream().filter(line -> !line.isEmpty()).collect(Collectors.joining(" ")); + return marker.endsWith("-") ? joined : joined + "\n"; + } + /** Opens or closes the blocks the column of the line's first token asks for. */ private void indent(Token token) { int column = token.getCharPositionInLine(); diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokens.java b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokens.java index 1543b23..222d1a0 100644 --- a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokens.java +++ b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokens.java @@ -13,6 +13,8 @@ * @param listBegin an opening bracket * @param listEnd a closing bracket * @param separator the comma between flow entries + * @param fold the marker of a scalar written over several lines + * @param scalar a plain scalar, which is what a folded one becomes */ public record BlockTokens( int begin, @@ -24,7 +26,9 @@ public record BlockTokens( int flowEnd, int listBegin, int listEnd, - int separator) { + int separator, + int fold, + int scalar) { /** Whether {@code type} opens a flow collection, which is a block on one line. */ public boolean opensFlow(int type) { diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/ProjectIntentTokenSource.java b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/ProjectIntentTokenSource.java index e13916e..248eba8 100644 --- a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/ProjectIntentTokenSource.java +++ b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/ProjectIntentTokenSource.java @@ -20,7 +20,9 @@ public class ProjectIntentTokenSource extends BlockTokenSource { InternalProjectIntentParser.RULE_FLOW_END, InternalProjectIntentParser.RULE_LIST_BEGIN, InternalProjectIntentParser.RULE_LIST_END, - InternalProjectIntentParser.RULE_FLOW_SEP); + InternalProjectIntentParser.RULE_FLOW_SEP, + InternalProjectIntentParser.RULE_FOLD, + InternalProjectIntentParser.RULE_SCALAR); public ProjectIntentTokenSource(TokenSource delegate) { super(delegate, TYPES); diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConverters.java b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConverters.java new file mode 100644 index 0000000..451c2e1 --- /dev/null +++ b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConverters.java @@ -0,0 +1,52 @@ +package dev.jorisjonkers.deploykit.emf.syntax.values; + +import org.eclipse.xtext.common.services.DefaultTerminalConverters; +import org.eclipse.xtext.conversion.IValueConverter; +import org.eclipse.xtext.conversion.ValueConverter; +import org.eclipse.xtext.conversion.ValueConverterException; +import org.eclipse.xtext.nodemodel.INode; + +/** What the authored text of a scalar means: quotes are syntax, and a boolean is a boolean. */ +public class ProjectIntentValueConverters extends DefaultTerminalConverters { + + @ValueConverter(rule = "Text") + public IValueConverter text() { + return new IValueConverter<>() { + @Override + public String toValue(String string, INode node) { + return unquote(string); + } + + @Override + public String toString(String value) { + return value; + } + }; + } + + @ValueConverter(rule = "Bool") + public IValueConverter bool() { + return new IValueConverter<>() { + @Override + public Boolean toValue(String string, INode node) throws ValueConverterException { + if (!"true".equals(string) && !"false".equals(string)) { + throw new ValueConverterException(string + " is not true or false", node, null); + } + return Boolean.valueOf(string); + } + + @Override + public String toString(Boolean value) { + return value.toString(); + } + }; + } + + /** The text of a quoted scalar without its quotes; a plain scalar unchanged. */ + static String unquote(String text) { + boolean quoted = text.length() > 1 + && (text.charAt(0) == '"' || text.charAt(0) == '\'') + && text.charAt(text.length() - 1) == text.charAt(0); + return quoted ? text.substring(1, text.length() - 1) : text; + } +} diff --git a/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSourceTest.java b/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSourceTest.java index 42dc8fd..b85ddf0 100644 --- a/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSourceTest.java +++ b/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSourceTest.java @@ -15,6 +15,17 @@ /** The block structure the grammar reads, as the tokens this source produces for authored YAML. */ class BlockTokenSourceTest { + /** Every token of {@code text}, hidden ones included. */ + private static List tokens(String text) { + ProjectIntentTokenSource source = + new ProjectIntentTokenSource(new InternalProjectIntentLexer(new ANTLRStringStream(text))); + List tokens = new ArrayList<>(); + for (Token token = source.nextToken(); token.getType() != Token.EOF; token = source.nextToken()) { + tokens.add(token); + } + return tokens; + } + /** Every significant token of {@code text}, as "BEGIN", "END" or its own text. */ private static List blocks(String text) { ProjectIntentTokenSource source = @@ -114,6 +125,70 @@ void aCommentLineOpensNoBlock() { assertThat(blocks("a: 1\n # a comment, indented\nb: 2\n")).containsExactly("a", ":", "1", "b", ":", "2"); } + @Test + void aFoldedScalarIsOneValueWithItsLinesJoinedBySpaces() { + assertThat(blocks("a: >-\n one line\n and another\nb: 2\n")) + .containsExactly("a", ":", "one line and another", "b", ":", "2"); + } + + @Test + void aLiteralScalarKeepsItsLineBreaks() { + assertThat(blocks("a: |-\n one line\n and another\n")).containsExactly("a", ":", "one line\nand another"); + } + + @Test + void aFoldWithoutAChompKeepsTheClosingLineBreak() { + assertThat(blocks("a: >\n one line\n")).containsExactly("a", ":", "one line\n"); + } + + @Test + void aBlankLineInsideAFoldedScalarIsNotRead() { + assertThat(blocks("a: >-\n one\n\n two\n")).containsExactly("a", ":", "one two"); + } + + @Test + void aBlankLineInsideALiteralScalarIsKept() { + assertThat(blocks("a: |-\n one\n\n two\n")).containsExactly("a", ":", "one\n\ntwo"); + } + + @Test + void whatFollowsAFoldedScalarIsReadAsUsual() { + assertThat(blocks("a: >-\n one\n # not a comment, part of the scalar\nb:\n c: 2\n")) + .containsExactly( + "a", ":", "one # not a comment, part of the scalar", "b", ":", "BEGIN", "c", ":", "2", "END"); + } + + @Test + void aFoldedScalarSitsWhereItsMarkerSatAndCoversItsWholeBlock() { + String source = "a: >-\n one line\n and another\nb: 2\n"; + + CommonToken folded = (CommonToken) tokens(source).stream() + .filter(token -> "one line and another".equals(token.getText())) + .findFirst() + .orElseThrow(); + + assertThat(folded.getLine()).isEqualTo(1); + assertThat(folded.getCharPositionInLine()).isEqualTo(3); + assertThat(folded.getStartIndex()).isEqualTo(source.indexOf('>')); + assertThat(folded.getStopIndex()).isEqualTo(source.indexOf("another") + "another".length() - 1); + } + + @Test + void everyCharacterOfTheFileIsCoveredByExactlyOneToken() { + String source = "a: >-\n one\n two\n\nb:\n c: 2\n"; + int next = 0; + + for (Token token : tokens(source)) { + CommonToken common = (CommonToken) token; + if (!token.getText().isEmpty()) { + assertThat(common.getStartIndex()).as(token.getText()).isEqualTo(next); + next = common.getStopIndex() + 1; + } + } + + assertThat(next).isEqualTo(source.length()); + } + @Test void aFlowSequenceIsAnEmptyBlockRatherThanAnItem() { assertThat(blocks("a: []\n")).containsExactly("a", ":", "BEGIN", "END"); diff --git a/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokensTest.java b/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokensTest.java index 85f891a..63d1ab7 100644 --- a/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokensTest.java +++ b/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokensTest.java @@ -7,7 +7,7 @@ /** Which token types open and close a flow collection. */ class BlockTokensTest { - private static final BlockTokens TYPES = new BlockTokens(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + private static final BlockTokens TYPES = new BlockTokens(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); @Test void aBraceAndABracketOpenAndCloseAFlowCollection() { @@ -37,5 +37,7 @@ void theTypesAreTheOnesItWasGiven() { assertThat(TYPES.listBegin()).isEqualTo(8); assertThat(TYPES.listEnd()).isEqualTo(9); assertThat(TYPES.separator()).isEqualTo(10); + assertThat(TYPES.fold()).isEqualTo(11); + assertThat(TYPES.scalar()).isEqualTo(12); } } diff --git a/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConvertersTest.java b/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConvertersTest.java new file mode 100644 index 0000000..ac90461 --- /dev/null +++ b/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConvertersTest.java @@ -0,0 +1,42 @@ +package dev.jorisjonkers.deploykit.emf.syntax.values; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.eclipse.xtext.conversion.ValueConverterException; +import org.junit.jupiter.api.Test; + +/** What the authored text of a scalar means. */ +class ProjectIntentValueConvertersTest { + + private static final ProjectIntentValueConverters CONVERTERS = new ProjectIntentValueConverters(); + + @Test + void aQuotedScalarLosesItsQuotesAndAPlainOneIsUnchanged() { + assertThat(CONVERTERS.text().toValue("\"0400\"", null)).isEqualTo("0400"); + assertThat(CONVERTERS.text().toValue("'0400'", null)).isEqualTo("0400"); + assertThat(CONVERTERS.text().toValue("0400", null)).isEqualTo("0400"); + assertThat(CONVERTERS.text().toValue("\"", null)).isEqualTo("\""); + assertThat(CONVERTERS.text().toValue("\"unbalanced", null)).isEqualTo("\"unbalanced"); + assertThat(CONVERTERS.text().toValue("'mixed\"", null)).isEqualTo("'mixed\""); + } + + @Test + void theTextOfAScalarIsTheValueItself() { + assertThat(CONVERTERS.text().toString("0400")).isEqualTo("0400"); + } + + @Test + void aBooleanIsABoolean() { + assertThat(CONVERTERS.bool().toValue("true", null)).isTrue(); + assertThat(CONVERTERS.bool().toValue("false", null)).isFalse(); + assertThat(CONVERTERS.bool().toString(true)).isEqualTo("true"); + } + + @Test + void anythingElseIsNotABoolean() { + assertThatThrownBy(() -> CONVERTERS.bool().toValue("yes", null)) + .isInstanceOf(ValueConverterException.class) + .hasMessageContaining("yes is not true or false"); + } +} diff --git a/spec/v1/examples/auth/auth.project.yml b/spec/v1/examples/auth/auth.project.yml index 444c2c0..ceb99c7 100644 --- a/spec/v1/examples/auth/auth.project.yml +++ b/spec/v1/examples/auth/auth.project.yml @@ -25,6 +25,8 @@ # bumped only when the model changes, so a literal here is legitimate and does # not go stale on a toolkit release. Composition admits same-major, minor <= # toolkit; the composition lock records the exact versions that ran. +apiVersion: intent.jorisjonkers.dev/v1 +kind: Project schemaVersion: 1.0.0 # The header is three fields, and `owner` is the ONLY one raised to the project. diff --git a/spec/v1/examples/auth/expected/intent.json b/spec/v1/examples/auth/expected/intent.json new file mode 100644 index 0000000..e9311b4 --- /dev/null +++ b/spec/v1/examples/auth/expected/intent.json @@ -0,0 +1 @@ +{"apiVersion":"intent.jorisjonkers.dev/v1","applications":[{"exposure":[{"audience":"anonymous","contentPolicy":"strict","host":"auth.jorisjonkers.dev","name":"public","routes":[{"match":"prefix","path":"/api","process":"auth-api","surface":"http"},{"match":"prefix","path":"/","process":"auth-ui","surface":"http"}]}],"id":"auth","observability":{"alertClass":"page","scrape":{"path":"/api/actuator/prometheus","process":"auth-api","surface":"http"}},"processes":[{"cutover":"rolling","dependsOn":[{"application":"platform-postgres","surface":"postgres"},{"application":"platform-valkey","surface":"redis"},{"application":"platform-rabbitmq","surface":"amqp"},{"application":"stalwart","surface":"smtp"}],"image":"auth-api","lifecycle":"application","name":"auth-api","placement":{"arch":["amd64"],"capabilities":["public-ingress"],"cpu":"250m","memory":"768Mi"},"probes":{"liveness":{"path":"/api/actuator/health/liveness","port":8081},"readiness":{"path":"/api/actuator/health/readiness","port":8081}},"provides":{"http":8080},"replicas":{"count":2,"reason":"Capacity, not availability: the pair was provisioned on freed Frankfurt budget and both pods land on the one public-ingress node. Retires when that budget is reclaimed or the capability spreads to a second node."},"runtime":"jvm","secrets":[{"access":"read","delivery":"self","keys":["user","password"],"path":"secret/data/platform/postgres/auth","rotation":{"maxAge":"168h","tolerates":"reload"}},{"access":"read","delivery":"self","keys":["mail.password","valkey.password","rabbitmq.user","rabbitmq.password"],"path":"secret/data/auth-api","rotation":{"tolerates":"reload"}},{"delivery":"self","engine":"transit","key":"auth-api-jwt","operations":["sign","rotate"],"rotation":{"tolerates":"reload"}}],"startupBudget":"600s","writablePaths":["/tmp"]},{"cutover":"rolling","image":"auth-ui","lifecycle":"application","name":"auth-ui","placement":{"cpu":"10m","memory":"64Mi"},"probes":{"liveness":{"path":"/","port":8080},"readiness":{"path":"/","port":8080}},"provides":{"http":8080},"runtime":"static","startupBudget":"30s","writablePaths":["/var/cache/nginx","/var/run"]}]}],"kind":"Project","owner":"joris","project":"auth","schemaVersion":"1.0.0"} \ No newline at end of file diff --git a/spec/v1/examples/data/data.project.yml b/spec/v1/examples/data/data.project.yml index 2467498..ab7d22f 100644 --- a/spec/v1/examples/data/data.project.yml +++ b/spec/v1/examples/data/data.project.yml @@ -27,6 +27,8 @@ # The data model's own semver, not the toolkit's # (docs/adr/model/0039-artifact-schema-versioning.md). +apiVersion: intent.jorisjonkers.dev/v1 +kind: Project schemaVersion: 1.0.0 project: data # namespace derives: data-system @@ -171,7 +173,6 @@ applications: # the owner states the stop-then-start they already have. cutover: recreate - stateful: true volumes: - claim: postgres-data mountAt: /var/lib/postgresql/data @@ -325,7 +326,6 @@ applications: # would be E_CUTOVER_UNHONOURABLE, not a silent Recreate. cutover: recreate - stateful: true volumes: - claim: rabbitmq-data mountAt: /var/lib/rabbitmq @@ -382,7 +382,6 @@ applications: # stop-then-start costs a cold cache and nothing else. cutover: recreate - stateful: true volumes: - claim: valkey-data mountAt: /data diff --git a/spec/v1/examples/data/expected/intent.json b/spec/v1/examples/data/expected/intent.json new file mode 100644 index 0000000..26a43b9 --- /dev/null +++ b/spec/v1/examples/data/expected/intent.json @@ -0,0 +1 @@ +{"apiVersion":"intent.jorisjonkers.dev/v1","applications":[{"id":"platform-postgres","observability":{"alertClass":"page","scrape":{"path":"/metrics","process":"postgres","surface":"metrics"}},"processes":[{"assets":[{"from":"config/postgresql.conf","mountAt":"/etc/postgresql/postgresql.conf"}],"cutover":"recreate","engine":"postgres","image":"postgres","lifecycle":"application","name":"postgres","placement":{"arch":["amd64"],"cpu":"500m","disk":{"media":["nvme","ssd"]},"memory":"2Gi"},"probes":{"liveness":{"tcp":5432},"readiness":{"tcp":5432}},"provides":{"metrics":9187,"postgres":5432},"runtime":"none","secrets":[{"access":"read","delivery":"env","keys":["datasource"],"path":"secret/data/platform/postgres/exporter","rotation":{"tolerates":"restart"}}],"sidecars":[{"cpu":"10m","image":"postgres-exporter","memory":"64Mi","name":"postgres-exporter"}],"startupBudget":"60s","volumes":[{"claim":"postgres-data","durability":"irreplaceable","mountAt":"/var/lib/postgresql/data"}],"writablePaths":["/var/run/postgresql","/tmp"]}]},{"exposure":[{"audience":"authenticated","host":"rabbitmq.jorisjonkers.dev","name":"management","routes":[{"match":"prefix","path":"/","process":"rabbitmq","surface":"management"}]}],"id":"platform-rabbitmq","observability":{"alertClass":"urgent","scrape":{"path":"/metrics","process":"rabbitmq","surface":"metrics"}},"processes":[{"cutover":"recreate","engine":"rabbitmq","image":"rabbitmq","lifecycle":"application","name":"rabbitmq","placement":{"cpu":"500m","memory":"2Gi"},"probes":{"liveness":{"tcp":5672},"readiness":{"tcp":5672}},"provides":{"amqp":5672,"management":15672,"metrics":15692},"runtime":"none","startupBudget":"120s","volumes":[{"claim":"rabbitmq-data","durability":"recoverable","mountAt":"/var/lib/rabbitmq","size":"20Gi"}]}]},{"id":"platform-valkey","processes":[{"cutover":"recreate","image":"valkey","lifecycle":"application","name":"valkey","placement":{"cpu":"50m","memory":"256Mi"},"probes":{"liveness":{"tcp":6379},"readiness":{"tcp":6379}},"provides":{"redis":6379},"runtime":"none","startupBudget":"30s","volumes":[{"claim":"valkey-data","durability":"reconstructible","mountAt":"/data","size":"2Gi"}]}]}],"kind":"Project","owner":"joris","project":"data","schemaVersion":"1.0.0"} \ No newline at end of file diff --git a/spec/v1/examples/expected/descriptor.json b/spec/v1/examples/expected/descriptor.json new file mode 100644 index 0000000..2f44a8e --- /dev/null +++ b/spec/v1/examples/expected/descriptor.json @@ -0,0 +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 diff --git a/spec/v1/examples/knowledge/expected/intent.json b/spec/v1/examples/knowledge/expected/intent.json new file mode 100644 index 0000000..07fa5a5 --- /dev/null +++ b/spec/v1/examples/knowledge/expected/intent.json @@ -0,0 +1 @@ +{"apiVersion":"intent.jorisjonkers.dev/v1","applications":[{"exposure":[{"audience":"authenticated","host":"knowledge.jorisjonkers.dev","name":"public","routes":[{"audience":"anonymous","match":"exact","path":"/mcp","process":"knowledge-api","surface":"http"},{"audience":"anonymous","match":"prefix","path":"/mcp/","process":"knowledge-api","surface":"http"},{"audience":"anonymous","match":"exact","path":"/install.sh","process":"knowledge-api","surface":"http"},{"audience":"anonymous","match":"exact","path":"/install-agents.sh","process":"knowledge-api","surface":"http"},{"match":"prefix","path":"/","process":"knowledge-api","surface":"http"}]}],"id":"knowledge","observability":{"alertClass":"business-hours","scrape":{"path":"/api/actuator/prometheus","process":"knowledge-api","surface":"http"}},"processes":[{"cutover":"rolling","dependsOn":[{"application":"platform-postgres","surface":"postgres"},{"application":"platform-rabbitmq","surface":"amqp"}],"image":"knowledge-api","lifecycle":"application","name":"knowledge-api","placement":{"capabilities":["public-ingress"],"cpu":"250m","memory":"768Mi"},"probes":{"liveness":{"path":"/api/actuator/health/liveness","port":8080},"readiness":{"path":"/api/actuator/health/readiness","port":8080}},"provides":{"http":8080},"runtime":"jvm","secrets":[{"access":"read","delivery":"env","keys":["workstation"],"path":"secret/data/knowledge-system/mcp-bearer","rotation":{"tolerates":"restart"}}],"startupBudget":"600s","writablePaths":["/tmp"]},{"cutover":"recreate","dependsOn":[{"application":"platform-rabbitmq","surface":"amqp"},{"application":"platform-postgres","surface":"postgres"}],"engine":"files","image":"knowledge-ingest-worker","lifecycle":"application","name":"knowledge-ingest-worker","placement":{"cpu":"50m","memory":"256Mi"},"probes":"none","runtime":"python","secrets":[{"access":"read","delivery":"file","fileMode":"0400","keys":["key"],"mountAt":"/home/worker/.ssh/id_ed25519","path":"secret/data/knowledge-system/vault-deploy-key","rotation":{"tolerates":"restart"}}],"startupBudget":"120s","volumes":[{"claim":"knowledge-vault-clone","durability":"irreplaceable","mountAt":"/var/lib/knowledge-vault","size":"20Gi"}]}],"secrets":[{"access":"read","delivery":"env","keys":["user","password"],"path":"secret/data/platform/postgres/kb","rotation":{"tolerates":"restart"}},{"access":"read","delivery":"env","keys":["rabbitmq.user","rabbitmq.password"],"path":"secret/data/platform/rabbitmq","rotation":{"tolerates":"restart"}}]}],"kind":"Project","owner":"joris","project":"knowledge","schemaVersion":"1.0.0"} \ No newline at end of file diff --git a/spec/v1/examples/knowledge/knowledge.project.yml b/spec/v1/examples/knowledge/knowledge.project.yml index c77e161..174d8d9 100644 --- a/spec/v1/examples/knowledge/knowledge.project.yml +++ b/spec/v1/examples/knowledge/knowledge.project.yml @@ -22,6 +22,8 @@ # The data model's own semver, not the toolkit's # (docs/adr/model/0039-artifact-schema-versioning.md). +apiVersion: intent.jorisjonkers.dev/v1 +kind: Project schemaVersion: 1.0.0 project: knowledge @@ -283,7 +285,6 @@ applications: # docs/adr/model/0078-engine-is-process-vocabulary.md). engine: files - stateful: true volumes: - claim: knowledge-vault-clone mountAt: /var/lib/knowledge-vault 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 e7695ed..32b9b67 100644 --- a/spec/v1/examples/refusals/cutover-recreate-over-rwo.project.yml +++ b/spec/v1/examples/refusals/cutover-recreate-over-rwo.project.yml @@ -30,7 +30,6 @@ applications: image: recreate-over-rwo-store runtime: static engine: valkey - stateful: true provides: redis: 6379 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 b512be6..3848c85 100644 --- a/spec/v1/examples/refusals/cutover-rolling-over-rwo.project.yml +++ b/spec/v1/examples/refusals/cutover-rolling-over-rwo.project.yml @@ -32,7 +32,6 @@ applications: image: rolling-over-rwo-store runtime: static engine: valkey - stateful: true provides: redis: 6379 diff --git a/spec/v1/schemas/project-intent.schema.json b/spec/v1/schemas/project-intent.schema.json new file mode 100644 index 0000000..06dce10 --- /dev/null +++ b/spec/v1/schemas/project-intent.schema.json @@ -0,0 +1,835 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/$defs/Project", + "$defs": { + "Project": { + "type": "object", + "properties": { + "apiVersion": { + "type": "string", + "const": "intent.jorisjonkers.dev/v1" + }, + "kind": { + "type": "string", + "const": "Project" + }, + "schemaVersion": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "project": { + "type": "string", + "minLength": 1 + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "applications": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/Application" + } + } + }, + "required": [ + "apiVersion", + "kind", + "schemaVersion", + "project", + "owner", + "applications" + ], + "additionalProperties": false + }, + "Application": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "observability": { + "$ref": "#/$defs/Observability" + }, + "exposure": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/Exposure" + } + }, + "secrets": { + "minItems": 1, + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/KvGrant" + }, + { + "$ref": "#/$defs/DatabaseGrant" + }, + { + "$ref": "#/$defs/TransitGrant" + } + ] + } + }, + "processes": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/Process" + } + } + }, + "required": [ + "id", + "processes" + ], + "additionalProperties": false + }, + "Observability": { + "type": "object", + "properties": { + "alertClass": { + "$ref": "#/$defs/AlertClass" + }, + "scrape": { + "$ref": "#/$defs/Scrape" + } + }, + "required": [ + "alertClass", + "scrape" + ], + "additionalProperties": false + }, + "AlertClass": { + "type": "string", + "enum": [ + "business-hours", + "urgent", + "page" + ] + }, + "Scrape": { + "type": "object", + "properties": { + "process": { + "type": "string", + "minLength": 1 + }, + "surface": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "process", + "surface", + "path" + ], + "additionalProperties": false + }, + "Exposure": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "host": { + "type": "string", + "minLength": 1 + }, + "audience": { + "$ref": "#/$defs/Audience" + }, + "contentPolicy": { + "$ref": "#/$defs/ContentPolicy" + }, + "routes": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/Route" + } + } + }, + "required": [ + "name", + "host", + "audience", + "routes" + ], + "additionalProperties": false + }, + "Audience": { + "type": "string", + "enum": [ + "anonymous", + "authenticated", + "internal", + "lan" + ] + }, + "ContentPolicy": { + "type": "string", + "enum": [ + "strict", + "admin", + "workflow" + ] + }, + "Route": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "match": { + "$ref": "#/$defs/Match" + }, + "process": { + "type": "string", + "minLength": 1 + }, + "surface": { + "type": "string", + "minLength": 1 + }, + "audience": { + "$ref": "#/$defs/Audience" + }, + "redirectTo": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "path", + "match", + "process", + "surface" + ], + "additionalProperties": false + }, + "Match": { + "type": "string", + "enum": [ + "prefix", + "exact" + ] + }, + "KvGrant": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "keys": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "access": { + "$ref": "#/$defs/AccessTier" + }, + "delivery": { + "$ref": "#/$defs/Delivery" + }, + "mountAt": { + "type": "string", + "minLength": 1 + }, + "fileMode": { + "type": "string", + "minLength": 1 + }, + "rotation": { + "$ref": "#/$defs/Rotation" + } + }, + "required": [ + "path", + "keys", + "access", + "delivery" + ], + "additionalProperties": false + }, + "AccessTier": { + "type": "string", + "enum": [ + "read", + "self-renew", + "self-roll", + "custody" + ] + }, + "Delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "Rotation": { + "type": "object", + "properties": { + "tolerates": { + "$ref": "#/$defs/Tolerance" + }, + "maxAge": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + }, + "Tolerance": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "DatabaseGrant": { + "type": "object", + "properties": { + "engine": { + "$ref": "#/$defs/DatabaseEngine" + }, + "role": { + "type": "string", + "minLength": 1 + }, + "delivery": { + "$ref": "#/$defs/Delivery" + }, + "mountAt": { + "type": "string", + "minLength": 1 + }, + "fileMode": { + "type": "string", + "minLength": 1 + }, + "rotation": { + "$ref": "#/$defs/Rotation" + } + }, + "required": [ + "engine", + "role", + "delivery" + ], + "additionalProperties": false + }, + "DatabaseEngine": { + "type": "string", + "enum": [ + "database" + ] + }, + "TransitGrant": { + "type": "object", + "properties": { + "engine": { + "$ref": "#/$defs/TransitEngine" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "operations": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/TransitOp" + } + }, + "delivery": { + "$ref": "#/$defs/Delivery" + }, + "mountAt": { + "type": "string", + "minLength": 1 + }, + "fileMode": { + "type": "string", + "minLength": 1 + }, + "rotation": { + "$ref": "#/$defs/Rotation" + } + }, + "required": [ + "engine", + "key", + "operations", + "delivery" + ], + "additionalProperties": false + }, + "TransitEngine": { + "type": "string", + "enum": [ + "transit" + ] + }, + "TransitOp": { + "type": "string", + "enum": [ + "sign", + "verify", + "encrypt", + "decrypt", + "rotate" + ] + }, + "Process": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "lifecycle": { + "$ref": "#/$defs/Lifecycle" + }, + "image": { + "type": "string", + "minLength": 1 + }, + "runtime": { + "$ref": "#/$defs/Runtime" + }, + "engine": { + "$ref": "#/$defs/Engine" + }, + "provides": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "placement": { + "$ref": "#/$defs/Placement" + }, + "writablePaths": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "sidecars": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/Sidecar" + } + }, + "dependsOn": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/DependencyEdge" + } + }, + "assets": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/Asset" + } + }, + "probes": { + "anyOf": [ + { + "$ref": "#/$defs/NoProbes" + }, + { + "$ref": "#/$defs/Probes" + } + ] + }, + "volumes": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/Volume" + } + }, + "replicas": { + "$ref": "#/$defs/Capacity" + }, + "secrets": { + "minItems": 1, + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/KvGrant" + }, + { + "$ref": "#/$defs/DatabaseGrant" + }, + { + "$ref": "#/$defs/TransitGrant" + } + ] + } + }, + "startupBudget": { + "type": "string", + "minLength": 1 + }, + "cutover": { + "$ref": "#/$defs/Cutover" + } + }, + "required": [ + "name", + "lifecycle", + "image", + "runtime", + "placement", + "cutover" + ], + "additionalProperties": false + }, + "Lifecycle": { + "type": "string", + "enum": [ + "application", + "job" + ] + }, + "Runtime": { + "type": "string", + "enum": [ + "jvm", + "python", + "node", + "static", + "none" + ] + }, + "Engine": { + "type": "string", + "enum": [ + "postgres", + "rabbitmq", + "valkey", + "files" + ] + }, + "Placement": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "minLength": 1 + }, + "cpu": { + "type": "string", + "minLength": 1 + }, + "arch": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/Arch" + } + }, + "site": { + "type": "string", + "minLength": 1 + }, + "disk": { + "$ref": "#/$defs/DiskRequest" + }, + "gpu": { + "$ref": "#/$defs/GpuRequest" + }, + "capabilities": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "memory", + "cpu" + ], + "additionalProperties": false + }, + "Arch": { + "type": "string", + "enum": [ + "amd64", + "arm64" + ] + }, + "DiskRequest": { + "type": "object", + "properties": { + "media": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/Media" + } + } + }, + "required": [ + "media" + ], + "additionalProperties": false + }, + "Media": { + "type": "string", + "enum": [ + "nvme", + "ssd", + "hdd" + ] + }, + "GpuRequest": { + "type": "object", + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "memory": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "class", + "memory" + ], + "additionalProperties": false + }, + "Sidecar": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "image": { + "type": "string", + "minLength": 1 + }, + "memory": { + "type": "string", + "minLength": 1 + }, + "cpu": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "name", + "image", + "memory", + "cpu" + ], + "additionalProperties": false + }, + "DependencyEdge": { + "type": "object", + "properties": { + "application": { + "type": "string", + "minLength": 1 + }, + "surface": { + "type": "string", + "minLength": 1 + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "application", + "surface" + ], + "additionalProperties": false + }, + "Asset": { + "type": "object", + "properties": { + "from": { + "type": "string", + "minLength": 1 + }, + "mountAt": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "from", + "mountAt" + ], + "additionalProperties": false + }, + "NoProbes": { + "type": "string", + "const": "none" + }, + "Probes": { + "type": "object", + "properties": { + "readiness": { + "anyOf": [ + { + "$ref": "#/$defs/HttpProbe" + }, + { + "$ref": "#/$defs/TcpProbe" + } + ] + }, + "liveness": { + "anyOf": [ + { + "$ref": "#/$defs/HttpProbe" + }, + { + "$ref": "#/$defs/TcpProbe" + } + ] + } + }, + "additionalProperties": false + }, + "HttpProbe": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "path", + "port" + ], + "additionalProperties": false + }, + "TcpProbe": { + "type": "object", + "properties": { + "tcp": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "tcp" + ], + "additionalProperties": false + }, + "Volume": { + "type": "object", + "properties": { + "claim": { + "type": "string", + "minLength": 1 + }, + "mountAt": { + "type": "string", + "minLength": 1 + }, + "size": { + "type": "string", + "minLength": 1 + }, + "durability": { + "$ref": "#/$defs/DurabilityClass" + } + }, + "required": [ + "claim", + "mountAt", + "durability" + ], + "additionalProperties": false + }, + "DurabilityClass": { + "type": "string", + "enum": [ + "reconstructible", + "recoverable", + "irreplaceable" + ] + }, + "Capacity": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 2, + "maximum": 9007199254740991 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "count", + "reason" + ], + "additionalProperties": false + }, + "Cutover": { + "type": "string", + "enum": [ + "rolling", + "recreate" + ] + } + } +} diff --git a/src/domain/project-intent/model.ts b/src/domain/project-intent/model.ts index 3b4e38e..6284f40 100644 --- a/src/domain/project-intent/model.ts +++ b/src/domain/project-intent/model.ts @@ -1,11 +1,19 @@ import type { + AccessTier, AlertClass, + Arch, Audience, ContentPolicy, Cutover, + Delivery, + DurabilityClass, + Engine, Lifecycle, Match, + Medium, Runtime, + Tolerance, + TransitOperation, } from "./vocabularies.ts"; export interface Project { @@ -18,6 +26,7 @@ export interface Application { readonly id: string; readonly observability?: Observability; readonly exposures: readonly Exposure[]; + readonly grants: readonly Grant[]; readonly processes: readonly Process[]; } @@ -35,18 +44,92 @@ export interface Exposure { readonly name: string; readonly host: string; readonly audience: Audience; - readonly contentPolicy: ContentPolicy; + readonly contentPolicy?: ContentPolicy; readonly routes: readonly Route[]; } export interface Route extends SurfaceRef { readonly path: string; readonly match: Match; + readonly audience?: Audience; + readonly redirectTo?: string; } -export interface Probe { +export type Probe = + { readonly path: string; readonly port: number } | { readonly tcp: number }; + +export type ProbePolicy = + "none" | { readonly readiness?: Probe; readonly liveness?: Probe }; + +export interface Rotation { + readonly tolerates: Tolerance; + readonly maxAge?: string; +} + +interface GrantDelivery { + readonly delivery: Delivery; + readonly mountAt?: string; + readonly fileMode?: string; + readonly rotation?: Rotation; +} + +export interface KvGrant extends GrantDelivery { readonly path: string; - readonly port: number; + readonly keys: readonly string[]; + readonly access: AccessTier; +} + +export interface DatabaseGrant extends GrantDelivery { + readonly engine: "database"; + readonly role: string; +} + +export interface TransitGrant extends GrantDelivery { + readonly engine: "transit"; + readonly key: string; + readonly operations: readonly TransitOperation[]; +} + +export type Grant = KvGrant | DatabaseGrant | TransitGrant; + +export interface Placement { + readonly memory: string; + readonly cpu: string; + readonly arch: readonly Arch[]; + readonly site?: string; + readonly disk?: { readonly media: readonly Medium[] }; + readonly gpu?: { readonly class: string; readonly memory: string }; + readonly capabilities: readonly string[]; +} + +export interface Sidecar { + readonly name: string; + readonly image: string; + readonly memory: string; + readonly cpu: string; +} + +export interface Dependency { + readonly application: string; + readonly surface: string; + readonly required: boolean; +} + +export interface Asset { + readonly from: string; + readonly mountAt: string; +} + +export interface Volume { + readonly claim: string; + readonly mountAt: string; + readonly size?: string; + readonly durability: DurabilityClass; +} + +export interface Capacity { + readonly count: number; + readonly reason: string; } export interface Process { @@ -54,9 +137,17 @@ export interface Process { readonly lifecycle: Lifecycle; readonly image: string; readonly runtime: Runtime; + readonly engine?: Engine; readonly provides: ReadonlyMap; - readonly placement: { readonly memory: string; readonly cpu: string }; - readonly probes: { readonly readiness?: Probe; readonly liveness?: Probe }; + readonly placement: Placement; + readonly writablePaths: readonly string[]; + readonly sidecars: readonly Sidecar[]; + readonly dependencies: readonly Dependency[]; + readonly assets: readonly Asset[]; + readonly probes: ProbePolicy; + readonly volumes: readonly Volume[]; + readonly replicas?: Capacity; + readonly grants: readonly Grant[]; readonly startupBudget?: string; readonly cutover: Cutover; } diff --git a/src/domain/project-intent/vocabularies.ts b/src/domain/project-intent/vocabularies.ts index 836cb5c..6137c21 100644 --- a/src/domain/project-intent/vocabularies.ts +++ b/src/domain/project-intent/vocabularies.ts @@ -3,7 +3,15 @@ export const LIFECYCLES = ["application", "job"] as const; export const RUNTIMES = ["jvm", "python", "node", "static", "none"] as const; +export const ENGINES = ["postgres", "rabbitmq", "valkey", "files"] as const; export const CUTOVERS = ["rolling", "recreate"] as const; +export const DURABILITY_CLASSES = [ + "reconstructible", + "recoverable", + "irreplaceable", +] as const; +export const ARCHITECTURES = ["amd64", "arm64"] as const; +export const MEDIA = ["nvme", "ssd", "hdd"] as const; export const ALERT_CLASSES = ["business-hours", "urgent", "page"] as const; export const AUDIENCES = [ "anonymous", @@ -13,11 +21,38 @@ export const AUDIENCES = [ ] as const; export const CONTENT_POLICIES = ["strict", "admin", "workflow"] as const; export const MATCHES = ["prefix", "exact"] as const; +export const DATABASE_ENGINES = ["database"] as const; +export const TRANSIT_ENGINES = ["transit"] as const; +export const ACCESS_TIERS = [ + "read", + "self-renew", + "self-roll", + "custody", +] as const; +export const TRANSIT_OPERATIONS = [ + "sign", + "verify", + "encrypt", + "decrypt", + "rotate", +] as const; +export const DELIVERIES = ["env", "file", "self"] as const; +export const TOLERANCES = ["restart", "reload"] as const; export type Lifecycle = (typeof LIFECYCLES)[number]; export type Runtime = (typeof RUNTIMES)[number]; +export type Engine = (typeof ENGINES)[number]; export type Cutover = (typeof CUTOVERS)[number]; +export type DurabilityClass = (typeof DURABILITY_CLASSES)[number]; +export type Arch = (typeof ARCHITECTURES)[number]; +export type Medium = (typeof MEDIA)[number]; export type AlertClass = (typeof ALERT_CLASSES)[number]; export type Audience = (typeof AUDIENCES)[number]; export type ContentPolicy = (typeof CONTENT_POLICIES)[number]; export type Match = (typeof MATCHES)[number]; +export type DatabaseEngine = (typeof DATABASE_ENGINES)[number]; +export type TransitEngine = (typeof TRANSIT_ENGINES)[number]; +export type AccessTier = (typeof ACCESS_TIERS)[number]; +export type TransitOperation = (typeof TRANSIT_OPERATIONS)[number]; +export type Delivery = (typeof DELIVERIES)[number]; +export type Tolerance = (typeof TOLERANCES)[number]; diff --git a/src/index.ts b/src/index.ts index ac53d04..60f8943 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,15 @@ export { parseProjectIntent } from "./application/parse-project-intent.ts"; export type { Diagnostic, Result } from "./domain/diagnostic.ts"; export type * from "./domain/project-intent/model.ts"; +export { descriptor } from "./wire/project-intent/descriptor.ts"; +export type { + Descriptor, + DescriptorClass, + DescriptorFeature, + DescriptorVocabulary, +} from "./wire/project-intent/descriptor.ts"; +export { + JSON_SCHEMA_PATH, + projectIntentJsonSchema, +} from "./wire/project-intent/json-schema.ts"; export { canonicalJson } from "./infrastructure/canonical-json.ts"; diff --git a/src/wire/project-intent/descriptor.ts b/src/wire/project-intent/descriptor.ts new file mode 100644 index 0000000..9a726a4 --- /dev/null +++ b/src/wire/project-intent/descriptor.ts @@ -0,0 +1,115 @@ +// The descriptor the parity contract fixes: every class of the source +// metamodel with its features, and every closed vocabulary with its literals +// (docs/architecture.md#the-parity-contract). It is built from the Zod schemas +// through Zod's own JSON Schema, and normalised into the contract's shape, +// which is neither format's. +import { z } from "zod"; +import { projectIntent } from "./schema.ts"; + +export interface DescriptorFeature { + readonly name: string; + readonly types: readonly string[]; + readonly required: boolean; + readonly many: boolean; + readonly map: boolean; +} + +export interface DescriptorClass { + readonly name: string; + readonly features: readonly DescriptorFeature[]; + readonly scalar?: string; +} + +export interface DescriptorVocabulary { + readonly name: string; + readonly literals: readonly string[]; +} + +export interface Descriptor { + readonly classes: readonly DescriptorClass[]; + readonly vocabularies: readonly DescriptorVocabulary[]; +} + +type Node = Record; + +const PRIMITIVES = new Map([ + ["string", "string"], + ["integer", "int"], + ["boolean", "boolean"], +]); + +/** The types a property admits, as class, vocabulary or primitive names. */ +export function typesOf(node: Node): string[] { + const named = node["$ref"]; + if (typeof named === "string") + return [named.slice(named.lastIndexOf("/") + 1)]; + const union = node["anyOf"]; + if (Array.isArray(union)) + return union.flatMap((member) => typesOf(member as Node)).sort(); + const primitive = PRIMITIVES.get(String(node["type"])); + if (primitive === undefined) + throw new TypeError( + `${String(node["type"])} is not a type the descriptor names`, + ); + return [primitive]; +} + +function feature( + name: string, + node: Node, + required: boolean, +): DescriptorFeature { + if (node["type"] === "array") + return { + name, + types: typesOf(node["items"] as Node), + required, + many: true, + map: false, + }; + if (node["type"] === "object") + return { + name, + types: typesOf(node["additionalProperties"] as Node), + required, + many: false, + map: true, + }; + return { name, types: typesOf(node), required, many: false, map: false }; +} + +/** The descriptor of the Project Intent metamodel, as the wire schemas declare it. */ +export function descriptor(): Descriptor { + const schema = z.toJSONSchema(projectIntent, { io: "input" }) as Node; + const definitions = schema["$defs"] as Record; + const classes: DescriptorClass[] = []; + const vocabularies: DescriptorVocabulary[] = []; + + for (const name of Object.keys(definitions).sort()) { + const definition = definitions[name] as Node; + const literals = definition["enum"]; + const constant = definition["const"]; + if (Array.isArray(literals)) { + vocabularies.push({ name, literals: literals.map(String) }); + } else if (typeof constant === "string") { + classes.push({ name, features: [], scalar: constant }); + } else { + const properties = definition["properties"] as Record; + const required = definition["required"] as string[] | undefined; + classes.push({ + name, + features: Object.keys(properties) + .sort() + .map((key) => + feature( + key, + properties[key] as Node, + required?.includes(key) === true, + ), + ), + }); + } + } + + return { classes, vocabularies }; +} diff --git a/src/wire/project-intent/json-schema.ts b/src/wire/project-intent/json-schema.ts new file mode 100644 index 0000000..cabacac --- /dev/null +++ b/src/wire/project-intent/json-schema.ts @@ -0,0 +1,22 @@ +// The JSON Schema an editor completes a project file against, generated from +// the input variant of the wire schema: a field with a platform default is +// optional in the file a human writes and required only after validation +// (docs/architecture.md#the-wire-boundary). The generated file is committed and +// checked for a diff (docs/adr/architecture/0119). +import { z } from "zod"; +import { projectIntent } from "./schema.ts"; + +/** The committed schema's path, relative to the repository root. */ +export const JSON_SCHEMA_PATH = "spec/v1/schemas/project-intent.schema.json"; + +/** The draft the generated schema declares itself to be. */ +export const DRAFT = "https://json-schema.org/draft/2020-12/schema"; + +/** The JSON Schema of an authored Project Intent document, as text. */ +export function projectIntentJsonSchema(): string { + // Stryker disable next-line all: no field has a platform default yet, so the + // input and output variants are the same document and no test can tell them + // apart. The option stays, because the first default makes them differ. + const schema = z.toJSONSchema(projectIntent, { io: "input" }); + return `${JSON.stringify({ $schema: DRAFT, ...schema }, undefined, 2)}\n`; +} diff --git a/src/wire/project-intent/map.ts b/src/wire/project-intent/map.ts index b087120..487cb41 100644 --- a/src/wire/project-intent/map.ts +++ b/src/wire/project-intent/map.ts @@ -1,6 +1,8 @@ import type { Diagnostic, Result } from "../../domain/diagnostic.ts"; import type { Application, + Dependency, + Placement, Process, Project, } from "../../domain/project-intent/model.ts"; @@ -8,24 +10,56 @@ import { projectIntent, type ProjectIntentDocument } from "./schema.ts"; type WireApplication = ProjectIntentDocument["applications"][number]; type WireProcess = WireApplication["processes"][number]; +type WirePlacement = WireProcess["placement"]; +type WireDependency = NonNullable[number]; const escape = (segment: PropertyKey): string => String(segment).replaceAll("~", "~0").replaceAll("/", "~1"); +/** A dependency is required unless the document says it is not. */ +function toDependency(edge: WireDependency): Dependency { + const { required, ...rest } = edge; + return { ...rest, required: required ?? true }; +} + +function toPlacement(placement: WirePlacement): Placement { + const { arch, capabilities, ...rest } = placement; + return { ...rest, arch: arch ?? [], capabilities: capabilities ?? [] }; +} + function toProcess(process: WireProcess): Process { - const { provides, probes, ...rest } = process; + const { + provides, + probes, + writablePaths, + sidecars, + dependsOn, + assets, + volumes, + secrets, + placement, + ...rest + } = process; return { ...rest, provides: new Map(Object.entries(provides ?? {})), + placement: toPlacement(placement), + writablePaths: writablePaths ?? [], + sidecars: sidecars ?? [], + dependencies: (dependsOn ?? []).map(toDependency), + assets: assets ?? [], probes: probes ?? {}, + volumes: volumes ?? [], + grants: secrets ?? [], }; } function toApplication(application: WireApplication): Application { - const { exposure, processes, ...rest } = application; + const { exposure, processes, secrets, ...rest } = application; return { ...rest, exposures: exposure ?? [], + grants: secrets ?? [], processes: processes.map(toProcess), }; } diff --git a/src/wire/project-intent/schema.ts b/src/wire/project-intent/schema.ts index 64cc2bb..d773c5a 100644 --- a/src/wire/project-intent/schema.ts +++ b/src/wire/project-intent/schema.ts @@ -1,71 +1,228 @@ -// The authoring shape of a Project Intent document, schemaVersion 1. It covers -// the fields the minimal example uses; the rest of chapter 10 lands with #84. +// The authoring shape of a Project Intent document, schemaVersion 1, as +// spec/v1/10-project-intent.md defines it. Every class carries the id the +// descriptor and the generated JSON Schema name it by, and a union in the +// language is a union here. import { z } from "zod"; import { + ACCESS_TIERS, + DATABASE_ENGINES, + TRANSIT_ENGINES, ALERT_CLASSES, + ARCHITECTURES, AUDIENCES, CONTENT_POLICIES, CUTOVERS, + DELIVERIES, + DURABILITY_CLASSES, + ENGINES, LIFECYCLES, MATCHES, + MEDIA, RUNTIMES, + TOLERANCES, + TRANSIT_OPERATIONS, } from "../../domain/project-intent/vocabularies.ts"; +// Every closed vocabulary is named, because the descriptor and the generated +// JSON Schema name it. +const accessTier = z.enum(ACCESS_TIERS).meta({ id: "AccessTier" }); +const databaseEngine = z.enum(DATABASE_ENGINES).meta({ id: "DatabaseEngine" }); +const transitEngine = z.enum(TRANSIT_ENGINES).meta({ id: "TransitEngine" }); +const alertClass = z.enum(ALERT_CLASSES).meta({ id: "AlertClass" }); +const arch = z.enum(ARCHITECTURES).meta({ id: "Arch" }); +const audience = z.enum(AUDIENCES).meta({ id: "Audience" }); +const contentPolicy = z.enum(CONTENT_POLICIES).meta({ id: "ContentPolicy" }); +const cutover = z.enum(CUTOVERS).meta({ id: "Cutover" }); +const delivery = z.enum(DELIVERIES).meta({ id: "Delivery" }); +const durabilityClass = z + .enum(DURABILITY_CLASSES) + .meta({ id: "DurabilityClass" }); +const engine = z.enum(ENGINES).meta({ id: "Engine" }); +const lifecycle = z.enum(LIFECYCLES).meta({ id: "Lifecycle" }); +const match = z.enum(MATCHES).meta({ id: "Match" }); +const media = z.enum(MEDIA).meta({ id: "Media" }); +const runtime = z.enum(RUNTIMES).meta({ id: "Runtime" }); +const tolerance = z.enum(TOLERANCES).meta({ id: "Tolerance" }); +const transitOp = z.enum(TRANSIT_OPERATIONS).meta({ id: "TransitOp" }); + const text = z.string().min(1); const port = z.int().min(1).max(65535); -const surfaceRef = { process: text, surface: text }; - -const probe = z.strictObject({ path: text, port }); - -const process = z.strictObject({ - name: text, - lifecycle: z.enum(LIFECYCLES), - image: text, - runtime: z.enum(RUNTIMES), - provides: z.record(text, port).exactOptional(), - placement: z.strictObject({ memory: text, cpu: text }), - probes: z - .strictObject({ - readiness: probe.exactOptional(), - liveness: probe.exactOptional(), - }) - .exactOptional(), - startupBudget: text.exactOptional(), - cutover: z.enum(CUTOVERS), -}); - -const exposure = z.strictObject({ - name: text, - host: text, - audience: z.enum(AUDIENCES), - contentPolicy: z.enum(CONTENT_POLICIES), - routes: z - .array( - z.strictObject({ path: text, match: z.enum(MATCHES), ...surfaceRef }), - ) - .min(1), -}); - -const application = z.strictObject({ - id: text, - observability: z - .strictObject({ - alertClass: z.enum(ALERT_CLASSES), - scrape: z.strictObject({ ...surfaceRef, path: text }), - }) - .exactOptional(), - exposure: z.array(exposure).exactOptional(), - processes: z.array(process).min(1), -}); - -export const projectIntent = z.strictObject({ - apiVersion: z.literal("intent.jorisjonkers.dev/v1"), - kind: z.literal("Project"), - schemaVersion: z.string().regex(/^\d+\.\d+\.\d+$/), - project: text, - owner: text, - applications: z.array(application).min(1), -}); +const httpProbe = z + .strictObject({ path: text, port }) + .meta({ id: "HttpProbe" }); +const tcpProbe = z.strictObject({ tcp: port }).meta({ id: "TcpProbe" }); +const probe = z.union([httpProbe, tcpProbe]); + +const probes = z + .strictObject({ + readiness: probe.exactOptional(), + liveness: probe.exactOptional(), + }) + .meta({ id: "Probes" }); +const noProbes = z.literal("none").meta({ id: "NoProbes" }); + +const rotation = z + .strictObject({ + tolerates: tolerance, + maxAge: text.exactOptional(), + }) + .meta({ id: "Rotation" }); + +const grantDelivery = { + delivery: delivery, + mountAt: text.exactOptional(), + fileMode: text.exactOptional(), + rotation: rotation.exactOptional(), +}; + +const kvGrant = z + .strictObject({ + path: text, + keys: z.array(text).min(1), + access: accessTier, + ...grantDelivery, + }) + .meta({ id: "KvGrant" }); + +const databaseGrant = z + .strictObject({ + engine: databaseEngine, + role: text, + ...grantDelivery, + }) + .meta({ id: "DatabaseGrant" }); + +const transitGrant = z + .strictObject({ + engine: transitEngine, + key: text, + operations: z.array(transitOp).min(1), + ...grantDelivery, + }) + .meta({ id: "TransitGrant" }); + +const grant = z.union([kvGrant, databaseGrant, transitGrant]); + +const diskRequest = z + .strictObject({ media: z.array(media).min(1) }) + .meta({ id: "DiskRequest" }); + +const gpuRequest = z + .strictObject({ class: text, memory: text }) + .meta({ id: "GpuRequest" }); + +const placement = z + .strictObject({ + memory: text, + cpu: text, + arch: z.array(arch).min(1).exactOptional(), + site: text.exactOptional(), + disk: diskRequest.exactOptional(), + gpu: gpuRequest.exactOptional(), + capabilities: z.array(text).min(1).exactOptional(), + }) + .meta({ id: "Placement" }); + +const sidecar = z + .strictObject({ name: text, image: text, memory: text, cpu: text }) + .meta({ id: "Sidecar" }); + +const dependencyEdge = z + .strictObject({ + application: text, + surface: text, + required: z.boolean().exactOptional(), + }) + .meta({ id: "DependencyEdge" }); + +const asset = z + .strictObject({ from: text, mountAt: text }) + .meta({ id: "Asset" }); + +const volume = z + .strictObject({ + claim: text, + mountAt: text, + size: text.exactOptional(), + durability: durabilityClass, + }) + .meta({ id: "Volume" }); + +const capacity = z + .strictObject({ count: z.int().min(2), reason: text }) + .meta({ id: "Capacity" }); + +const process = z + .strictObject({ + name: text, + lifecycle: lifecycle, + image: text, + runtime: runtime, + engine: engine.exactOptional(), + provides: z.record(text, port).exactOptional(), + placement, + writablePaths: z.array(text).min(1).exactOptional(), + sidecars: z.array(sidecar).min(1).exactOptional(), + dependsOn: z.array(dependencyEdge).min(1).exactOptional(), + assets: z.array(asset).min(1).exactOptional(), + probes: z.union([noProbes, probes]).exactOptional(), + volumes: z.array(volume).min(1).exactOptional(), + replicas: capacity.exactOptional(), + secrets: z.array(grant).min(1).exactOptional(), + startupBudget: text.exactOptional(), + cutover: cutover, + }) + .meta({ id: "Process" }); + +const route = z + .strictObject({ + path: text, + match: match, + process: text, + surface: text, + audience: audience.exactOptional(), + redirectTo: text.exactOptional(), + }) + .meta({ id: "Route" }); + +const exposure = z + .strictObject({ + name: text, + host: text, + audience: audience, + contentPolicy: contentPolicy.exactOptional(), + routes: z.array(route).min(1), + }) + .meta({ id: "Exposure" }); + +const scrape = z + .strictObject({ process: text, surface: text, path: text }) + .meta({ id: "Scrape" }); + +const observability = z + .strictObject({ alertClass: alertClass, scrape }) + .meta({ id: "Observability" }); + +const application = z + .strictObject({ + id: text, + observability: observability.exactOptional(), + exposure: z.array(exposure).min(1).exactOptional(), + secrets: z.array(grant).min(1).exactOptional(), + processes: z.array(process).min(1), + }) + .meta({ id: "Application" }); + +export const projectIntent = z + .strictObject({ + apiVersion: z.literal("intent.jorisjonkers.dev/v1"), + kind: z.literal("Project"), + schemaVersion: z.string().regex(/^\d+\.\d+\.\d+$/), + project: text, + owner: text, + applications: z.array(application).min(1), + }) + .meta({ id: "Project" }); export type ProjectIntentDocument = z.output; diff --git a/test/model/descriptor.test.ts b/test/model/descriptor.test.ts new file mode 100644 index 0000000..a00d5bd --- /dev/null +++ b/test/model/descriptor.test.ts @@ -0,0 +1,126 @@ +// REQ-023 (docs/requirements.md): the metamodel's structure is committed, and +// both implementations are held to it. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { typesOf } from "../../src/wire/project-intent/descriptor.ts"; +import { DRAFT } from "../../src/wire/project-intent/json-schema.ts"; +import { + JSON_SCHEMA_PATH, + canonicalJson, + descriptor, + projectIntentJsonSchema, +} from "../../src/index.ts"; + +const REPOSITORY = join(import.meta.dirname, "..", ".."); +const read = (path: string): string => + readFileSync(join(REPOSITORY, path), "utf8"); + +describe("the descriptor", () => { + it("equals the committed file, so a schema change without one fails", () => { + expect(canonicalJson(descriptor())).toBe( + read("spec/v1/examples/expected/descriptor.json"), + ); + }); + + it("names every class and vocabulary once, sorted", () => { + const { classes, vocabularies } = descriptor(); + const names = classes.map(({ name }) => name); + const literals = vocabularies.map(({ name }) => name); + + expect(names).toStrictEqual([...names].sort()); + expect(literals).toStrictEqual([...literals].sort()); + expect(new Set([...names, ...literals]).size).toBe( + names.length + literals.length, + ); + }); + + it("carries a union as the classes it stands for, and a map as a map", () => { + const { classes } = descriptor(); + const process = classes.find(({ name }) => name === "Process"); + const feature = (name: string) => + process?.features.find((candidate) => candidate.name === name); + + expect(feature("probes")?.types).toStrictEqual(["NoProbes", "Probes"]); + expect(feature("provides")).toStrictEqual({ + name: "provides", + types: ["int"], + required: false, + many: false, + map: true, + }); + expect(feature("secrets")).toStrictEqual({ + name: "secrets", + types: ["DatabaseGrant", "KvGrant", "TransitGrant"], + required: false, + many: true, + map: false, + }); + }); + + it("carries a class written as one word as that word", () => { + expect( + descriptor().classes.find(({ name }) => name === "NoProbes"), + ).toStrictEqual({ + name: "NoProbes", + features: [], + scalar: "none", + }); + }); + + it("lists every closed vocabulary the chapter names", () => { + expect( + descriptor().vocabularies.find(({ name }) => name === "AlertClass"), + ).toStrictEqual({ + name: "AlertClass", + literals: ["business-hours", "urgent", "page"], + }); + expect(descriptor().vocabularies).toHaveLength(17); + }); +}); + +describe("a property's types", () => { + it("reads a reference, a union and a primitive", () => { + expect(typesOf({ $ref: "#/$defs/Exposure" })).toStrictEqual(["Exposure"]); + expect( + typesOf({ anyOf: [{ $ref: "#/$defs/B" }, { $ref: "#/$defs/A" }] }), + ).toStrictEqual(["A", "B"]); + expect(typesOf({ type: "integer" })).toStrictEqual(["int"]); + expect(typesOf({ type: "boolean" })).toStrictEqual(["boolean"]); + }); + + it("refuses a type the descriptor has no name for", () => { + expect(() => typesOf({ type: "null" })).toThrow( + "null is not a type the descriptor names", + ); + }); +}); + +describe("the generated JSON Schema", () => { + it("regenerates without a diff", () => { + expect(projectIntentJsonSchema()).toBe(read(JSON_SCHEMA_PATH)); + }); + + it("declares the draft it is written against, and is committed at that path", () => { + expect(JSON_SCHEMA_PATH).toBe("spec/v1/schemas/project-intent.schema.json"); + expect(projectIntentJsonSchema().split("\n").slice(0, 2)).toStrictEqual([ + "{", + ` "$schema": "${DRAFT}",`, + ]); + }); + + it("is the input variant, so a document validates against what a human writes", () => { + const schema = JSON.parse(projectIntentJsonSchema()) as { + $defs: Record; + }; + + expect(schema.$defs["Process"]?.required).toStrictEqual([ + "name", + "lifecycle", + "image", + "runtime", + "placement", + "cutover", + ]); + }); +}); diff --git a/test/model/project-intent.test.ts b/test/model/project-intent.test.ts index a4de65d..585670a 100644 --- a/test/model/project-intent.test.ts +++ b/test/model/project-intent.test.ts @@ -1,6 +1,6 @@ // REQ-021 (docs/requirements.md): an authored Project Intent file parses to its // committed intent oracle, and YAML or fields outside the language are refused. -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { canonicalJson, parseProjectIntent } from "../../src/index.ts"; @@ -50,7 +50,38 @@ function refused(text: string) { })); } +const cases = readdirSync(EXAMPLES, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => readdirSync(join(EXAMPLES, name)).includes("expected")) + .sort(); + +const projectFile = (directory: string): string => { + const file = readdirSync(join(EXAMPLES, directory)).find((name) => + name.endsWith(".project.yml"), + ); + return readFileSync(join(EXAMPLES, directory, file ?? ""), "utf8"); +}; + describe("parseProjectIntent", () => { + it.each(cases)( + "parses %s to its committed intent oracle, byte for byte", + (directory) => { + const result = parseProjectIntent(projectFile(directory)); + + expect(result.ok && canonicalJson(result.value.document)).toBe( + readFileSync( + join(EXAMPLES, directory, "expected", "intent.json"), + "utf8", + ), + ); + }, + ); + + it("covers every worked example", () => { + expect(cases).toStrictEqual(["auth", "data", "knowledge", "minimal"]); + }); + it("parses the minimal case to its committed intent oracle, byte for byte", () => { const result = parseProjectIntent(MINIMAL); @@ -76,6 +107,7 @@ describe("parseProjectIntent", () => { alertClass: "business-hours", scrape: { process: "notes-api", surface: "http", path: "/metrics" }, }, + grants: [], exposures: [ { name: "public", @@ -99,7 +131,18 @@ describe("parseProjectIntent", () => { image: "notes-api", runtime: "node", provides: new Map([["http", 8080]]), - placement: { memory: "256Mi", cpu: "50m" }, + placement: { + memory: "256Mi", + cpu: "50m", + arch: [], + capabilities: [], + }, + writablePaths: [], + sidecars: [], + dependencies: [], + assets: [], + volumes: [], + grants: [], probes: { readiness: { path: "/healthz/ready", port: 8080 }, liveness: { path: "/healthz/live", port: 8080 }, @@ -122,6 +165,7 @@ describe("parseProjectIntent", () => { { id: "batch", exposures: [], + grants: [], processes: [ { name: "worker", @@ -129,8 +173,19 @@ describe("parseProjectIntent", () => { image: "worker", runtime: "none", provides: new Map(), - placement: { memory: "64Mi", cpu: "10m" }, + placement: { + memory: "64Mi", + cpu: "10m", + arch: [], + capabilities: [], + }, + writablePaths: [], + sidecars: [], + dependencies: [], + assets: [], probes: {}, + volumes: [], + grants: [], cutover: "recreate", }, ], @@ -230,6 +285,35 @@ describe("parseProjectIntent", () => { ]); }); + it.each([ + ["required: false", false], + ["required: true", true], + ])("reads a dependency written %s as such", (line, required) => { + const text = withApplications( + ` - id: batch\n processes:\n${PROCESS} dependsOn:\n - application: other\n surface: http\n ${line}\n`, + ); + const result = parseProjectIntent(text); + + expect( + result.ok && + result.value.project.applications[0]?.processes[0]?.dependencies, + ).toStrictEqual([{ application: "other", surface: "http", required }]); + }); + + it("reads a dependency with no required field as required", () => { + const text = withApplications( + ` - id: batch\n processes:\n${PROCESS} dependsOn:\n - { application: other, surface: http }\n`, + ); + const result = parseProjectIntent(text); + + expect( + result.ok && + result.value.project.applications[0]?.processes[0]?.dependencies, + ).toStrictEqual([ + { application: "other", surface: "http", required: true }, + ]); + }); + it("gives every diagnostic a hint", () => { const diagnostics = ["", `${HEADER}applications: []\n`].flatMap((text) => { const result = parseProjectIntent(text); diff --git a/vitest.config.ts b/vitest.config.ts index a1a1e08..f9aab16 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,15 +23,15 @@ 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 parser under src/ at 100%: - // statements 832/845, branches 456/488, functions 100%, lines 776/789. - // 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. + // 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 + // bottom of each gate and the branches for a tool that cannot be started + // at all. thresholds: { - statements: 98.46, - branches: 93.44, + statements: 98.6, + branches: 93.8, functions: 100, - lines: 98.35, + lines: 98.51, }, }, },