diff --git a/README.md b/README.md index 58b15c2..f2c6df4 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,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 -99.03%, branches 96.09%, functions 100%, lines 98.95%. +99.06%, branches 96.17%, functions 100%, lines 98.97%. ## Conventions diff --git a/docs/architecture.md b/docs/architecture.md index 86c1b41..8762af2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -290,8 +290,11 @@ by either source format: 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 + whether it is `required`, whether it holds `many` values, whether it is a + `map` keyed by string, and whether it is a `reference`: a name the document + writes that links to a model element, whose one type is the element's class. + A map also names what one of its entries is, as `entry`, so a reference can + point at a map's entries. 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 diff --git a/docs/requirements.md b/docs/requirements.md index de824cb..3e1a624 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 **28** rows. The compiler's behaviours join it as they land. +This ledger holds **29** rows. The compiler's behaviours join it as they land. | id | a contributor or a consumer can rely on | proved by | |---|---|---| @@ -51,3 +51,4 @@ This ledger holds **28** rows. The compiler's behaviours join it as they land. | REQ-026 | A pull request's shape-and-coverage comment is updated in place on a second push, rather than posted again | [test/pr-report.test.ts](../test/pr-report.test.ts) | | REQ-027 | A pull request's release candidate version sorts above the current release and below any version release-please could choose next | [test/rc-version.test.ts](../test/rc-version.test.ts) | | REQ-028 | A citation to a decision record marked superseded is checked for its successor in the same sentence, a term the model retired is checked outside a quotation, and a stated count is checked against the real collection it claims to count | [test/meaning-contract.test.ts](../test/meaning-contract.test.ts) | +| REQ-029 | A route's and a scrape's `process` and `surface` link to the Process and surface they name inside their Application, and a name that links to nothing is refused with `E_UNKNOWN_PROCESS` or `E_UNKNOWN_SURFACE` at the pointer of the route or scrape | [test/model/links.test.ts](../test/model/links.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 23c3307..1f9d42c 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 @@ -7,7 +7,9 @@ import org.eclipse.emf.common.util.Enumerator; import org.eclipse.emf.ecore.EAnnotation; import org.eclipse.emf.ecore.EObject; +import org.eclipse.emf.ecore.EReference; import org.eclipse.emf.ecore.EStructuralFeature; +import org.eclipse.emf.ecore.util.EcoreUtil; /** * Reads a parsed model as the JSON value the parity contract compares: every feature named as the @@ -42,6 +44,10 @@ private static boolean isSet(EObject owner, EStructuralFeature feature) { private static Object value(EObject owner, EStructuralFeature feature) { Object value = owner.eGet(feature); + if (feature instanceof EReference reference && !reference.isContainment()) { + // A reference is written as the name that linked it: the identifier of what it points at. + return EcoreUtil.getID((EObject) value); + } if (feature.isMany()) { return many(feature, (List) value); } diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java index fb6a584..47776df 100644 --- a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java +++ b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java @@ -9,6 +9,8 @@ import org.eclipse.emf.ecore.EObject; import org.eclipse.emf.ecore.EPackage; import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.xtext.EcoreUtil2; +import org.eclipse.xtext.linking.impl.XtextLinkingDiagnostic; import org.eclipse.xtext.resource.XtextResourceSet; /** @@ -31,9 +33,19 @@ public static Parsed intent(Path path) { .getInstance(XtextResourceSet.class); Resource resource = resources.getResource(URI.createFileURI(path.toAbsolutePath().toString()), true); + // Linking is lazy: every reference is resolved before the errors are read, so a name that links to + // nothing is among them. + EcoreUtil2.resolveAll(resource); List refusals = new ArrayList<>(); + List unlinked = new ArrayList<>(); for (Resource.Diagnostic error : resource.getErrors()) { - refusals.add(new Diagnostic(Diagnostic.SCHEMA, "", "line " + error.getLine() + ": " + error.getMessage())); + if (error instanceof XtextLinkingDiagnostic linking) { + EObject owner = resource.getEObject(linking.getUriToProblem().fragment()); + unlinked.add(new Diagnostic(linking.getCode(), Pointer.of(owner), linking.getMessage())); + } else { + refusals.add( + new Diagnostic(Diagnostic.SCHEMA, "", "line " + error.getLine() + ": " + error.getMessage())); + } } if (resource.getContents().isEmpty()) { refusals.add(new Diagnostic(Diagnostic.SCHEMA, "", path.getFileName() + " holds no document")); @@ -42,8 +54,9 @@ public static Parsed intent(Path path) { return Parsed.refused(refusals); } EObject document = resource.getContents().get(0); - List broken = - Constraints.check(document, Constraints.beside(ProjectIntentPackage.class, CONSTRAINTS)); + List broken = new ArrayList<>( + Constraints.check(document, Constraints.beside(ProjectIntentPackage.class, CONSTRAINTS))); + broken.addAll(unlinked); return broken.isEmpty() ? Parsed.of(IntentJson.of(document)) : Parsed.refused(broken); } } 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 7f43a47..2e3db18 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 @@ -5,10 +5,12 @@ import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.HttpProbe; import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Lifecycle; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Match; import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Placement; import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Probes; import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Process; import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentFactory; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Route; import java.util.Map; import org.junit.jupiter.api.Test; @@ -66,6 +68,20 @@ void anOptionalFeatureTheDocumentSetIsWritten() { entry("probes", Map.of("readiness", Map.of("path", "/healthz/ready", "port", 8080)))); } + @Test + void aReferenceIsWrittenAsTheNameItLinked() { + Process process = process(); + process.getProvides().put("http", 8080); + Route route = MODEL.createRoute(); + route.setPath("/"); + route.setMatch(Match.PREFIX); + route.setProcess(process); + route.setSurface(process.getProvides().get(0)); + + assertThat(IntentJson.of(route)) + .contains(entry("process", "notes-api"), entry("surface", "http"), entry("match", "prefix")); + } + @Test void aMapEntryIsWrittenAsAnObjectAndAListAsAList() { Process process = process(); diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PipelineTest.java b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PipelineTest.java index bb42b59..9327f8c 100644 --- a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PipelineTest.java +++ b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PipelineTest.java @@ -60,6 +60,28 @@ void yamlOutsideTheGrammarIsRefusedWithADiagnostic(@TempDir Path directory) thro .isNotEmpty(); } + @Test + void aNameThatLinksToNothingIsRefusedAtThePointerOfWhatWroteIt(@TempDir Path directory) throws IOException { + String routed = MINIMAL.replace("applications:\n - id: notes\n", """ + applications: + - id: notes + exposure: + - name: public + host: notes.jorisjonkers.dev + audience: lan + routes: + - { path: /, match: prefix, process: notes-api, surface: https } + """) + .replace(" runtime: node\n", " runtime: node\n provides: { http: 8080 }\n"); + + Parsed parsed = Pipeline.intent(file(directory, routed)); + + assertThat(parsed.diagnostics()) + .extracting(Diagnostic::code, Diagnostic::path) + .containsExactly(org.assertj.core.groups.Tuple.tuple( + "E_UNKNOWN_SURFACE", "/applications/0/exposure/0/routes/0")); + } + @Test void anEmptyDocumentIsRefused(@TempDir Path directory) throws IOException { Parsed parsed = Pipeline.intent(file(directory, "")); diff --git a/emf/docs/architecture.md b/emf/docs/architecture.md index 7f2db5e..b991b9c 100644 --- a/emf/docs/architecture.md +++ b/emf/docs/architecture.md @@ -166,6 +166,14 @@ 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. +A route's and a scrape's `process` and `surface` are cross-references, linked by +a scope provider that offers the Processes of the Application holding them and the +surfaces the linked Process provides. A name that links to nothing becomes the +specification's code, `E_UNKNOWN_PROCESS` or `E_UNKNOWN_SURFACE`, at the pointer +of the route or scrape; a surface whose Process did not link is not reported as +well. A dependency edge's names reach other documents and stay names until the +composed union links them. + Indentation is not the grammar's concern: a token source turns the block structure into the synthetic `BEGIN` and `END` tokens the rules read, and folds a scalar written over several lines into one token. A line diff --git a/emf/docs/witnesses.md b/emf/docs/witnesses.md index eb2b68d..c8303b4 100644 --- a/emf/docs/witnesses.md +++ b/emf/docs/witnesses.md @@ -10,10 +10,11 @@ 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 **3** witnesses. +This list holds **4** witnesses. | id | JUnit test | |---|---| | REQ-021 | `ParityTest#theParsedIntentEqualsTheCommittedOracle` | | REQ-023 | `ParityTest#theMetamodelsStructureEqualsTheCommittedDescriptor` | | REQ-024 | `ParityTest#aRefusedDocumentEqualsItsCommittedDiagnostics` | +| REQ-029 | `LinkingTest#aRouteAndAScrapeLinkToTheVeryProcessAndSurfaceTheirApplicationHolds` | diff --git a/emf/metamodel/model/project-intent.ecore b/emf/metamodel/model/project-intent.ecore index 5eca3d0..62966be 100644 --- a/emf/metamodel/model/project-intent.ecore +++ b/emf/metamodel/model/project-intent.ecore @@ -29,8 +29,8 @@ - - + + @@ -43,18 +43,18 @@ - - + + - + - + @@ -67,8 +67,8 @@ - - + + diff --git a/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java b/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java index 9381449..cec735d 100644 --- a/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java +++ b/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java @@ -103,13 +103,20 @@ private static String scalar(EClass owner) { } private static Map feature(EStructuralFeature feature) { - boolean map = feature instanceof EReference reference && isMapEntry(reference.getEReferenceType()); + boolean map = feature instanceof EReference containment + && containment.isContainment() + && isMapEntry(containment.getEReferenceType()); + boolean linked = feature instanceof EReference reference && !reference.isContainment(); Map json = new LinkedHashMap<>(); json.put("name", feature.getName()); - json.put("types", types(feature, map)); + json.put("types", linked ? List.of(feature.getEType().getName()) : types(feature, map)); json.put("required", feature.isRequired()); json.put("many", feature.isMany() && !map); json.put("map", map); + json.put("reference", linked); + if (map) { + json.put("entry", feature.getEType().getName()); + } return json; } 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 index 1455a6e..9c30b1c 100644 --- 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 @@ -60,10 +60,23 @@ void anAbstractClassIsAUnionRatherThanAClassOfItsOwn() { @Test void aMapEntryIsAMapRatherThanAClassOfItsOwn() { - assertThat(entries("classes").stream().map(entry -> entry.get("name"))).doesNotContain("SurfacePort"); + assertThat(entries("classes").stream().map(entry -> entry.get("name"))).doesNotContain("Surface"); assertThat(feature("Process", "provides")) .isEqualTo(Map.of( - "name", "provides", "types", List.of("int"), "required", false, "many", false, "map", true)); + "name", + "provides", + "types", + List.of("int"), + "required", + false, + "many", + false, + "map", + true, + "reference", + false, + "entry", + "Surface")); } @Test @@ -79,6 +92,8 @@ void aFeatureCarriesItsTypeAndItsMultiplicity() { "many", true, "map", + false, + "reference", false)); assertThat(feature("Process", "startupBudget").get("types")).isEqualTo(List.of("string")); assertThat(feature("Process", "cutover").get("types")).isEqualTo(List.of("Cutover")); @@ -87,6 +102,27 @@ void aFeatureCarriesItsTypeAndItsMultiplicity() { assertThat(feature("Exposure", "contentPolicy").get("required")).isEqualTo(false); } + @Test + void aNameTheModelLinksIsAReferenceToItsTarget() { + assertThat(feature("Route", "process")) + .isEqualTo(Map.of( + "name", + "process", + "types", + List.of("Process"), + "required", + true, + "many", + false, + "map", + false, + "reference", + true)); + assertThat(feature("Scrape", "surface").get("types")).isEqualTo(List.of("Surface")); + assertThat(feature("Scrape", "surface").get("reference")).isEqualTo(true); + assertThat(feature("DependencyEdge", "surface").get("reference")).isEqualTo(false); + } + @Test void aClassWrittenAsOneWordCarriesThatWord() { assertThat(named("classes", "NoProbes")) diff --git a/emf/syntax/META-INF/MANIFEST.MF b/emf/syntax/META-INF/MANIFEST.MF index b20fd93..2f18ef1 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.linking, dev.jorisjonkers.deploykit.emf.syntax.values, dev.jorisjonkers.deploykit.emf.syntax.parser.antlr, dev.jorisjonkers.deploykit.emf.syntax.services 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 1531e09..6bda880 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 @@ -29,8 +29,8 @@ Observability returns Observability: & ('scrape' ':' BEGIN scrape=Scrape END)?); Scrape returns Scrape: - (('process' ':' process=Text) - & ('surface' ':' surface=Text) + (('process' ':' process=[Process|Text]) + & ('surface' ':' surface=[Surface|Text]) & ('path' ':' path=Text)); Exposure returns Exposure: @@ -43,8 +43,8 @@ Exposure returns Exposure: Route returns Route: (('path' ':' path=Text) & ('match' ':' match=Match) - & ('process' ':' process=Text) - & ('surface' ':' surface=Text) + & ('process' ':' process=[Process|Text]) + & ('surface' ':' surface=[Surface|Text]) & ('audience' ':' audience=Audience)? & ('redirectTo' ':' redirectTo=Text)?); @@ -54,7 +54,7 @@ Process returns Process: & ('image' ':' image=Text) & ('runtime' ':' runtime=Runtime) & ('engine' ':' engine=Engine)? - & ('provides' ':' BEGIN provides+=SurfacePort+ END)? + & ('provides' ':' BEGIN provides+=Surface+ END)? & ('placement' ':' BEGIN placement=Placement END) & ('writablePaths' ':' BEGIN (DASH BEGIN writablePaths+=Text END | writablePaths+=Text)+ END)? & ('sidecars' ':' BEGIN (DASH BEGIN sidecars+=Sidecar END)+ END)? @@ -67,7 +67,7 @@ Process returns Process: & ('startupBudget' ':' startupBudget=Text)? & ('cutover' ':' cutover=Cutover)); -SurfacePort returns SurfacePort: +Surface returns Surface: key=Text ':' value=Port; Placement returns Placement: 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 8488255..512c191 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,10 +1,14 @@ /* - * generated by Xtext, then owned here: the value converters are this language's. + * generated by Xtext, then owned here: the value converters and the linking are this language's. */ package dev.jorisjonkers.deploykit.emf.syntax; +import dev.jorisjonkers.deploykit.emf.syntax.linking.ProjectIntentScopes; +import dev.jorisjonkers.deploykit.emf.syntax.linking.UnlinkedNames; import dev.jorisjonkers.deploykit.emf.syntax.values.ProjectIntentValueConverters; import org.eclipse.xtext.conversion.IValueConverterService; +import org.eclipse.xtext.linking.ILinkingDiagnosticMessageProvider; +import org.eclipse.xtext.scoping.IScopeProvider; /** Registers the components this language runs with, outside the Eclipse extension registry. */ public class ProjectIntentRuntimeModule extends AbstractProjectIntentRuntimeModule { @@ -14,4 +18,15 @@ public class ProjectIntentRuntimeModule extends AbstractProjectIntentRuntimeModu public Class bindIValueConverterService() { return ProjectIntentValueConverters.class; } + + /** A route's and a scrape's names link inside their Application: see {@link ProjectIntentScopes}. */ + @Override + public Class bindIScopeProvider() { + return ProjectIntentScopes.class; + } + + /** A name that links to nothing carries its specification code: see {@link UnlinkedNames}. */ + public Class bindILinkingDiagnosticMessageProvider() { + return UnlinkedNames.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 0eeb948..c31875b 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 @@ -82,9 +82,12 @@ private void significant(Token token) { afterDash = false; flowDepth++; pending.add(marker(types.begin(), token)); + // The brace stays in the stream as whitespace, so every character still has its token. + pending.add(hidden(token)); } else if (types.closesFlow(token.getType())) { flowDepth--; pending.add(marker(types.end(), token)); + pending.add(hidden(token)); } else { if (afterDash) { afterDash = false; diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/ProjectIntentScopes.java b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/ProjectIntentScopes.java new file mode 100644 index 0000000..9730101 --- /dev/null +++ b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/ProjectIntentScopes.java @@ -0,0 +1,38 @@ +package dev.jorisjonkers.deploykit.emf.syntax.linking; + +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Application; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentPackage; +import java.util.List; +import java.util.Map; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.emf.ecore.EReference; +import org.eclipse.xtext.EcoreUtil2; +import org.eclipse.xtext.naming.QualifiedName; +import org.eclipse.xtext.scoping.IScope; +import org.eclipse.xtext.scoping.IScopeProvider; +import org.eclipse.xtext.scoping.Scopes; + +/** + * What a route's or a scrape's names can link to (spec/v1/10-project-intent.md#what-is-checked): a + * Process of the Application that holds it, and a surface that Process provides. Nothing outside the + * Application is in scope; a dependency edge's names reach other documents and are not linked here. + */ +public class ProjectIntentScopes implements IScopeProvider { + + @Override + public IScope getScope(EObject context, EReference reference) { + if (reference.getEReferenceType() == ProjectIntentPackage.Literals.PROCESS) { + Application application = EcoreUtil2.getContainerOfType(context, Application.class); + return Scopes.scopeFor(application.getProcesses()); + } + EObject process = (EObject) context.eGet(context.eClass().getEStructuralFeature("process")); + if (process.eIsProxy()) { + return IScope.NULLSCOPE; + } + List provides = (List) process.eGet(ProjectIntentPackage.Literals.PROCESS__PROVIDES); + return Scopes.scopeFor( + provides.stream().map(EObject.class::cast).toList(), + surface -> QualifiedName.create(String.valueOf(((Map.Entry) surface).getKey())), + IScope.NULLSCOPE); + } +} diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/UnlinkedNames.java b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/UnlinkedNames.java new file mode 100644 index 0000000..7234399 --- /dev/null +++ b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/UnlinkedNames.java @@ -0,0 +1,33 @@ +package dev.jorisjonkers.deploykit.emf.syntax.linking; + +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentPackage; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.xtext.diagnostics.DiagnosticMessage; +import org.eclipse.xtext.diagnostics.Severity; +import org.eclipse.xtext.linking.impl.LinkingDiagnosticMessageProvider; + +/** + * A name that links to nothing, as the code the specification gives it: `E_UNKNOWN_PROCESS` for a + * Process, `E_UNKNOWN_SURFACE` for a surface. A surface whose Process did not link is not reported + * as well: there is no Process to look it up in, and the Process's own refusal already says so. + */ +public class UnlinkedNames extends LinkingDiagnosticMessageProvider { + + public static final String UNKNOWN_PROCESS = "E_UNKNOWN_PROCESS"; + public static final String UNKNOWN_SURFACE = "E_UNKNOWN_SURFACE"; + + @Override + public DiagnosticMessage getUnresolvedProxyMessage(ILinkingDiagnosticContext context) { + String name = context.getLinkText(); + if (context.getReference().getEReferenceType() == ProjectIntentPackage.Literals.PROCESS) { + return new DiagnosticMessage( + "no Process of this Application is named " + name, Severity.ERROR, UNKNOWN_PROCESS); + } + EObject owner = context.getContext(); + EObject process = (EObject) owner.eGet(owner.eClass().getEStructuralFeature("process")); + if (process.eIsProxy()) { + return null; + } + return new DiagnosticMessage("the Process provides no surface named " + name, Severity.ERROR, UNKNOWN_SURFACE); + } +} 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 b85ddf0..1707019 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 @@ -175,7 +175,7 @@ void aFoldedScalarSitsWhereItsMarkerSatAndCoversItsWholeBlock() { @Test void everyCharacterOfTheFileIsCoveredByExactlyOneToken() { - String source = "a: >-\n one\n two\n\nb:\n c: 2\n"; + String source = "a: >-\n one\n two\n\nb:\n c: 2\nd: { e: 1, f: [x, y] }\n"; int next = 0; for (Token token : tokens(source)) { diff --git a/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/linking/LinkingTest.java b/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/linking/LinkingTest.java new file mode 100644 index 0000000..feda97e --- /dev/null +++ b/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/linking/LinkingTest.java @@ -0,0 +1,120 @@ +package dev.jorisjonkers.deploykit.emf.syntax.linking; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Application; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Project; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentPackage; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Route; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Scrape; +import dev.jorisjonkers.deploykit.emf.syntax.ProjectIntentStandaloneSetup; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.eclipse.emf.common.util.URI; +import org.eclipse.emf.ecore.EPackage; +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.xtext.EcoreUtil2; +import org.eclipse.xtext.linking.impl.XtextLinkingDiagnostic; +import org.eclipse.xtext.resource.XtextResourceSet; +import org.junit.jupiter.api.Test; + +/** What a route's and a scrape's names link to, and what a name that links to nothing becomes. */ +class LinkingTest { + + private static final String DOCUMENT = """ + apiVersion: intent.jorisjonkers.dev/v1 + kind: Project + schemaVersion: 1.0.0 + project: links + owner: joris + applications: + - id: links + observability: + alertClass: urgent + scrape: { process: SCRAPE_PROCESS, surface: SCRAPE_SURFACE, path: /metrics } + exposure: + - name: public + host: links.jorisjonkers.dev + audience: lan + routes: + - { path: /, match: prefix, process: ROUTE_PROCESS, surface: ROUTE_SURFACE } + processes: + - name: links-api + lifecycle: application + image: links-api + runtime: node + provides: { http: 8080, metrics: 9090 } + placement: { memory: 64Mi, cpu: 10m } + cutover: rolling + - id: elsewhere + processes: + - name: elsewhere-api + lifecycle: application + image: elsewhere-api + runtime: node + provides: { http: 8080 } + placement: { memory: 64Mi, cpu: 10m } + cutover: rolling + """; + + private static Resource parse(String routeProcess, String routeSurface, String scrapeProcess, String scrapeSurface) + throws IOException { + EPackage.Registry.INSTANCE.putIfAbsent(ProjectIntentPackage.eNS_URI, ProjectIntentPackage.eINSTANCE); + XtextResourceSet resources = new ProjectIntentStandaloneSetup() + .createInjectorAndDoEMFRegistration() + .getInstance(XtextResourceSet.class); + Resource resource = resources.createResource(URI.createURI("memory:/links.yml")); + String text = DOCUMENT.replace("ROUTE_PROCESS", routeProcess) + .replace("ROUTE_SURFACE", routeSurface) + .replace("SCRAPE_PROCESS", scrapeProcess) + .replace("SCRAPE_SURFACE", scrapeSurface); + resource.load(new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)), Map.of()); + EcoreUtil2.resolveAll(resource); + return resource; + } + + private static List codes(Resource resource) { + return resource.getErrors().stream() + .map(error -> ((XtextLinkingDiagnostic) error).getCode() + " " + error.getMessage()) + .toList(); + } + + private static Application links(Resource resource) { + return ((Project) resource.getContents().get(0)).getApplications().get(0); + } + + @Test + void aRouteAndAScrapeLinkToTheVeryProcessAndSurfaceTheirApplicationHolds() throws IOException { + Resource resource = parse("links-api", "http", "links-api", "metrics"); + Application application = links(resource); + Route route = application.getExposure().get(0).getRoutes().get(0); + Scrape scrape = application.getObservability().getScrape(); + + assertThat(resource.getErrors()).isEmpty(); + assertThat(route.getProcess()).isSameAs(application.getProcesses().get(0)); + assertThat(route.getSurface().getKey()).isEqualTo("http"); + assertThat(scrape.getProcess()).isSameAs(application.getProcesses().get(0)); + assertThat(scrape.getSurface().getKey()).isEqualTo("metrics"); + } + + @Test + void aProcessOfAnotherApplicationIsNotInScope() throws IOException { + assertThat(codes(parse("elsewhere-api", "http", "links-api", "metrics"))) + .containsExactly("E_UNKNOWN_PROCESS no Process of this Application is named elsewhere-api"); + } + + @Test + void aSurfaceTheProcessDoesNotProvideIsUnknown() throws IOException { + assertThat(codes(parse("links-api", "https", "links-api", "metrics"))) + .containsExactly("E_UNKNOWN_SURFACE the Process provides no surface named https"); + } + + @Test + void aSurfaceIsNotReportedWhenItsProcessDidNotLink() throws IOException { + assertThat(codes(parse("links-api", "http", "nothing", "nothing"))) + .containsExactly("E_UNKNOWN_PROCESS no Process of this Application is named nothing"); + } +} diff --git a/scripts/diagrams/class-diagram.py b/scripts/diagrams/class-diagram.py index ee53726..4dc3990 100644 --- a/scripts/diagrams/class-diagram.py +++ b/scripts/diagrams/class-diagram.py @@ -57,7 +57,7 @@ rows = [re.sub(r"^\+\s*(\S+)\s+(\S+)$", r"+ \1 \2", r) for r in rows] nodes[name] = {"kind": kind, "rows": rows, "notes": []} -comp, dep = [], [] +comp, dep, assoc = [], [], [] for line in body.split("\n"): m = re.match(r'\s*(\w+)\s+"([^"]+)"\s+\*--\s+"([^"]+)"\s+(\w+)\s*:\s*(.*)', line) if m: @@ -66,6 +66,11 @@ m = re.match(r"\s*(\w+)\s+\.\.>\s+(\w+)\s*:\s*(.*)", line) if m: dep.append((m.group(1), m.group(2), m.group(3).strip())) + continue + # an association: a reference the model resolves, drawn as a solid open arrow + m = re.match(r"\s*(\w+)\s+-->\s+(\w+)\s*:\s*(.*)", line) + if m: + assoc.append((m.group(1), m.group(2), m.group(3).strip())) # The tree. Sibling order puts cross-link partners next to each other: # Surface is Process's last child and Route is Exposure's first, so the two @@ -232,7 +237,9 @@ def tree_edge(eid, style, a, b, label): i += 1 # what is left links two nodes on one layer that sibling order made neighbours +E_ASSOC = E_ENUM.replace("dashed=1;dashPattern=8 4;", "") cross = [(E_COMP, a, b, f"{m} {l}") for a, b, m, l in comp if (a, b) not in tree] +cross += [(E_ASSOC, a, b, l) for a, b, l in assoc] cross += [(E_ENUM, a, b, f"«{l}»") for a, b, l in dep if (a, b) not in tree] def neighbours(a, b): """True when nothing on their row sits between the two boxes.""" @@ -266,8 +273,10 @@ def neighbours(a, b): add_edge(f"x{j}", st, a, b, "", [(cx(a), ly), (ex, ly)]) # the name goes below every lane feeding this box, as its own text, # clear of both arrow heads and of any run - prev_y, prev_l = deep.get(b, (0, "")) - deep[b] = (max(prev_y, ly), prev_l or label) + prev_y, prev_l = deep.get(b, (0, [])) + # every distinct name, left to right in the order the links arrive + named = prev_l + [(cx(a), label)] if label and label not in [l for _, l in prev_l] else prev_l + deep[b] = (max(prev_y, ly), named) else: # the boxes face each other: one straight line, side to side, at a # height that is inside both of them @@ -289,11 +298,12 @@ def neighbours(a, b): LABEL = ("text;html=0;strokeColor=none;fillColor=none;align=center;" "verticalAlign=middle;fontFamily=Helvetica;fontSize=11;fontColor=#6d28d9;") -for t, (ly, label) in deep.items(): - c = ET.SubElement(root, "mxCell", {"id": f"L{ident[t]}", "value": label, +for t, (ly, labels) in deep.items(): + c = ET.SubElement(root, "mxCell", {"id": f"L{ident[t]}", + "value": " · ".join(l for _, l in sorted(labels)), "style": LABEL, "parent": "1", "vertex": "1"}) - ET.SubElement(c, "mxGeometry", {"x": str(int(cx(t) - 110)), "y": str(int(ly + 12)), - "width": "220", "height": "18", "as": "geometry"}) + ET.SubElement(c, "mxGeometry", {"x": str(int(cx(t) - 130)), "y": str(int(ly + 12)), + "width": "260", "height": "18", "as": "geometry"}) open(sys.argv[1], "w").write( '' diff --git a/scripts/lint-codes.ts b/scripts/lint-codes.ts index 6ea40dc..b55d081 100644 --- a/scripts/lint-codes.ts +++ b/scripts/lint-codes.ts @@ -45,11 +45,6 @@ export const RETIRED: Readonly> = { }; export const PENDING: readonly Pending[] = [ - { - ticket: "#39", - reason: "a reference resolved by name, which needs the linking step", - codes: ["E_UNKNOWN_SURFACE"], - }, { ticket: "#41", reason: "a reference into the Platform document, which needs its metamodel", diff --git a/spec/v1/10-project-intent.md b/spec/v1/10-project-intent.md index 29b0cf1..5f08db7 100644 --- a/spec/v1/10-project-intent.md +++ b/spec/v1/10-project-intent.md @@ -1245,13 +1245,20 @@ gap, not an argument for a field. | two exposures declare the same `host` | `E_DUPLICATE_HOST` | | two exposures of one Application share a `name` | `E_DUPLICATE_EXPOSURE_NAME` | | two routes of one exposure share the same `path` + `match` pair | `E_DUPLICATE_ROUTE_MATCH` | +| a route names a Process its Application does not have | `E_UNKNOWN_PROCESS` | | a route's `{process, surface}` pair names no surface that Process provides | `E_UNKNOWN_SURFACE` | `E_DUPLICATE_HOST` is evaluated at composition over the whole union, Registered Unmanaged Surfaces included, because a name the estate already answers on is taken -whether or not this model deploys what answers (chapter 40). The other three are +whether or not this model deploys what answers (chapter 40). The other four are scoped to a single document and are refused as soon as the fragment is read. +A route's `process` and `surface` are **references**, not strings: reading the +document links each to the Process and the surface it names, inside the one +Application that holds the route, and a name that links to nothing is refused at +the route's own path. When the Process does not resolve, the surface is not +reported as well: there is no Process to look it up in. + `E_DUPLICATE_EXPOSURE_NAME` has had an implementation and an error code for longer than it has had a definition: nothing said what a name was, or whether an exposure had one. It is unique **within the Application**. `jellyfin` may declare @@ -1303,7 +1310,11 @@ section at all. `scrape` names a **surface, not a port**, the same way a route does ([Exposure](#exposure)). The port is already declared once in `provides`, and a -second statement of it would be a second declaring site for one fact. The path +second statement of it would be a second declaring site for one fact. Its +`process` and `surface` are references linked the same way a route's are: a +scrape naming a Process its Application does not have is `E_UNKNOWN_PROCESS`, and +one naming a surface that Process does not provide is `E_UNKNOWN_SURFACE`, both +at the scrape's path. The path genuinely varies ( `/actuator/prometheus`, `/api/actuator/prometheus`, `/metrics`) so it is authored, and a platform that guessed would collect nothing and report success. @@ -1857,6 +1868,18 @@ already told them, and the lines reaching them made the model harder to read. | `Tolerance` | `Rotation.tolerates` | `restart`, `reload` | | `PlaceholderKind` | `Placeholder.kind` | `secret`, `dependency`, `exposure`, `identity` | +### Absent or `none` + +One convention decides how a document opts out of something, and it depends on +which mistake is worse. **Where forgetting a block is harmless, absence is the +opt-out**: an Application with no `observability` block wants no monitoring, and +there is no `none` to write, because a forgotten alert is a gap someone notices. +**Where forgetting a block is dangerous, `none` must be written**: a Process with +no listener declares `probes: none`, because a forgotten probe block would let a +Process that serves traffic start without anyone checking it. The metamodel +encodes both: `observability` is optional, and `probes` is either a block or the +word `none`. + Three carry a constraint the list alone does not state. `AccessTier` is `kv`-only except for `read`, and `TransitOp` applies to a `transit` grant only ([Access tiers](#access-tiers)). `AlertClass` has no `none` member: an Application @@ -2132,7 +2155,7 @@ classDiagram Application "1" *-- "0..*" Exposure : exposure Exposure "1" *-- "1..*" Route : routes - Route ..> Surface : resolves by name + Route --> Surface : surface DependencyEdge ..> Surface : resolves by name Process "1" *-- "1..*" EnvFile : env per process diff --git a/spec/v1/diagrams/10-project-intent-model.drawio.svg b/spec/v1/diagrams/10-project-intent-model.drawio.svg index 4008dff..71fa7ed 100644 --- a/spec/v1/diagrams/10-project-intent-model.drawio.svg +++ b/spec/v1/diagrams/10-project-intent-model.drawio.svg @@ -1,4 +1,4 @@ -Asset+ Path from+ Path mountAt+ map substituteCapacity+ int count+ string reasonDependencyEdge+ ApplicationId application+ string surface+ bool requiredDiskRequest+ Media[] mediaProject+ ProjectName project+ string owner+ SemVer schemaVersionEnvFile+ ClusterTarget cluster+ dotenv entriesExposure+ ExposureName name+ Fqdn host+ Audience audience+ ContentPolicy contentPolicyGpuRequest+ GpuClassName class+ Quantity memoryGrant+ SecretEngine engine+ VaultPath path+ string[] keys+ AccessTier access+ string role+ string key+ TransitOp[] operations+ Delivery delivery+ Path mountAt+ FileMode fileModeObservability+ AlertClass alertClassPlaceholder+ PlaceholderKind kind+ string sourcePlacement+ Quantity memory+ Quantity cpu+ Arch[] arch+ Site site+ Capability[] capabilitiesProbe+ Path path+ int port+ int tcpRotation+ Tolerance tolerates+ Duration maxAgeRoute+ Path path+ Match match+ string process+ string surface+ Audience audience+ Path redirectToScrape+ string process+ string surface+ Path pathService+ ApplicationId idSidecar+ string name+ ImageAlias image+ Quantity memory+ Quantity cpuSurface+ string name+ int portVolume+ string claim+ Path mountAt+ Quantity size+ DurabilityClass durabilityProcess+ string name+ Lifecycle lifecycle+ ImageAlias image+ Runtime runtime+ Engine engine+ Duration startupBudget+ Cutover cutover+ Path[] writablePaths1..* applications1..* processes0..* provides0..* sidecars0..* dependsOn0..1 readiness0..1 liveness0..* assets0..* volumes1 placement0..1 observability1 scrape0..1 replicas0..1 disk0..1 gpu0..* exposure1..* routes1..* env per process0..* resolves0..* secrets0..1 rotation0..* secrets«resolves by name» \ No newline at end of file +Application+ ApplicationId idAsset+ Path from+ Path mountAt+ map substituteCapacity+ int count+ string reasonDependencyEdge+ ApplicationId application+ string surface+ bool requiredDiskRequest+ Media[] mediaEnvFile+ ClusterTarget cluster+ dotenv entriesExposure+ ExposureName name+ Fqdn host+ Audience audience+ ContentPolicy contentPolicyGpuRequest+ GpuClassName class+ Quantity memoryGrant+ SecretEngine engine+ VaultPath path+ string[] keys+ AccessTier access+ string role+ string key+ TransitOp[] operations+ Delivery delivery+ Path mountAt+ FileMode fileModeObservability+ AlertClass alertClassPlaceholder+ PlaceholderKind kind+ string sourcePlacement+ Quantity memory+ Quantity cpu+ Arch[] arch+ Site site+ Capability[] capabilitiesProbe+ Path path+ int port+ int tcpProcess+ string name+ Lifecycle lifecycle+ ImageAlias image+ Runtime runtime+ Engine engine+ Duration startupBudget+ Cutover cutover+ Path[] writablePathsProject+ ProjectName project+ string owner+ SemVer schemaVersionRotation+ Tolerance tolerates+ Duration maxAgeRoute+ Path path+ Match match+ string process+ string surface+ Audience audience+ Path redirectToScrape+ string process+ string surface+ Path pathSidecar+ string name+ ImageAlias image+ Quantity memory+ Quantity cpuSurface+ string name+ int portVolume+ string claim+ Path mountAt+ Quantity size+ DurabilityClass durability1..* applications1..* processes0..* provides0..* sidecars0..* dependsOn0..1 readiness0..1 liveness0..* assets0..* volumes1 placement0..1 observability1 scrape0..1 replicas0..1 disk0..1 gpu0..* exposure1..* routes1..* env per process0..* resolves0..* secrets0..1 rotation0..* secrets«resolves by name» · surface \ No newline at end of file diff --git a/spec/v1/examples/expected/descriptor.json b/spec/v1/examples/expected/descriptor.json index 43af0c1..e9e681b 100644 --- a/spec/v1/examples/expected/descriptor.json +++ b/spec/v1/examples/expected/descriptor.json @@ -1 +1 @@ -{"classes":[{"features":[{"many":true,"map":false,"name":"exposure","required":false,"types":["Exposure"]},{"many":false,"map":false,"name":"id","required":true,"types":["string"]},{"many":false,"map":false,"name":"observability","required":false,"types":["Observability"]},{"many":true,"map":false,"name":"processes","required":true,"types":["Process"]},{"many":true,"map":false,"name":"secrets","required":false,"types":["DatabaseGrant","KvGrant","TransitGrant"]}],"name":"Application"},{"features":[{"many":false,"map":false,"name":"from","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":true,"types":["string"]}],"name":"Asset"},{"features":[{"many":false,"map":false,"name":"count","required":true,"types":["int"]},{"many":false,"map":false,"name":"reason","required":true,"types":["string"]}],"name":"Capacity"},{"features":[{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"engine","required":true,"types":["DatabaseEngine"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":false,"map":false,"name":"role","required":true,"types":["string"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"DatabaseGrant"},{"features":[{"many":false,"map":false,"name":"application","required":true,"types":["string"]},{"many":false,"map":false,"name":"required","required":false,"types":["boolean"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"DependencyEdge"},{"features":[{"many":true,"map":false,"name":"media","required":true,"types":["Media"]}],"name":"DiskRequest"},{"features":[{"many":false,"map":false,"name":"audience","required":true,"types":["Audience"]},{"many":false,"map":false,"name":"contentPolicy","required":false,"types":["ContentPolicy"]},{"many":false,"map":false,"name":"host","required":true,"types":["string"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]},{"many":true,"map":false,"name":"routes","required":true,"types":["Route"]}],"name":"Exposure"},{"features":[{"many":false,"map":false,"name":"class","required":true,"types":["string"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]}],"name":"GpuRequest"},{"features":[{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"port","required":true,"types":["int"]}],"name":"HttpProbe"},{"features":[{"many":false,"map":false,"name":"access","required":true,"types":["AccessTier"]},{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":true,"map":false,"name":"keys","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"KvGrant"},{"features":[],"name":"NoProbes","scalar":"none"},{"features":[{"many":false,"map":false,"name":"alertClass","required":true,"types":["AlertClass"]},{"many":false,"map":false,"name":"scrape","required":false,"types":["Scrape"]}],"name":"Observability"},{"features":[{"many":true,"map":false,"name":"arch","required":false,"types":["Arch"]},{"many":true,"map":false,"name":"capabilities","required":false,"types":["string"]},{"many":false,"map":false,"name":"cpu","required":true,"types":["string"]},{"many":false,"map":false,"name":"disk","required":false,"types":["DiskRequest"]},{"many":false,"map":false,"name":"gpu","required":false,"types":["GpuRequest"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]},{"many":false,"map":false,"name":"site","required":false,"types":["string"]}],"name":"Placement"},{"features":[{"many":false,"map":false,"name":"liveness","required":false,"types":["HttpProbe","TcpProbe"]},{"many":false,"map":false,"name":"readiness","required":false,"types":["HttpProbe","TcpProbe"]}],"name":"Probes"},{"features":[{"many":true,"map":false,"name":"assets","required":false,"types":["Asset"]},{"many":false,"map":false,"name":"cutover","required":true,"types":["Cutover"]},{"many":true,"map":false,"name":"dependsOn","required":false,"types":["DependencyEdge"]},{"many":false,"map":false,"name":"engine","required":false,"types":["Engine"]},{"many":false,"map":false,"name":"image","required":true,"types":["string"]},{"many":false,"map":false,"name":"lifecycle","required":true,"types":["Lifecycle"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]},{"many":false,"map":false,"name":"placement","required":true,"types":["Placement"]},{"many":false,"map":false,"name":"probes","required":false,"types":["NoProbes","Probes"]},{"many":false,"map":true,"name":"provides","required":false,"types":["int"]},{"many":false,"map":false,"name":"replicas","required":false,"types":["Capacity"]},{"many":false,"map":false,"name":"runtime","required":true,"types":["Runtime"]},{"many":true,"map":false,"name":"secrets","required":false,"types":["DatabaseGrant","KvGrant","TransitGrant"]},{"many":true,"map":false,"name":"sidecars","required":false,"types":["Sidecar"]},{"many":false,"map":false,"name":"startupBudget","required":false,"types":["string"]},{"many":true,"map":false,"name":"volumes","required":false,"types":["Volume"]},{"many":true,"map":false,"name":"writablePaths","required":false,"types":["string"]}],"name":"Process"},{"features":[{"many":false,"map":false,"name":"apiVersion","required":true,"types":["string"]},{"many":true,"map":false,"name":"applications","required":true,"types":["Application"]},{"many":false,"map":false,"name":"kind","required":true,"types":["string"]},{"many":false,"map":false,"name":"owner","required":true,"types":["string"]},{"many":false,"map":false,"name":"project","required":true,"types":["string"]},{"many":false,"map":false,"name":"schemaVersion","required":true,"types":["string"]}],"name":"Project"},{"features":[{"many":false,"map":false,"name":"maxAge","required":false,"types":["string"]},{"many":false,"map":false,"name":"tolerates","required":true,"types":["Tolerance"]}],"name":"Rotation"},{"features":[{"many":false,"map":false,"name":"audience","required":false,"types":["Audience"]},{"many":false,"map":false,"name":"match","required":true,"types":["Match"]},{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"process","required":true,"types":["string"]},{"many":false,"map":false,"name":"redirectTo","required":false,"types":["string"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"Route"},{"features":[{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"process","required":true,"types":["string"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"Scrape"},{"features":[{"many":false,"map":false,"name":"cpu","required":true,"types":["string"]},{"many":false,"map":false,"name":"image","required":true,"types":["string"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]}],"name":"Sidecar"},{"features":[{"many":false,"map":false,"name":"tcp","required":true,"types":["int"]}],"name":"TcpProbe"},{"features":[{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"engine","required":true,"types":["TransitEngine"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":false,"map":false,"name":"key","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":true,"map":false,"name":"operations","required":true,"types":["TransitOp"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"TransitGrant"},{"features":[{"many":false,"map":false,"name":"claim","required":true,"types":["string"]},{"many":false,"map":false,"name":"durability","required":true,"types":["DurabilityClass"]},{"many":false,"map":false,"name":"mountAt","required":true,"types":["string"]},{"many":false,"map":false,"name":"size","required":false,"types":["string"]}],"name":"Volume"}],"vocabularies":[{"literals":["read","self-renew","self-roll","custody"],"name":"AccessTier"},{"literals":["business-hours","urgent","page"],"name":"AlertClass"},{"literals":["amd64","arm64"],"name":"Arch"},{"literals":["anonymous","authenticated","internal","lan"],"name":"Audience"},{"literals":["strict","admin","workflow"],"name":"ContentPolicy"},{"literals":["rolling","recreate"],"name":"Cutover"},{"literals":["database"],"name":"DatabaseEngine"},{"literals":["env","file","self"],"name":"Delivery"},{"literals":["reconstructible","recoverable","irreplaceable"],"name":"DurabilityClass"},{"literals":["postgres","rabbitmq","valkey","files"],"name":"Engine"},{"literals":["application","job"],"name":"Lifecycle"},{"literals":["prefix","exact"],"name":"Match"},{"literals":["nvme","ssd","hdd"],"name":"Media"},{"literals":["jvm","python","node","static","none"],"name":"Runtime"},{"literals":["restart","reload"],"name":"Tolerance"},{"literals":["transit"],"name":"TransitEngine"},{"literals":["sign","verify","encrypt","decrypt","rotate"],"name":"TransitOp"}]} \ No newline at end of file +{"classes":[{"features":[{"many":true,"map":false,"name":"exposure","reference":false,"required":false,"types":["Exposure"]},{"many":false,"map":false,"name":"id","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"observability","reference":false,"required":false,"types":["Observability"]},{"many":true,"map":false,"name":"processes","reference":false,"required":true,"types":["Process"]},{"many":true,"map":false,"name":"secrets","reference":false,"required":false,"types":["DatabaseGrant","KvGrant","TransitGrant"]}],"name":"Application"},{"features":[{"many":false,"map":false,"name":"from","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","reference":false,"required":true,"types":["string"]}],"name":"Asset"},{"features":[{"many":false,"map":false,"name":"count","reference":false,"required":true,"types":["int"]},{"many":false,"map":false,"name":"reason","reference":false,"required":true,"types":["string"]}],"name":"Capacity"},{"features":[{"many":false,"map":false,"name":"delivery","reference":false,"required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"engine","reference":false,"required":true,"types":["DatabaseEngine"]},{"many":false,"map":false,"name":"fileMode","reference":false,"required":false,"types":["string"]},{"many":false,"map":false,"name":"mountAt","reference":false,"required":false,"types":["string"]},{"many":false,"map":false,"name":"role","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"rotation","reference":false,"required":false,"types":["Rotation"]}],"name":"DatabaseGrant"},{"features":[{"many":false,"map":false,"name":"application","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"required","reference":false,"required":false,"types":["boolean"]},{"many":false,"map":false,"name":"surface","reference":false,"required":true,"types":["string"]}],"name":"DependencyEdge"},{"features":[{"many":true,"map":false,"name":"media","reference":false,"required":true,"types":["Media"]}],"name":"DiskRequest"},{"features":[{"many":false,"map":false,"name":"audience","reference":false,"required":true,"types":["Audience"]},{"many":false,"map":false,"name":"contentPolicy","reference":false,"required":false,"types":["ContentPolicy"]},{"many":false,"map":false,"name":"host","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"name","reference":false,"required":true,"types":["string"]},{"many":true,"map":false,"name":"routes","reference":false,"required":true,"types":["Route"]}],"name":"Exposure"},{"features":[{"many":false,"map":false,"name":"class","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"memory","reference":false,"required":true,"types":["string"]}],"name":"GpuRequest"},{"features":[{"many":false,"map":false,"name":"path","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"port","reference":false,"required":true,"types":["int"]}],"name":"HttpProbe"},{"features":[{"many":false,"map":false,"name":"access","reference":false,"required":true,"types":["AccessTier"]},{"many":false,"map":false,"name":"delivery","reference":false,"required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"fileMode","reference":false,"required":false,"types":["string"]},{"many":true,"map":false,"name":"keys","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","reference":false,"required":false,"types":["string"]},{"many":false,"map":false,"name":"path","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"rotation","reference":false,"required":false,"types":["Rotation"]}],"name":"KvGrant"},{"features":[],"name":"NoProbes","scalar":"none"},{"features":[{"many":false,"map":false,"name":"alertClass","reference":false,"required":true,"types":["AlertClass"]},{"many":false,"map":false,"name":"scrape","reference":false,"required":false,"types":["Scrape"]}],"name":"Observability"},{"features":[{"many":true,"map":false,"name":"arch","reference":false,"required":false,"types":["Arch"]},{"many":true,"map":false,"name":"capabilities","reference":false,"required":false,"types":["string"]},{"many":false,"map":false,"name":"cpu","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"disk","reference":false,"required":false,"types":["DiskRequest"]},{"many":false,"map":false,"name":"gpu","reference":false,"required":false,"types":["GpuRequest"]},{"many":false,"map":false,"name":"memory","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"site","reference":false,"required":false,"types":["string"]}],"name":"Placement"},{"features":[{"many":false,"map":false,"name":"liveness","reference":false,"required":false,"types":["HttpProbe","TcpProbe"]},{"many":false,"map":false,"name":"readiness","reference":false,"required":false,"types":["HttpProbe","TcpProbe"]}],"name":"Probes"},{"features":[{"many":true,"map":false,"name":"assets","reference":false,"required":false,"types":["Asset"]},{"many":false,"map":false,"name":"cutover","reference":false,"required":true,"types":["Cutover"]},{"many":true,"map":false,"name":"dependsOn","reference":false,"required":false,"types":["DependencyEdge"]},{"many":false,"map":false,"name":"engine","reference":false,"required":false,"types":["Engine"]},{"many":false,"map":false,"name":"image","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"lifecycle","reference":false,"required":true,"types":["Lifecycle"]},{"many":false,"map":false,"name":"name","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"placement","reference":false,"required":true,"types":["Placement"]},{"many":false,"map":false,"name":"probes","reference":false,"required":false,"types":["NoProbes","Probes"]},{"entry":"Surface","many":false,"map":true,"name":"provides","reference":false,"required":false,"types":["int"]},{"many":false,"map":false,"name":"replicas","reference":false,"required":false,"types":["Capacity"]},{"many":false,"map":false,"name":"runtime","reference":false,"required":true,"types":["Runtime"]},{"many":true,"map":false,"name":"secrets","reference":false,"required":false,"types":["DatabaseGrant","KvGrant","TransitGrant"]},{"many":true,"map":false,"name":"sidecars","reference":false,"required":false,"types":["Sidecar"]},{"many":false,"map":false,"name":"startupBudget","reference":false,"required":false,"types":["string"]},{"many":true,"map":false,"name":"volumes","reference":false,"required":false,"types":["Volume"]},{"many":true,"map":false,"name":"writablePaths","reference":false,"required":false,"types":["string"]}],"name":"Process"},{"features":[{"many":false,"map":false,"name":"apiVersion","reference":false,"required":true,"types":["string"]},{"many":true,"map":false,"name":"applications","reference":false,"required":true,"types":["Application"]},{"many":false,"map":false,"name":"kind","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"owner","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"project","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"schemaVersion","reference":false,"required":true,"types":["string"]}],"name":"Project"},{"features":[{"many":false,"map":false,"name":"maxAge","reference":false,"required":false,"types":["string"]},{"many":false,"map":false,"name":"tolerates","reference":false,"required":true,"types":["Tolerance"]}],"name":"Rotation"},{"features":[{"many":false,"map":false,"name":"audience","reference":false,"required":false,"types":["Audience"]},{"many":false,"map":false,"name":"match","reference":false,"required":true,"types":["Match"]},{"many":false,"map":false,"name":"path","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"process","reference":true,"required":true,"types":["Process"]},{"many":false,"map":false,"name":"redirectTo","reference":false,"required":false,"types":["string"]},{"many":false,"map":false,"name":"surface","reference":true,"required":true,"types":["Surface"]}],"name":"Route"},{"features":[{"many":false,"map":false,"name":"path","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"process","reference":true,"required":true,"types":["Process"]},{"many":false,"map":false,"name":"surface","reference":true,"required":true,"types":["Surface"]}],"name":"Scrape"},{"features":[{"many":false,"map":false,"name":"cpu","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"image","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"memory","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"name","reference":false,"required":true,"types":["string"]}],"name":"Sidecar"},{"features":[{"many":false,"map":false,"name":"tcp","reference":false,"required":true,"types":["int"]}],"name":"TcpProbe"},{"features":[{"many":false,"map":false,"name":"delivery","reference":false,"required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"engine","reference":false,"required":true,"types":["TransitEngine"]},{"many":false,"map":false,"name":"fileMode","reference":false,"required":false,"types":["string"]},{"many":false,"map":false,"name":"key","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","reference":false,"required":false,"types":["string"]},{"many":true,"map":false,"name":"operations","reference":false,"required":true,"types":["TransitOp"]},{"many":false,"map":false,"name":"rotation","reference":false,"required":false,"types":["Rotation"]}],"name":"TransitGrant"},{"features":[{"many":false,"map":false,"name":"claim","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"durability","reference":false,"required":true,"types":["DurabilityClass"]},{"many":false,"map":false,"name":"mountAt","reference":false,"required":true,"types":["string"]},{"many":false,"map":false,"name":"size","reference":false,"required":false,"types":["string"]}],"name":"Volume"}],"vocabularies":[{"literals":["read","self-renew","self-roll","custody"],"name":"AccessTier"},{"literals":["business-hours","urgent","page"],"name":"AlertClass"},{"literals":["amd64","arm64"],"name":"Arch"},{"literals":["anonymous","authenticated","internal","lan"],"name":"Audience"},{"literals":["strict","admin","workflow"],"name":"ContentPolicy"},{"literals":["rolling","recreate"],"name":"Cutover"},{"literals":["database"],"name":"DatabaseEngine"},{"literals":["env","file","self"],"name":"Delivery"},{"literals":["reconstructible","recoverable","irreplaceable"],"name":"DurabilityClass"},{"literals":["postgres","rabbitmq","valkey","files"],"name":"Engine"},{"literals":["application","job"],"name":"Lifecycle"},{"literals":["prefix","exact"],"name":"Match"},{"literals":["nvme","ssd","hdd"],"name":"Media"},{"literals":["jvm","python","node","static","none"],"name":"Runtime"},{"literals":["restart","reload"],"name":"Tolerance"},{"literals":["transit"],"name":"TransitEngine"},{"literals":["sign","verify","encrypt","decrypt","rotate"],"name":"TransitOp"}]} \ No newline at end of file diff --git a/spec/v1/examples/refusals/scrape-unknown-process.diagnostics.json b/spec/v1/examples/refusals/scrape-unknown-process.diagnostics.json new file mode 100644 index 0000000..193b31d --- /dev/null +++ b/spec/v1/examples/refusals/scrape-unknown-process.diagnostics.json @@ -0,0 +1 @@ +[{"code":"E_UNKNOWN_PROCESS","path":"/applications/0/observability/scrape"}] \ No newline at end of file diff --git a/spec/v1/examples/refusals/scrape-unknown-process.project.yml b/spec/v1/examples/refusals/scrape-unknown-process.project.yml new file mode 100644 index 0000000..77b9791 --- /dev/null +++ b/spec/v1/examples/refusals/scrape-unknown-process.project.yml @@ -0,0 +1,36 @@ +# REFUSED: E_UNKNOWN_PROCESS +# +# A scrape names the Process that carries the signal, and that Process must be +# one of this Application's own. This one names an exporter no Process of the +# Application is called, so the signal the class is about has nowhere to come +# from (spec/v1/10-project-intent.md #observability). + +apiVersion: intent.jorisjonkers.dev/v1 +kind: Project +schemaVersion: 1.0.0 +project: refusals +owner: joris +applications: + - id: scrape-unknown-process + observability: + alertClass: urgent + scrape: + process: scrape-unknown-process-exporter + surface: metrics + path: /metrics + processes: + - name: scrape-unknown-process-api + lifecycle: application + image: scrape-unknown-process-api + runtime: node + provides: + http: 8080 + metrics: 9090 + placement: + memory: 128Mi + cpu: 25m + probes: + readiness: { path: /healthz/ready, port: 8080 } + liveness: { path: /healthz/live, port: 8080 } + startupBudget: 20s + cutover: rolling diff --git a/spec/v1/examples/refusals/unknown-surface.diagnostics.json b/spec/v1/examples/refusals/unknown-surface.diagnostics.json new file mode 100644 index 0000000..f5c3a67 --- /dev/null +++ b/spec/v1/examples/refusals/unknown-surface.diagnostics.json @@ -0,0 +1 @@ +[{"code":"E_UNKNOWN_SURFACE","path":"/applications/0/exposure/0/routes/0"}] \ No newline at end of file diff --git a/spec/v1/examples/refusals/unknown-surface.project.yml b/spec/v1/examples/refusals/unknown-surface.project.yml new file mode 100644 index 0000000..eb05255 --- /dev/null +++ b/spec/v1/examples/refusals/unknown-surface.project.yml @@ -0,0 +1,37 @@ +# REFUSED: E_UNKNOWN_SURFACE +# +# A route names a Process and a surface, and the surface is resolved against +# what that Process `provides`. This one names `https`, which the Process never +# declares: the port is written once, by the Process that listens on it, and a +# route that names anything else points at nothing +# (spec/v1/10-project-intent.md #what-is-checked). + +apiVersion: intent.jorisjonkers.dev/v1 +kind: Project +schemaVersion: 1.0.0 +project: refusals +owner: joris +applications: + - id: unknown-surface + exposure: + - name: public + host: unknown-surface.jorisjonkers.dev + audience: anonymous + contentPolicy: strict + routes: + - { path: /, match: prefix, process: unknown-surface-api, surface: https } + processes: + - name: unknown-surface-api + lifecycle: application + image: unknown-surface-api + runtime: node + provides: + http: 8080 + placement: + memory: 128Mi + cpu: 25m + probes: + readiness: { path: /healthz/ready, port: 8080 } + liveness: { path: /healthz/live, port: 8080 } + startupBudget: 20s + cutover: rolling diff --git a/spec/v1/schemas/project-intent.schema.json b/spec/v1/schemas/project-intent.schema.json index 238ca4c..9d98a99 100644 --- a/spec/v1/schemas/project-intent.schema.json +++ b/spec/v1/schemas/project-intent.schema.json @@ -119,11 +119,13 @@ "properties": { "process": { "type": "string", - "minLength": 1 + "minLength": 1, + "reference": "Process" }, "surface": { "type": "string", - "minLength": 1 + "minLength": 1, + "reference": "Surface" }, "path": { "type": "string", @@ -199,11 +201,13 @@ }, "process": { "type": "string", - "minLength": 1 + "minLength": 1, + "reference": "Process" }, "surface": { "type": "string", - "minLength": 1 + "minLength": 1, + "reference": "Surface" }, "audience": { "$ref": "#/$defs/Audience" @@ -433,7 +437,8 @@ "type": "integer", "minimum": 1, "maximum": 65535 - } + }, + "entry": "Surface" }, "placement": { "$ref": "#/$defs/Placement" diff --git a/src/domain/project-intent/model.ts b/src/domain/project-intent/model.ts index e2e4396..990fcbe 100644 --- a/src/domain/project-intent/model.ts +++ b/src/domain/project-intent/model.ts @@ -30,15 +30,26 @@ export interface Application { readonly processes: readonly Process[]; } +/** A port a Process listens on, named once, in its `provides`. */ +export interface Surface { + readonly name: string; + readonly port: number; +} + +/** A Process and one of its surfaces, resolved from the names a document writes. */ export interface SurfaceRef { - readonly process: string; - readonly surface: string; + readonly process: Process; + readonly surface: Surface; +} + +export interface Scrape extends SurfaceRef { + readonly path: string; } export interface Observability { readonly alertClass: AlertClass; /** Whole or absent in the model: a document with a class and no signal is refused. */ - readonly scrape?: SurfaceRef & { readonly path: string }; + readonly scrape?: Scrape; } export interface Exposure { @@ -139,7 +150,7 @@ export interface Process { readonly image: string; readonly runtime: Runtime; readonly engine?: Engine; - readonly provides: ReadonlyMap; + readonly surfaces: readonly Surface[]; readonly placement: Placement; readonly writablePaths: readonly string[]; readonly sidecars: readonly Sidecar[]; diff --git a/src/wire/project-intent/descriptor.ts b/src/wire/project-intent/descriptor.ts index 9a726a4..4addd95 100644 --- a/src/wire/project-intent/descriptor.ts +++ b/src/wire/project-intent/descriptor.ts @@ -12,6 +12,10 @@ export interface DescriptorFeature { readonly required: boolean; readonly many: boolean; readonly map: boolean; + /** Whether the value links to a model element rather than holding one. */ + readonly reference: boolean; + /** The name of what one entry of a map is, where the feature is a map. */ + readonly entry?: string; } export interface DescriptorClass { @@ -59,6 +63,16 @@ function feature( node: Node, required: boolean, ): DescriptorFeature { + const reference = node["reference"]; + if (typeof reference === "string") + return { + name, + types: [reference], + required, + many: false, + map: false, + reference: true, + }; if (node["type"] === "array") return { name, @@ -66,6 +80,7 @@ function feature( required, many: true, map: false, + reference: false, }; if (node["type"] === "object") return { @@ -74,8 +89,17 @@ function feature( required, many: false, map: true, + reference: false, + entry: String(node["entry"]), }; - return { name, types: typesOf(node), required, many: false, map: false }; + return { + name, + types: typesOf(node), + required, + many: false, + map: false, + reference: false, + }; } /** The descriptor of the Project Intent metamodel, as the wire schemas declare it. */ diff --git a/src/wire/project-intent/link.ts b/src/wire/project-intent/link.ts new file mode 100644 index 0000000..26838a3 --- /dev/null +++ b/src/wire/project-intent/link.ts @@ -0,0 +1,48 @@ +// The linking step: the names a route or a scrape writes, resolved to the +// Process and the surface they mean, inside the one Application that holds +// them. A name that resolves to nothing is refused here, at the JSON Pointer of +// the route or scrape that wrote it. A dependency edge names another +// Application, which only the composed union can resolve (#46), so it is left +// as the names it was written with. +import type { Diagnostic } from "../../domain/diagnostic.ts"; +import type { Process, SurfaceRef } from "../../domain/project-intent/model.ts"; + +interface Written { + readonly process: string; + readonly surface: string; +} + +export type Linked = + | { readonly ok: true; readonly value: SurfaceRef } + | { readonly ok: false; readonly diagnostic: Diagnostic }; + +/** The Process and surface `written` names among `processes`, or the refusal naming what did not resolve. */ +export function link( + written: Written, + processes: readonly Process[], + at: string, +): Linked { + const process = processes.find(({ name }) => name === written.process); + if (process === undefined) + return { + ok: false, + diagnostic: { + code: "E_UNKNOWN_PROCESS", + path: at, + message: `no Process of this Application is named ${written.process}`, + hint: "Name one of the Application's own Processes.", + }, + }; + const surface = process.surfaces.find(({ name }) => name === written.surface); + if (surface === undefined) + return { + ok: false, + diagnostic: { + code: "E_UNKNOWN_SURFACE", + path: at, + message: `${process.name} provides no surface named ${written.surface}`, + hint: "Name a surface the Process declares in its `provides`.", + }, + }; + return { ok: true, value: { process, surface } }; +} diff --git a/src/wire/project-intent/map.ts b/src/wire/project-intent/map.ts index f7de953..6851871 100644 --- a/src/wire/project-intent/map.ts +++ b/src/wire/project-intent/map.ts @@ -2,10 +2,13 @@ import type { Diagnostic, Result } from "../../domain/diagnostic.ts"; import type { Application, Dependency, + Exposure, + Observability, Placement, Process, Project, } from "../../domain/project-intent/model.ts"; +import { link, type Linked } from "./link.ts"; import { ruleDiagnostics } from "./rules.ts"; import { projectIntent, type ProjectIntentDocument } from "./schema.ts"; @@ -43,7 +46,10 @@ function toProcess(process: WireProcess): Process { } = process; return { ...rest, - provides: new Map(Object.entries(provides ?? {})), + surfaces: Object.entries(provides ?? {}).map(([name, port]) => ({ + name, + port, + })), placement: toPlacement(placement), writablePaths: writablePaths ?? [], sidecars: sidecars ?? [], @@ -55,13 +61,72 @@ function toProcess(process: WireProcess): Process { }; } -function toApplication(application: WireApplication): Application { - const { exposure, processes, secrets, ...rest } = application; +type Resolved = Extract; + +/** An Application with its routes and scrape linked to its own Processes, or the refusals of what did not link. */ +function toApplication( + application: WireApplication, + at: string, +): + | { readonly application: Application; readonly refusals: readonly [] } + | { readonly refusals: readonly Diagnostic[] } { + const { exposure, processes, secrets, observability, ...rest } = application; + const linked = processes.map(toProcess); + const exposures = exposure ?? []; + + const linkedExposures = exposures.map((wire, index) => ({ + wire, + routes: wire.routes.map(({ process, surface, ...fields }, position) => ({ + fields, + result: link( + { process, surface }, + linked, + `${at}/exposure/${index}/routes/${position}`, + ), + })), + })); + const scrape = observability?.scrape; + const scrapeLinks = + scrape === undefined + ? [] + : [link(scrape, linked, `${at}/observability/scrape`)]; + const refusals = [ + ...linkedExposures.flatMap(({ routes }) => + routes.map(({ result }) => result), + ), + ...scrapeLinks, + ].flatMap((result) => (result.ok ? [] : [result.diagnostic])); + if (refusals.length > 0) return { refusals }; + + const resolved = (result: Linked | undefined): Resolved["value"] => + (result as Resolved).value; + let monitoring: Observability | undefined; + if (observability !== undefined) { + const { scrape: _written, ...fields } = observability; + monitoring = + scrape === undefined + ? fields + : { + ...fields, + scrape: { ...resolved(scrapeLinks[0]), path: scrape.path }, + }; + } + return { - ...rest, - exposures: exposure ?? [], - grants: secrets ?? [], - processes: processes.map(toProcess), + application: { + ...rest, + ...(monitoring === undefined ? {} : { observability: monitoring }), + exposures: linkedExposures.map(({ wire, routes }): Exposure => ({ + ...wire, + routes: routes.map(({ fields, result }) => ({ + ...fields, + ...resolved(result), + })), + })), + grants: secrets ?? [], + processes: linked, + }, + refusals: [], }; } @@ -84,9 +149,15 @@ export function validateProjectIntent( hint: "Correct the field against spec/v1/10-project-intent.md.", })), }; - const refusals = ruleDiagnostics(parsed.data); - if (refusals.length > 0) return { ok: false, diagnostics: refusals }; const { project, owner, applications } = parsed.data; + const mapped = applications.map((application, index) => + toApplication(application, `/applications/${index}`), + ); + const refusals = [ + ...ruleDiagnostics(parsed.data), + ...mapped.flatMap(({ refusals: unlinked }) => unlinked), + ]; + if (refusals.length > 0) return { ok: false, diagnostics: refusals }; return { ok: true, value: { @@ -94,7 +165,11 @@ export function validateProjectIntent( project: { name: project, owner, - applications: applications.map(toApplication), + // With no refusal left, every Application linked. + applications: mapped.map( + (result) => + (result as { readonly application: Application }).application, + ), }, }, }; diff --git a/src/wire/project-intent/schema.ts b/src/wire/project-intent/schema.ts index 24f939c..465bac7 100644 --- a/src/wire/project-intent/schema.ts +++ b/src/wire/project-intent/schema.ts @@ -46,6 +46,11 @@ const tolerance = z.enum(TOLERANCES).meta({ id: "Tolerance" }); const transitOp = z.enum(TRANSIT_OPERATIONS).meta({ id: "TransitOp" }); const text = z.string().min(1); + +// A name that links to a model element when the document is read. The meta is +// what the descriptor records the reference by; the authored value stays the name. +const processReference = text.meta({ reference: "Process" }); +const surfaceReference = text.meta({ reference: "Surface" }); const port = z.int().min(1).max(65535); const httpProbe = z @@ -160,7 +165,7 @@ const process = z image: text, runtime: runtime, engine: engine.exactOptional(), - provides: z.record(text, port).exactOptional(), + provides: z.record(text, port).meta({ entry: "Surface" }).exactOptional(), placement, writablePaths: z.array(text).min(1).exactOptional(), sidecars: z.array(sidecar).min(1).exactOptional(), @@ -179,8 +184,8 @@ const route = z .strictObject({ path: text, match: match, - process: text, - surface: text, + process: processReference, + surface: surfaceReference, audience: audience.exactOptional(), redirectTo: text.exactOptional(), }) @@ -197,7 +202,11 @@ const exposure = z .meta({ id: "Exposure" }); const scrape = z - .strictObject({ process: text, surface: text, path: text }) + .strictObject({ + process: processReference, + surface: surfaceReference, + path: text, + }) .meta({ id: "Scrape" }); // `scrape` is optional in the shape, not in the model: a block carrying a class diff --git a/test/diagram-model-consistency.test.ts b/test/diagram-model-consistency.test.ts index 7b6b5d5..1f7a2bc 100644 --- a/test/diagram-model-consistency.test.ts +++ b/test/diagram-model-consistency.test.ts @@ -33,6 +33,7 @@ function mermaidModel(): { classes: Record; comps: Pair[]; deps: Pair[]; + assocs: Pair[]; } { const md = read(join(spec, "10-project-intent.md")); const body = capture( @@ -55,7 +56,11 @@ function mermaidModel(): { m[1] ?? "", m[2] ?? "", ]); - return { classes, comps, deps }; + const assocs = [...body.matchAll(/(\w+) --> (\w+) :/g)].map((m): Pair => [ + m[1] ?? "", + m[2] ?? "", + ]); + return { classes, comps, deps, assocs }; } /** The boxes of a committed SVG, read out of its embedded draw.io payload. */ @@ -106,13 +111,17 @@ test("the class diagram draws exactly the mermaid's classes and attributes", () }); test("only the relations that span layers are left undrawn", () => { - const { comps, deps } = mermaidModel(); + const { comps, deps, assocs } = mermaidModel(); const { edges } = svgModel("10-project-intent-model.drawio.svg"); // Placeholder reaches Grant and Exposure across four layers. Those two are // stated in the chapter instead; everything else is on the drawing. const undrawn = deps.filter(([from]) => from === "Placeholder").length; expect(undrawn, "the set of undrawn relations changed").toBe(2); - expect(edges).toBe(comps.length + deps.length - undrawn); + expect( + assocs, + "a reference the model resolves is drawn as an association", + ).toStrictEqual([["Route", "Surface"]]); + expect(edges).toBe(comps.length + deps.length + assocs.length - undrawn); }); test("no drawing carries an enumeration box", () => { diff --git a/test/model/descriptor.test.ts b/test/model/descriptor.test.ts index a00d5bd..147991b 100644 --- a/test/model/descriptor.test.ts +++ b/test/model/descriptor.test.ts @@ -48,6 +48,8 @@ describe("the descriptor", () => { required: false, many: false, map: true, + reference: false, + entry: "Surface", }); expect(feature("secrets")).toStrictEqual({ name: "secrets", @@ -55,9 +57,29 @@ describe("the descriptor", () => { required: false, many: true, map: false, + reference: false, }); }); + it("records a name the model links as a reference to its target", () => { + const { classes } = descriptor(); + const features = (owner: string) => + classes + .find(({ name }) => name === owner) + ?.features.filter(({ reference }) => reference) + .map(({ name, types }) => [name, types]); + + expect(features("Route")).toStrictEqual([ + ["process", ["Process"]], + ["surface", ["Surface"]], + ]); + expect(features("Scrape")).toStrictEqual([ + ["process", ["Process"]], + ["surface", ["Surface"]], + ]); + expect(features("DependencyEdge")).toStrictEqual([]); + }); + it("carries a class written as one word as that word", () => { expect( descriptor().classes.find(({ name }) => name === "NoProbes"), diff --git a/test/model/links.test.ts b/test/model/links.test.ts new file mode 100644 index 0000000..26d7a07 --- /dev/null +++ b/test/model/links.test.ts @@ -0,0 +1,87 @@ +// REQ-029 (docs/requirements.md): a route's and a scrape's names link to the +// Process and surface they mean, and a name that links to nothing is refused at +// the pointer of what wrote it. +import { describe, expect, it } from "vitest"; +import { parseProjectIntent } from "../../src/index.ts"; + +const DOCUMENT = `apiVersion: intent.jorisjonkers.dev/v1 +kind: Project +schemaVersion: 1.0.0 +project: refusals +owner: joris +applications: + - id: batch +GRANTS processes: + - name: worker + lifecycle: job + image: worker + runtime: none + placement: { memory: 64Mi, cpu: 10m } + cutover: recreate +`; + +const refusalsOf = (text: string): { code: string; path: string }[] => { + const result = parseProjectIntent(text); + return result.ok + ? [] + : result.diagnostics.map(({ code, path }) => ({ code, path })); +}; + +describe("the linking step", () => { + const withRoute = (process: string, surface: string): string => + DOCUMENT.replace( + "GRANTS", + ` exposure:\n - name: public\n host: batch.jorisjonkers.dev\n audience: lan\n routes:\n - { path: /, match: prefix, process: ${process}, surface: ${surface} }\n`, + ).replace( + " cutover: recreate\n", + " provides: { http: 8080 }\n cutover: recreate\n", + ); + + const withScrape = (process: string, surface: string): string => + DOCUMENT.replace( + "GRANTS", + ` observability:\n alertClass: urgent\n scrape: { process: ${process}, surface: ${surface}, path: /metrics }\n`, + ).replace( + " cutover: recreate\n", + " provides: { http: 8080 }\n cutover: recreate\n", + ); + + it("refuses a route naming a Process the Application does not have, and reports its surface no further", () => { + expect(refusalsOf(withRoute("elsewhere", "nothing"))).toStrictEqual([ + { + code: "E_UNKNOWN_PROCESS", + path: "/applications/0/exposure/0/routes/0", + }, + ]); + }); + + it("refuses a scrape naming a surface its Process does not provide", () => { + expect(refusalsOf(withScrape("worker", "metrics"))).toStrictEqual([ + { + code: "E_UNKNOWN_SURFACE", + path: "/applications/0/observability/scrape", + }, + ]); + }); + + it("links a route and a scrape that name what the Application holds", () => { + expect(refusalsOf(withRoute("worker", "http"))).toStrictEqual([]); + expect(refusalsOf(withScrape("worker", "http"))).toStrictEqual([]); + }); + + it("says what did not link and how to fix it", () => { + const result = parseProjectIntent(withRoute("elsewhere", "nothing")); + const [process] = result.ok ? [] : result.diagnostics; + const scraped = parseProjectIntent(withScrape("worker", "metrics")); + const [surface] = scraped.ok ? [] : scraped.diagnostics; + + expect(process?.message).toBe( + "no Process of this Application is named elsewhere", + ); + expect(process?.hint).toBe("Name one of the Application's own Processes."); + expect(surface?.message).toBe("worker provides no surface named metrics"); + expect(surface?.hint).toBe( + "Name a surface the Process declares in its `provides`.", + ); + }); +}); diff --git a/test/model/project-intent.test.ts b/test/model/project-intent.test.ts index 1879728..c091231 100644 --- a/test/model/project-intent.test.ts +++ b/test/model/project-intent.test.ts @@ -94,8 +94,29 @@ describe("parseProjectIntent", () => { expect(result.ok && canonicalJson(result.value.document)).not.toBe(ORACLE); }); - it("maps the minimal case into the domain model", () => { + it("maps the minimal case into the domain model, with its references resolved", () => { const result = parseProjectIntent(MINIMAL); + const surface = { name: "http", port: 8080 }; + const process = { + name: "notes-api", + lifecycle: "application", + image: "notes-api", + runtime: "node", + surfaces: [surface], + 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 }, + }, + startupBudget: "20s", + cutover: "rolling", + }; expect(result.ok && result.value.project).toStrictEqual({ name: "notes", @@ -105,7 +126,7 @@ describe("parseProjectIntent", () => { id: "notes", observability: { alertClass: "business-hours", - scrape: { process: "notes-api", surface: "http", path: "/metrics" }, + scrape: { process, surface, path: "/metrics" }, }, grants: [], exposures: [ @@ -114,48 +135,32 @@ describe("parseProjectIntent", () => { host: "notes.jorisjonkers.dev", audience: "anonymous", contentPolicy: "strict", - routes: [ - { - path: "/", - match: "prefix", - process: "notes-api", - surface: "http", - }, - ], - }, - ], - processes: [ - { - name: "notes-api", - lifecycle: "application", - image: "notes-api", - runtime: "node", - provides: new Map([["http", 8080]]), - 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 }, - }, - startupBudget: "20s", - cutover: "rolling", + routes: [{ path: "/", match: "prefix", process, surface }], }, ], + processes: [process], }, ], }); }); + it("links a route and a scrape to the very Process and surface the Application holds", () => { + const result = parseProjectIntent(MINIMAL); + const application = result.ok + ? result.value.project.applications[0] + : undefined; + const process = application?.processes[0]; + + expect(application?.exposures[0]?.routes[0]?.process).toBe(process); + expect(application?.exposures[0]?.routes[0]?.surface).toBe( + process?.surfaces[0], + ); + expect(application?.observability?.scrape?.process).toBe(process); + expect(application?.observability?.scrape?.surface).toBe( + process?.surfaces[0], + ); + }); + it("maps an absent block to an empty one, and leaves an absent optional field absent", () => { const result = parseProjectIntent( withApplications(` - id: batch\n processes:\n${PROCESS}`), @@ -172,7 +177,7 @@ describe("parseProjectIntent", () => { lifecycle: "job", image: "worker", runtime: "none", - provides: new Map(), + surfaces: [], placement: { memory: "64Mi", cpu: "10m", diff --git a/test/model/refusals.test.ts b/test/model/refusals.test.ts index 50c1c3a..b3ea0b9 100644 --- a/test/model/refusals.test.ts +++ b/test/model/refusals.test.ts @@ -109,8 +109,10 @@ describe("the refusal fixtures", () => { "env-cannot-reload", "illegal-delivery-for-access", "non-kv-delivery", + "scrape-unknown-process", + "unknown-surface", ]); - expect(refused).toHaveLength(8); + expect(refused).toHaveLength(10); expect( fixtures.length - refused.length, "the accepted counterpart and the vocabulary case carry no oracle", diff --git a/test/simplification-contract.test.ts b/test/simplification-contract.test.ts index 6733e6a..275c689 100644 --- a/test/simplification-contract.test.ts +++ b/test/simplification-contract.test.ts @@ -145,17 +145,14 @@ test("a scrape names a surface its own Process provides, never a port", () => { for (const application of applicationsOf(file)) { const scrape = application.observability?.scrape; if (scrape === undefined) continue; - const process = application.processes.find( - (candidate) => candidate.name === scrape.process, - ); expect( - process, - `${application.id}: scrape names a Process that does not exist`, - ).toBeDefined(); + application.processes, + `${application.id}: scrape names a Process of another Application`, + ).toContain(scrape.process); expect( - [...(process?.provides.keys() ?? [])], - `${application.id}: its Process provides no surface of that name`, + scrape.process.surfaces, + `${application.id}: its Process provides no such surface`, ).toContain(scrape.surface); expect(scrape.path, `${application.id}: scrape names no path`).not.toBe( "", @@ -225,7 +222,7 @@ test("no Process or sidecar authors hardening", () => { test("no provides port below 1024, because there is no capability to declare", () => { for (const file of projectFiles) for (const process of processesOf(file)) - for (const [surface, port] of process.provides) + for (const { name: surface, port } of process.surfaces) expect( port, `${process.name}: ${surface} on ${port} is E_PRIVILEGED_PORT_UNDER_NONROOT`, diff --git a/vitest.config.ts b/vitest.config.ts index 159490f..3f92eff 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,11 +30,12 @@ export default defineConfig({ // lines 1324/1338. 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. + // Raised with the linking step (#39), which lands at 100%. thresholds: { - statements: 99.03, - branches: 96.09, + statements: 99.06, + branches: 96.17, functions: 100, - lines: 98.95, + lines: 98.97, }, }, },