From 9f835347f547991955ec287c982c3956758b34e1 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 09:54:18 +0000 Subject: [PATCH 1/2] feat(component): define metadata.version semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 4 was a heading and a TODO, while metadata.version was already load-bearing in three rules: blueprint §4.1 deploys "the referenced document's own metadata.version", blueprint §3 and listing §3 pin an item's two halves to each other, and the published reference form carries its own componentVersion. Answers the three questions #15 asks. Monotonicity: each publication MUST carry a version strictly greater than the highest already published for that component. Gaps are permitted. The phase is capability, because deciding it needs the catalog and §7 forbids the earlier phases from reaching the network — so offline validation is exactly as strict as it was and this is a minor release rather than a narrowing one. Reuse: a version is used once. componentVersion: 3 is the whole of what a published node deploys, and a registry that let 3 mean two documents would make the pin name nothing. Whether an identical re-submission is a no-op rather than a publication stays outside this contract, which orders publications without defining when two YAML files are the same document. Relationship to the item: none. The two numbers count different things, and in the published form one component is deployed by many items at once. A component release SHOULD be accompanied by an item release, without a diagnostic — the disagreement is visible only across two revisions and a validator is handed one. ERR_VERSION_NOT_MONOTONIC is the family's first capability code, so §8's closing paragraph no longer says the semantic codes alone are its own. It can have no fixture for the same reason ERR_UNKNOWN_COMPONENT cannot, and is recorded in the runner's UNCOVERED list with that reason. Section 4 also states what metadata carries — version and nothing else, no slug — which nothing said before. Refs #15 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- conformance/README.md | 5 +- specifications/component/v1/spec.md | 71 +++++++++++++++++++++++++++-- tools/src/conformance.ts | 4 ++ 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/conformance/README.md b/conformance/README.md index 2a56523..a7e3549 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -163,12 +163,13 @@ later-phase diagnostic before the earlier phases pass. ## Coverage status `parser`, `structural` and `semantic` are covered. Every diagnostic code the -three `spec.md` files declare is exercised by at least one case, with one -exception: +three `spec.md` files declare is exercised by at least one case, with two +exceptions, both `capability`: | Code | Why it has no case | |---|---| | `ERR_UNKNOWN_COMPONENT` | `capability` — resolving a published reference needs the catalog, and no phase a client runs may reach the network | +| `ERR_VERSION_NOT_MONOTONIC` | `capability` — comparing a version against the lineage it extends needs the catalog, and a fixture is one document with no previous release to be greater than | That table is not prose anyone has to remember to update. `task check:conformance` derives it: every `ERR_*` row in a family's own diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md index b298bbe..5cf092b 100644 --- a/specifications/component/v1/spec.md +++ b/specifications/component/v1/spec.md @@ -115,9 +115,69 @@ release its consumers hold. ## 4. Metadata -> **TODO** — Define `metadata.version` semantics: monotonicity requirements, the -> relationship to the containing item's version, and whether reuse of a version -> number with different content is an error. +`metadata` carries `version` and nothing else. A component document has no +`slug`. A [blueprint](../../blueprint/v1/spec.md#identity) and its listing each +name the item they are two halves of; a component is not the item, and the name +it answers to is the stem of the file that holds it — which is what a repo-local +reference spells out in full +([blueprint §4.1](../../blueprint/v1/spec.md#component-reference)). Any other +property is `ERR_UNKNOWN_FIELD`, as [§2](#envelope) requires at every level. + +`version` is an integer, 1 or greater. It is REQUIRED and never defaulted, so +what a node deploys is a function of this file alone. + +**The version names a position in one component's lineage.** It is not a SemVer +triple and carries no compatibility meaning: nothing is derivable from the +distance between 2 and 7, and nothing is promised about how one version behaves +against another. It orders, and that is the whole of its job. + +**Two reference forms pin it, and only one writes it down.** + +| Reference form | What pins the version | +|---|---| +| Repo-local | The referenced document's own `metadata.version`. `componentVersion` MUST NOT be present. | +| Published | `componentVersion` on the node. | + +**A version is used once.** Each publication of a component MUST carry a version +strictly greater than the highest already published for that component. Gaps are +permitted — 1 to 7 is a release and not an error — but a version that does not +increase is rejected in the `capability` phase with `ERR_VERSION_NOT_MONOTONIC`. + +Reuse is the case the rule exists for. `componentVersion: 3` on a published node +is the whole of what that node deploys, and a registry that let 3 mean two +different documents would make the pin name nothing. The repo-local form has the +same problem one step removed: a blueprint that deployed version 3 last month +and version 3 today, with different bytes behind it, has no way to say so. + +**Why the phase is `capability`.** Deciding the rule needs to know what was +published before, which needs the catalog, which needs the network — and +[§7](#validation-layers) forbids the `parser`, `structural` and `semantic` +phases from requiring it. A client validating a file it has just written cannot +see the lineage and MUST NOT report this rule. Offline validation is therefore +exactly as strict as it was. + +Whether a registry treats an identical re-submission as a no-op rather than as a +publication is outside this contract. This document orders publications; it does +not define when two YAML files are the same document. + +**The version is not the item's version.** +[Blueprint §3](../../blueprint/v1/spec.md#identity) pins a blueprint to its +sibling listing, and neither is pinned to any component beneath it. The two +numbers count different things: an item's version counts releases of the item, a +component's counts releases of the component, and in the published form one +component is deployed by many items at once. + +A release of a component an item deploys SHOULD be accompanied by a release of +the item, because the listing describes what would be installed and a component +that has moved makes that description stale. It is a SHOULD and carries no +diagnostic: the disagreement is visible only across two revisions, and a +validator is handed one. + +**What v1 does not constrain.** Nothing orders one component's versions against +another's — two components in the same item sitting at 4 and 11 mean nothing +worth reading into. Nothing checks a version offline at all: `minimum: 1` is the +whole of the `structural` rule, and every other statement in this section is +either `capability` or a SHOULD. ## 5. Workload @@ -373,12 +433,13 @@ different text and that is expected. | `ERR_INVALID_VALUE` | `structural` | A value violates a pattern, enum, or bound. | | `ERR_UNPINNED_IMAGE` | `semantic` | An image reference carries a floating tag. | | `ERR_UNKNOWN_ENDPOINT` | `semantic` | A probe names an endpoint the workload does not declare. | +| `ERR_VERSION_NOT_MONOTONIC` | `capability` | A published component version is not greater than the lineage's current version. | The `parser` and `structural` rows are the shared envelope registry: the [blueprint](../../blueprint/v1/spec.md#diagnostics) and [listing](../../listing/v1/spec.md#diagnostics) families declare themselves -additions to this table rather than restating it. The two `semantic` codes are -this family's own. +additions to this table rather than restating it. The `semantic` and +`capability` rows are this family's own. ## 9. Conformance diff --git a/tools/src/conformance.ts b/tools/src/conformance.ts index 73dde3e..d667787 100644 --- a/tools/src/conformance.ts +++ b/tools/src/conformance.ts @@ -433,6 +433,10 @@ const UNCOVERED: ReadonlyMap = new Map([ 'ERR_UNKNOWN_COMPONENT', 'capability — resolving a published reference needs the catalog, and no phase this repository runs may reach the network', ], + [ + 'ERR_VERSION_NOT_MONOTONIC', + 'capability — comparing a version against the lineage it extends needs the catalog, and a fixture is one document with no previous release to be greater than', + ], ]) /** From 124be344cd57794829d80b77ac9304642d1316b8 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 10:03:21 +0000 Subject: [PATCH 2/2] feat(component): define endpoints, and address the public URL per endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 5.2 was a heading and a TODO, while the endpoint block was already constrained from the outside: §5 forbids endpoints on a WORKER, JOB and CRON, and §5.4 makes readiness REQUIRED for a SERVICE exposing a PUBLIC endpoint. Answers the three questions #16 asks, and the two things §5.2 turned out to owe that #16 does not name. Port range. containerPort is an integer from 1 to 65535, structural. A port below 1024 is a SHOULD NOT and not a rejection: whether the container can bind one depends on a capability the runtime grants, which this document cannot see, and a rule that rejects on a fact it cannot check is guessing. structural-026 is what keeps that a SHOULD NOT rather than drifting into a MUST. Protocol x visibility. A PUBLIC endpoint is published at a URL by something that speaks the HTTP family, so its protocol MUST be HTTP, HTTPS, WS or GRPC and a TCP or UDP endpoint MUST be PRIVATE. §5.4 is the second argument: a probe polls an HTTP path, so a PUBLIC TCP endpoint would compel a readiness probe it has no way to express. Multiple PUBLIC endpoints are permitted, and each publishes its own URL. The consequence is a rule rather than a caveat — anything naming a public address MUST name the endpoint it means — so platformDefault becomes an object carrying `source` and `endpoint` rather than a bare enum. That field had no prose at all before; §6.1 now has it. Note this is not what #16 assumed: capping PUBLIC at one is not expressible structurally, since JSON Schema has no keyword that counts mapping members matching a condition. Two things §5.2 owed and nothing had paid. "The primary endpoint" was used by §5.4 and by ComponentProbe.endpoint and defined nowhere; it is now the sole endpoint, failing that the sole PUBLIC one, failing that nothing — an error rather than a sort-order tiebreak, because a tiebreak lets a new endpoint named `api` silently re-point a probe that already works. And endpoint names were bare mapping keys, so `web.api` was legal despite being referenced by probes and platform defaults and becoming a DNS label; they now take the slug grammar blueprint §4.1 quotes. Three semantic codes, all fixture-covered: ERR_AMBIGUOUS_ENDPOINT, ERR_ENDPOINT_NOT_PUBLIC, and ERR_UNKNOWN_ENDPOINT broadened past probes. checkProbeEndpoints becomes checkEndpointReferences, which walks probes and platform defaults through one resolver. BREAKING CHANGE: bounding containerPort, restricting a PUBLIC endpoint's protocol, constraining endpoint names, and reshaping platformDefault each reject documents that validate today. No v1.0.0 has been published — every family reads 0.0.0 in the release-please manifest and no tag exists — so §3's guarantee has no released version to run from and this lands free. It does not once #1/#2/#3 merge. Refs #16 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- conformance/component/v1/cases.json | 60 +++++++++ .../case.yaml | 33 +++++ .../diagnostics.json | 6 + .../metadata.json | 7 ++ .../case.yaml | 28 +++++ .../diagnostics.json | 6 + .../metadata.json | 7 ++ .../005-no-primary-endpoint-elected/case.yaml | 25 ++++ .../diagnostics.json | 6 + .../metadata.json | 7 ++ .../006-primary-endpoint-elected/case.yaml | 24 ++++ .../metadata.json | 7 ++ .../case.yaml | 17 +++ .../diagnostics.json | 6 + .../metadata.json | 7 ++ .../024-container-port-below-range/case.yaml | 16 +++ .../diagnostics.json | 6 + .../metadata.json | 7 ++ .../025-container-port-above-range/case.yaml | 16 +++ .../diagnostics.json | 6 + .../metadata.json | 7 ++ .../026-privileged-container-port/case.yaml | 18 +++ .../metadata.json | 7 ++ .../027-public-tcp-endpoint/case.yaml | 22 ++++ .../027-public-tcp-endpoint/diagnostics.json | 6 + .../027-public-tcp-endpoint/metadata.json | 7 ++ .../028-public-grpc-endpoint/case.yaml | 21 ++++ .../028-public-grpc-endpoint/metadata.json | 7 ++ .../029-endpoint-name-not-a-slug/case.yaml | 17 +++ .../diagnostics.json | 6 + .../metadata.json | 7 ++ .../030-two-public-endpoints/case.yaml | 36 ++++++ .../030-two-public-endpoints/metadata.json | 7 ++ .../component/v1/examples/multi-endpoint.yaml | 45 +++++++ .../v1/schemas/dist/component.schema.json | 77 ++++++++++-- .../v1/schemas/src/component.schema.json | 77 ++++++++++-- specifications/component/v1/spec.md | 114 +++++++++++++++-- tools/src/semantic.ts | 119 +++++++++++++++--- 38 files changed, 858 insertions(+), 39 deletions(-) create mode 100644 conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/case.yaml create mode 100644 conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/diagnostics.json create mode 100644 conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/metadata.json create mode 100644 conformance/component/v1/semantic/004-platform-default-names-private-endpoint/case.yaml create mode 100644 conformance/component/v1/semantic/004-platform-default-names-private-endpoint/diagnostics.json create mode 100644 conformance/component/v1/semantic/004-platform-default-names-private-endpoint/metadata.json create mode 100644 conformance/component/v1/semantic/005-no-primary-endpoint-elected/case.yaml create mode 100644 conformance/component/v1/semantic/005-no-primary-endpoint-elected/diagnostics.json create mode 100644 conformance/component/v1/semantic/005-no-primary-endpoint-elected/metadata.json create mode 100644 conformance/component/v1/semantic/006-primary-endpoint-elected/case.yaml create mode 100644 conformance/component/v1/semantic/006-primary-endpoint-elected/metadata.json create mode 100644 conformance/component/v1/semantic/007-probe-on-endpointless-workload/case.yaml create mode 100644 conformance/component/v1/semantic/007-probe-on-endpointless-workload/diagnostics.json create mode 100644 conformance/component/v1/semantic/007-probe-on-endpointless-workload/metadata.json create mode 100644 conformance/component/v1/structural/024-container-port-below-range/case.yaml create mode 100644 conformance/component/v1/structural/024-container-port-below-range/diagnostics.json create mode 100644 conformance/component/v1/structural/024-container-port-below-range/metadata.json create mode 100644 conformance/component/v1/structural/025-container-port-above-range/case.yaml create mode 100644 conformance/component/v1/structural/025-container-port-above-range/diagnostics.json create mode 100644 conformance/component/v1/structural/025-container-port-above-range/metadata.json create mode 100644 conformance/component/v1/structural/026-privileged-container-port/case.yaml create mode 100644 conformance/component/v1/structural/026-privileged-container-port/metadata.json create mode 100644 conformance/component/v1/structural/027-public-tcp-endpoint/case.yaml create mode 100644 conformance/component/v1/structural/027-public-tcp-endpoint/diagnostics.json create mode 100644 conformance/component/v1/structural/027-public-tcp-endpoint/metadata.json create mode 100644 conformance/component/v1/structural/028-public-grpc-endpoint/case.yaml create mode 100644 conformance/component/v1/structural/028-public-grpc-endpoint/metadata.json create mode 100644 conformance/component/v1/structural/029-endpoint-name-not-a-slug/case.yaml create mode 100644 conformance/component/v1/structural/029-endpoint-name-not-a-slug/diagnostics.json create mode 100644 conformance/component/v1/structural/029-endpoint-name-not-a-slug/metadata.json create mode 100644 conformance/component/v1/structural/030-two-public-endpoints/case.yaml create mode 100644 conformance/component/v1/structural/030-two-public-endpoints/metadata.json create mode 100644 specifications/component/v1/examples/multi-endpoint.yaml diff --git a/conformance/component/v1/cases.json b/conformance/component/v1/cases.json index 6d45beb..e52f0bb 100644 --- a/conformance/component/v1/cases.json +++ b/conformance/component/v1/cases.json @@ -137,6 +137,41 @@ "phase": "structural", "path": "structural/023-field-from-newer-release" }, + { + "id": "structural-024-container-port-below-range", + "phase": "structural", + "path": "structural/024-container-port-below-range" + }, + { + "id": "structural-025-container-port-above-range", + "phase": "structural", + "path": "structural/025-container-port-above-range" + }, + { + "id": "structural-026-privileged-container-port", + "phase": "structural", + "path": "structural/026-privileged-container-port" + }, + { + "id": "structural-027-public-tcp-endpoint", + "phase": "structural", + "path": "structural/027-public-tcp-endpoint" + }, + { + "id": "structural-028-public-grpc-endpoint", + "phase": "structural", + "path": "structural/028-public-grpc-endpoint" + }, + { + "id": "structural-029-endpoint-name-not-a-slug", + "phase": "structural", + "path": "structural/029-endpoint-name-not-a-slug" + }, + { + "id": "structural-030-two-public-endpoints", + "phase": "structural", + "path": "structural/030-two-public-endpoints" + }, { "id": "semantic-001-floating-tag-image-reference", "phase": "semantic", @@ -146,6 +181,31 @@ "id": "semantic-002-probe-names-unknown-endpoint", "phase": "semantic", "path": "semantic/002-probe-names-unknown-endpoint" + }, + { + "id": "semantic-003-platform-default-names-unknown-endpoint", + "phase": "semantic", + "path": "semantic/003-platform-default-names-unknown-endpoint" + }, + { + "id": "semantic-004-platform-default-names-private-endpoint", + "phase": "semantic", + "path": "semantic/004-platform-default-names-private-endpoint" + }, + { + "id": "semantic-005-no-primary-endpoint-elected", + "phase": "semantic", + "path": "semantic/005-no-primary-endpoint-elected" + }, + { + "id": "semantic-006-primary-endpoint-elected", + "phase": "semantic", + "path": "semantic/006-primary-endpoint-elected" + }, + { + "id": "semantic-007-probe-on-endpointless-workload", + "phase": "semantic", + "path": "semantic/007-probe-on-endpointless-workload" } ] } diff --git a/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/case.yaml b/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/case.yaml new file mode 100644 index 0000000..3855e47 --- /dev/null +++ b/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/case.yaml @@ -0,0 +1,33 @@ +# The platform default derives from "console", and the only endpoint is "api". +# JSON Schema cannot catch it: the endpoint names are mapping keys elsewhere +# in the document. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + api: + containerPort: 8080 + protocol: HTTP + visibility: PUBLIC + health: + readiness: + path: /healthz + endpoint: api + contract: + inputs: + consoleUrl: + schema: + type: STRING + platformDefault: + source: PUBLIC_URL + endpoint: console + ui: + label: Console URL + outputs: {} diff --git a/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/diagnostics.json b/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/diagnostics.json new file mode 100644 index 0000000..068a4ad --- /dev/null +++ b/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNKNOWN_ENDPOINT", + "path": "/spec/contract/inputs/consoleUrl/platformDefault/endpoint" + } +] diff --git a/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/metadata.json b/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/metadata.json new file mode 100644 index 0000000..ea2fe00 --- /dev/null +++ b/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-003-platform-default-names-unknown-endpoint", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#inputs", + "summary": "A platform default naming an endpoint the workload does not declare is rejected." +} diff --git a/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/case.yaml b/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/case.yaml new file mode 100644 index 0000000..8dcc154 --- /dev/null +++ b/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/case.yaml @@ -0,0 +1,28 @@ +# PUBLIC_URL derives an externally reachable address, and a PRIVATE endpoint +# has none to give. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + internal: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE + contract: + inputs: + selfUrl: + schema: + type: STRING + platformDefault: + source: PUBLIC_URL + endpoint: internal + ui: + label: Own URL + outputs: {} diff --git a/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/diagnostics.json b/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/diagnostics.json new file mode 100644 index 0000000..80d36e0 --- /dev/null +++ b/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_ENDPOINT_NOT_PUBLIC", + "path": "/spec/contract/inputs/selfUrl/platformDefault/endpoint" + } +] diff --git a/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/metadata.json b/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/metadata.json new file mode 100644 index 0000000..d2efe6c --- /dev/null +++ b/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-004-platform-default-names-private-endpoint", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#inputs", + "summary": "A platform default deriving a public address from a PRIVATE endpoint is rejected." +} diff --git a/conformance/component/v1/semantic/005-no-primary-endpoint-elected/case.yaml b/conformance/component/v1/semantic/005-no-primary-endpoint-elected/case.yaml new file mode 100644 index 0000000..48a8a2a --- /dev/null +++ b/conformance/component/v1/semantic/005-no-primary-endpoint-elected/case.yaml @@ -0,0 +1,25 @@ +# Two PUBLIC endpoints, and the probe names neither. Section 5.2 elects no +# primary here and the reference is rejected rather than resolved by sort +# order, which would let a new endpoint silently re-point this probe. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + api: + containerPort: 8080 + protocol: HTTP + visibility: PUBLIC + console: + containerPort: 8081 + protocol: HTTP + visibility: PUBLIC + health: + readiness: + path: /healthz diff --git a/conformance/component/v1/semantic/005-no-primary-endpoint-elected/diagnostics.json b/conformance/component/v1/semantic/005-no-primary-endpoint-elected/diagnostics.json new file mode 100644 index 0000000..5c6aeaf --- /dev/null +++ b/conformance/component/v1/semantic/005-no-primary-endpoint-elected/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_AMBIGUOUS_ENDPOINT", + "path": "/spec/workload/health/readiness/endpoint" + } +] diff --git a/conformance/component/v1/semantic/005-no-primary-endpoint-elected/metadata.json b/conformance/component/v1/semantic/005-no-primary-endpoint-elected/metadata.json new file mode 100644 index 0000000..616e251 --- /dev/null +++ b/conformance/component/v1/semantic/005-no-primary-endpoint-elected/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-005-no-primary-endpoint-elected", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#endpoints", + "summary": "A reference omitting the endpoint is rejected where the workload elects no primary." +} diff --git a/conformance/component/v1/semantic/006-primary-endpoint-elected/case.yaml b/conformance/component/v1/semantic/006-primary-endpoint-elected/case.yaml new file mode 100644 index 0000000..ab2b96a --- /dev/null +++ b/conformance/component/v1/semantic/006-primary-endpoint-elected/case.yaml @@ -0,0 +1,24 @@ +# One PUBLIC endpoint beside a PRIVATE one. The sole PUBLIC endpoint is the +# primary, so the probe naming none resolves to "web". +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PUBLIC + metrics: + containerPort: 9090 + protocol: HTTP + visibility: PRIVATE + health: + readiness: + path: /healthz diff --git a/conformance/component/v1/semantic/006-primary-endpoint-elected/metadata.json b/conformance/component/v1/semantic/006-primary-endpoint-elected/metadata.json new file mode 100644 index 0000000..e26b4ed --- /dev/null +++ b/conformance/component/v1/semantic/006-primary-endpoint-elected/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-006-primary-endpoint-elected", + "phase": "semantic", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#endpoints", + "summary": "The sole PUBLIC endpoint is the primary a probe naming none resolves to." +} diff --git a/conformance/component/v1/semantic/007-probe-on-endpointless-workload/case.yaml b/conformance/component/v1/semantic/007-probe-on-endpointless-workload/case.yaml new file mode 100644 index 0000000..f39eff4 --- /dev/null +++ b/conformance/component/v1/semantic/007-probe-on-endpointless-workload/case.yaml @@ -0,0 +1,17 @@ +# Section 5 permits a SERVICE to declare no endpoint. A probe on one polls a +# port that does not exist: the election finds no primary here for the +# opposite reason it finds none among two PUBLIC endpoints, and rejects it +# just the same. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + health: + liveness: + path: /healthz diff --git a/conformance/component/v1/semantic/007-probe-on-endpointless-workload/diagnostics.json b/conformance/component/v1/semantic/007-probe-on-endpointless-workload/diagnostics.json new file mode 100644 index 0000000..eebe861 --- /dev/null +++ b/conformance/component/v1/semantic/007-probe-on-endpointless-workload/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_AMBIGUOUS_ENDPOINT", + "path": "/spec/workload/health/liveness/endpoint" + } +] diff --git a/conformance/component/v1/semantic/007-probe-on-endpointless-workload/metadata.json b/conformance/component/v1/semantic/007-probe-on-endpointless-workload/metadata.json new file mode 100644 index 0000000..322b821 --- /dev/null +++ b/conformance/component/v1/semantic/007-probe-on-endpointless-workload/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-007-probe-on-endpointless-workload", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#endpoints", + "summary": "A probe on a workload declaring no endpoint has no port to poll." +} diff --git a/conformance/component/v1/structural/024-container-port-below-range/case.yaml b/conformance/component/v1/structural/024-container-port-below-range/case.yaml new file mode 100644 index 0000000..5798ff4 --- /dev/null +++ b/conformance/component/v1/structural/024-container-port-below-range/case.yaml @@ -0,0 +1,16 @@ +# Port 0 binds nothing. The bound is a grammar, so the schema carries it. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 0 + protocol: HTTP + visibility: PRIVATE diff --git a/conformance/component/v1/structural/024-container-port-below-range/diagnostics.json b/conformance/component/v1/structural/024-container-port-below-range/diagnostics.json new file mode 100644 index 0000000..2bc7a78 --- /dev/null +++ b/conformance/component/v1/structural/024-container-port-below-range/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/workload/endpoints/web/containerPort" + } +] diff --git a/conformance/component/v1/structural/024-container-port-below-range/metadata.json b/conformance/component/v1/structural/024-container-port-below-range/metadata.json new file mode 100644 index 0000000..7bf5c73 --- /dev/null +++ b/conformance/component/v1/structural/024-container-port-below-range/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-024-container-port-below-range", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#endpoints", + "summary": "A containerPort below 1 is rejected." +} diff --git a/conformance/component/v1/structural/025-container-port-above-range/case.yaml b/conformance/component/v1/structural/025-container-port-above-range/case.yaml new file mode 100644 index 0000000..eb63685 --- /dev/null +++ b/conformance/component/v1/structural/025-container-port-above-range/case.yaml @@ -0,0 +1,16 @@ +# 65535 is the last port there is; 65536 names one that cannot exist. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 65536 + protocol: HTTP + visibility: PRIVATE diff --git a/conformance/component/v1/structural/025-container-port-above-range/diagnostics.json b/conformance/component/v1/structural/025-container-port-above-range/diagnostics.json new file mode 100644 index 0000000..2bc7a78 --- /dev/null +++ b/conformance/component/v1/structural/025-container-port-above-range/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/workload/endpoints/web/containerPort" + } +] diff --git a/conformance/component/v1/structural/025-container-port-above-range/metadata.json b/conformance/component/v1/structural/025-container-port-above-range/metadata.json new file mode 100644 index 0000000..40bc6db --- /dev/null +++ b/conformance/component/v1/structural/025-container-port-above-range/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-025-container-port-above-range", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#endpoints", + "summary": "A containerPort above 65535 is rejected." +} diff --git a/conformance/component/v1/structural/026-privileged-container-port/case.yaml b/conformance/component/v1/structural/026-privileged-container-port/case.yaml new file mode 100644 index 0000000..846a651 --- /dev/null +++ b/conformance/component/v1/structural/026-privileged-container-port/case.yaml @@ -0,0 +1,18 @@ +# A port below 1024 is discouraged and not rejected: whether the container can +# bind one depends on a capability the runtime grants, which this document +# cannot see. This case is what keeps that a SHOULD NOT. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 80 + protocol: HTTP + visibility: PRIVATE diff --git a/conformance/component/v1/structural/026-privileged-container-port/metadata.json b/conformance/component/v1/structural/026-privileged-container-port/metadata.json new file mode 100644 index 0000000..6c073f0 --- /dev/null +++ b/conformance/component/v1/structural/026-privileged-container-port/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-026-privileged-container-port", + "phase": "structural", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#endpoints", + "summary": "A privileged containerPort is discouraged but validates." +} diff --git a/conformance/component/v1/structural/027-public-tcp-endpoint/case.yaml b/conformance/component/v1/structural/027-public-tcp-endpoint/case.yaml new file mode 100644 index 0000000..9526c5e --- /dev/null +++ b/conformance/component/v1/structural/027-public-tcp-endpoint/case.yaml @@ -0,0 +1,22 @@ +# A PUBLIC endpoint is published at a URL by something that speaks the HTTP +# family, and TCP has no URL to publish. Readiness is present so the case +# fails on the protocol rule alone. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + db: + containerPort: 5432 + protocol: TCP + visibility: PUBLIC + health: + readiness: + path: /healthz + endpoint: db diff --git a/conformance/component/v1/structural/027-public-tcp-endpoint/diagnostics.json b/conformance/component/v1/structural/027-public-tcp-endpoint/diagnostics.json new file mode 100644 index 0000000..a531e51 --- /dev/null +++ b/conformance/component/v1/structural/027-public-tcp-endpoint/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/workload/endpoints/db/protocol" + } +] diff --git a/conformance/component/v1/structural/027-public-tcp-endpoint/metadata.json b/conformance/component/v1/structural/027-public-tcp-endpoint/metadata.json new file mode 100644 index 0000000..ee2a2ed --- /dev/null +++ b/conformance/component/v1/structural/027-public-tcp-endpoint/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-027-public-tcp-endpoint", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#endpoints", + "summary": "A TCP endpoint may not be PUBLIC." +} diff --git a/conformance/component/v1/structural/028-public-grpc-endpoint/case.yaml b/conformance/component/v1/structural/028-public-grpc-endpoint/case.yaml new file mode 100644 index 0000000..b3ce514 --- /dev/null +++ b/conformance/component/v1/structural/028-public-grpc-endpoint/case.yaml @@ -0,0 +1,21 @@ +# GRPC rides on HTTP/2, so it is one of the four protocols a PUBLIC endpoint +# may serve. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + rpc: + containerPort: 8080 + protocol: GRPC + visibility: PUBLIC + health: + readiness: + path: /healthz + endpoint: rpc diff --git a/conformance/component/v1/structural/028-public-grpc-endpoint/metadata.json b/conformance/component/v1/structural/028-public-grpc-endpoint/metadata.json new file mode 100644 index 0000000..85a6938 --- /dev/null +++ b/conformance/component/v1/structural/028-public-grpc-endpoint/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-028-public-grpc-endpoint", + "phase": "structural", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#endpoints", + "summary": "GRPC is one of the protocols a PUBLIC endpoint may serve." +} diff --git a/conformance/component/v1/structural/029-endpoint-name-not-a-slug/case.yaml b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/case.yaml new file mode 100644 index 0000000..fc4a46a --- /dev/null +++ b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/case.yaml @@ -0,0 +1,17 @@ +# An endpoint name is referenced by probes and by platform defaults, and +# becomes a DNS label. A dot in one is a hazard rather than a matter of taste. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + web.api: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE diff --git a/conformance/component/v1/structural/029-endpoint-name-not-a-slug/diagnostics.json b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/diagnostics.json new file mode 100644 index 0000000..0682289 --- /dev/null +++ b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/workload/endpoints" + } +] diff --git a/conformance/component/v1/structural/029-endpoint-name-not-a-slug/metadata.json b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/metadata.json new file mode 100644 index 0000000..fd05591 --- /dev/null +++ b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-029-endpoint-name-not-a-slug", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#endpoints", + "summary": "An endpoint name outside the slug grammar is rejected." +} diff --git a/conformance/component/v1/structural/030-two-public-endpoints/case.yaml b/conformance/component/v1/structural/030-two-public-endpoints/case.yaml new file mode 100644 index 0000000..82ba1f9 --- /dev/null +++ b/conformance/component/v1/structural/030-two-public-endpoints/case.yaml @@ -0,0 +1,36 @@ +# A component may front two public URLs. Each is addressed by name, which is +# what makes the second one no harder than the first. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + api: + containerPort: 8080 + protocol: HTTP + visibility: PUBLIC + console: + containerPort: 8081 + protocol: HTTP + visibility: PUBLIC + health: + readiness: + path: /healthz + endpoint: api + contract: + inputs: + consoleUrl: + schema: + type: STRING + platformDefault: + source: PUBLIC_URL + endpoint: console + ui: + label: Console URL + outputs: {} diff --git a/conformance/component/v1/structural/030-two-public-endpoints/metadata.json b/conformance/component/v1/structural/030-two-public-endpoints/metadata.json new file mode 100644 index 0000000..7697ef9 --- /dev/null +++ b/conformance/component/v1/structural/030-two-public-endpoints/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-030-two-public-endpoints", + "phase": "structural", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#endpoints", + "summary": "Two PUBLIC endpoints validate when each public address names one." +} diff --git a/specifications/component/v1/examples/multi-endpoint.yaml b/specifications/component/v1/examples/multi-endpoint.yaml new file mode 100644 index 0000000..3671d22 --- /dev/null +++ b/specifications/component/v1/examples/multi-endpoint.yaml @@ -0,0 +1,45 @@ +# yaml-language-server: $schema=https://schemas.musher.dev/component/v1/component.schema.json +# +# A service fronting two public URLs and one private port. Because more than +# one endpoint is PUBLIC, nothing here may say "the component's URL" — the +# readiness probe and the platform default each name the endpoint they mean. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + api: + containerPort: 8080 + protocol: HTTP + visibility: PUBLIC + console: + containerPort: 8081 + protocol: HTTP + visibility: PUBLIC + metrics: + containerPort: 9090 + protocol: HTTP + visibility: PRIVATE + health: + readiness: + path: /healthz + endpoint: api + contract: + inputs: + consoleUrl: + schema: + type: STRING + platformDefault: + source: PUBLIC_URL + endpoint: console + ui: + label: Console URL + target: + envVarKey: CONSOLE_URL + outputs: {} diff --git a/specifications/component/v1/schemas/dist/component.schema.json b/specifications/component/v1/schemas/dist/component.schema.json index a42462d..055178d 100644 --- a/specifications/component/v1/schemas/dist/component.schema.json +++ b/specifications/component/v1/schemas/dist/component.schema.json @@ -125,9 +125,38 @@ }, "ComponentEndpoint": { "additionalProperties": false, + "allOf": [ + { + "$comment": "spec.md §5.2. A PUBLIC endpoint is published at a URL by something that speaks the HTTP family, so TCP and UDP have no URL to publish. A bare conditional carrying no `type` declares no properties of its own, so lint.ts checkClosedObjects correctly skips it.", + "if": { + "properties": { + "visibility": { + "const": "PUBLIC" + } + }, + "required": [ + "visibility" + ] + }, + "then": { + "properties": { + "protocol": { + "enum": [ + "HTTP", + "HTTPS", + "WS", + "GRPC" + ] + } + } + } + } + ], "properties": { "containerPort": { - "description": "Port the workload listens on inside the container.", + "description": "Port the workload listens on inside the container. Below 1024 needs a capability the runtime grants, so spec.md §5.2 discourages it without rejecting it.", + "maximum": 65535, + "minimum": 1, "type": "integer" }, "protocol": { @@ -415,14 +444,10 @@ "type": "boolean" }, "platformDefault": { - "description": "Platform-derived default bound into the target env var — PUBLIC_HOSTNAME injects the component's own public endpoint hostname, PUBLIC_URL the full https URL of that endpoint; null when the value is user-supplied, wired, or generated.", + "description": "Default derived from the component's own addressing and bound into the target env var; null when the value is user-supplied, wired, or generated.", "anyOf": [ { - "enum": [ - "PUBLIC_HOSTNAME", - "PUBLIC_URL" - ], - "type": "string" + "$ref": "#/$defs/ComponentPlatformDefault" }, { "type": "null" @@ -619,13 +644,45 @@ "type": "object", "x-additionalPropertiesName": "outputName" }, + "ComponentPlatformDefault": { + "description": "Default derived from the component's own public addressing. Which endpoint it derives from is named rather than assumed, because spec.md §5.2 permits more than one PUBLIC endpoint.", + "additionalProperties": false, + "properties": { + "endpoint": { + "description": "Name of the PUBLIC endpoint the value is derived from. Null selects the primary endpoint spec.md §5.2 elects.", + "anyOf": [ + { + "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "source": { + "description": "What is derived: PUBLIC_HOSTNAME for the endpoint's hostname alone, PUBLIC_URL for its full URL.", + "enum": [ + "PUBLIC_HOSTNAME", + "PUBLIC_URL" + ], + "type": "string" + } + }, + "required": [ + "source" + ], + "type": "object" + }, "ComponentProbe": { "additionalProperties": false, "properties": { "endpoint": { - "description": "Name of the endpoint whose port the probe targets. Null selects the primary endpoint.", + "description": "Name of the endpoint whose port the probe targets. Null selects the primary endpoint spec.md §5.2 elects.", "anyOf": [ { + "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$", "type": "string" }, { @@ -1008,6 +1065,10 @@ "additionalProperties": { "$ref": "#/$defs/ComponentEndpoint" }, + "propertyNames": { + "$comment": "spec.md §5.2. An endpoint name is referenced by probes and by platform defaults, and becomes a DNS label, so it takes the slug grammar rather than an arbitrary key.", + "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$" + }, "type": "object" }, "envVars": { diff --git a/specifications/component/v1/schemas/src/component.schema.json b/specifications/component/v1/schemas/src/component.schema.json index e14b4cc..5f1df05 100644 --- a/specifications/component/v1/schemas/src/component.schema.json +++ b/specifications/component/v1/schemas/src/component.schema.json @@ -125,9 +125,38 @@ }, "ComponentEndpoint": { "additionalProperties": false, + "allOf": [ + { + "$comment": "spec.md §5.2. A PUBLIC endpoint is published at a URL by something that speaks the HTTP family, so TCP and UDP have no URL to publish. A bare conditional carrying no `type` declares no properties of its own, so lint.ts checkClosedObjects correctly skips it.", + "if": { + "properties": { + "visibility": { + "const": "PUBLIC" + } + }, + "required": [ + "visibility" + ] + }, + "then": { + "properties": { + "protocol": { + "enum": [ + "HTTP", + "HTTPS", + "WS", + "GRPC" + ] + } + } + } + } + ], "properties": { "containerPort": { - "description": "Port the workload listens on inside the container.", + "description": "Port the workload listens on inside the container. Below 1024 needs a capability the runtime grants, so spec.md §5.2 discourages it without rejecting it.", + "maximum": 65535, + "minimum": 1, "type": "integer" }, "protocol": { @@ -417,18 +446,14 @@ "platformDefault": { "anyOf": [ { - "enum": [ - "PUBLIC_HOSTNAME", - "PUBLIC_URL" - ], - "type": "string" + "$ref": "#/$defs/ComponentPlatformDefault" }, { "type": "null" } ], "default": null, - "description": "Platform-derived default bound into the target env var — PUBLIC_HOSTNAME injects the component's own public endpoint hostname, PUBLIC_URL the full https URL of that endpoint; null when the value is user-supplied, wired, or generated." + "description": "Default derived from the component's own addressing and bound into the target env var; null when the value is user-supplied, wired, or generated." }, "schema": { "$ref": "#/$defs/ComponentValueSchema", @@ -619,12 +644,44 @@ "type": "object", "x-additionalPropertiesName": "outputName" }, + "ComponentPlatformDefault": { + "additionalProperties": false, + "description": "Default derived from the component's own public addressing. Which endpoint it derives from is named rather than assumed, because spec.md §5.2 permits more than one PUBLIC endpoint.", + "properties": { + "endpoint": { + "anyOf": [ + { + "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of the PUBLIC endpoint the value is derived from. Null selects the primary endpoint spec.md §5.2 elects." + }, + "source": { + "description": "What is derived: PUBLIC_HOSTNAME for the endpoint's hostname alone, PUBLIC_URL for its full URL.", + "enum": [ + "PUBLIC_HOSTNAME", + "PUBLIC_URL" + ], + "type": "string" + } + }, + "required": [ + "source" + ], + "type": "object" + }, "ComponentProbe": { "additionalProperties": false, "properties": { "endpoint": { "anyOf": [ { + "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$", "type": "string" }, { @@ -632,7 +689,7 @@ } ], "default": null, - "description": "Name of the endpoint whose port the probe targets. Null selects the primary endpoint." + "description": "Name of the endpoint whose port the probe targets. Null selects the primary endpoint spec.md §5.2 elects." }, "failureThreshold": { "default": 3, @@ -1008,6 +1065,10 @@ "$ref": "#/$defs/ComponentEndpoint" }, "description": "Endpoints the workload exposes, keyed by endpoint name. Only a SERVICE may declare one; on any other kind the mapping must be absent or empty.", + "propertyNames": { + "$comment": "spec.md §5.2. An endpoint name is referenced by probes and by platform defaults, and becomes a DNS label, so it takes the slug grammar rather than an arbitrary key.", + "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$" + }, "type": "object" }, "envVars": { diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md index 5cf092b..cedab2c 100644 --- a/specifications/component/v1/spec.md +++ b/specifications/component/v1/spec.md @@ -261,8 +261,82 @@ resolves. The floating-tag rule applies only to a reference carrying no digest. ### 5.2 Endpoints -> **TODO** — Port range, protocol/visibility interaction, and whether more than -> one `PUBLIC` endpoint is permitted. +`endpoints` is a mapping from endpoint name to one port the workload listens +on. `containerPort`, `protocol` and `visibility` are all REQUIRED — an endpoint +missing any of them describes a port nothing can route to. Only a `SERVICE` may +declare one; [§5](#workload) carries the rest of that rule. + +**An endpoint name is a reference.** A name MUST match +`^[a-z][a-z0-9-]{0,61}[a-z0-9]$` — the grammar +[blueprint §4.1](../../blueprint/v1/spec.md#component-reference) quotes for a +slug — and one that does not is rejected in the `structural` phase with +`ERR_INVALID_VALUE`. The name is not decoration: [§5.4](#health) points a probe +at one and [§6.1](#inputs) derives an address from one, so a dot or a space in +a name is a hazard rather than a matter of taste. The 63-character bound is a +DNS label, which is what the name becomes. + +**`containerPort` is an integer from 1 to 65535.** Outside that range there is +no port to bind. The bound is `structural` and carries `ERR_INVALID_VALUE`. + +A port below 1024 SHOULD NOT be used. Binding one needs a capability the runtime +grants to the container, and a workload that does not hold it fails at deploy +time with nothing in the document to blame. It stays advice rather than a +rejection because whether that grant exists is a fact about the runtime this +document cannot see, and a rule that rejects on a fact it cannot check is +guessing. Making it a MUST later rejects documents v1 accepts, and is therefore +breaking. + +**A `PUBLIC` endpoint is reached over HTTP.** `visibility: PUBLIC` publishes the +endpoint at an externally reachable URL, and what publishes it speaks the HTTP +family. `protocol` on a `PUBLIC` endpoint MUST therefore be one of `HTTP`, +`HTTPS`, `WS` or `GRPC`; a `TCP` or `UDP` endpoint MUST be `PRIVATE`. The rule +is `structural` and carries `ERR_INVALID_VALUE`. + +[§5.4](#health) is the second argument for it. A probe polls an HTTP path, and +`readiness` is REQUIRED for a `SERVICE` exposing a `PUBLIC` endpoint — so a +`PUBLIC` `TCP` endpoint would compel a probe it has no way to express. + +**A component MAY declare more than one `PUBLIC` endpoint**, and each one +publishes its own URL. A component fronting an API on one port and a console on +another is one component rather than two, and nothing about routing the second +is harder than routing the first. + +The consequence is a rule and not a caveat: **anything naming a public address +MUST name the endpoint it means.** Where two exist there is no such thing as +"the component's URL". [§6.1](#inputs) is where that bites, and where the +selector lives. + +**The primary endpoint.** A reference MAY omit the endpoint it targets — a +probe's `endpoint` and a platform default's both admit null — and the primary +endpoint is what null selects. It is: + +1. the workload's sole endpoint, where it declares exactly one; failing that +2. its sole `PUBLIC` endpoint, where it declares exactly one; failing that +3. nothing. + +Where it is nothing, a reference that omits the endpoint is rejected in the +`semantic` phase with `ERR_AMBIGUOUS_ENDPOINT`. The schema cannot express this +for the reason [§5.4](#health) gives: the endpoint names are mapping keys +elsewhere in the document. + +"Nothing" is reached two ways and both are rejected, though they read +differently to an author. A workload declaring several candidates has too many +and must choose. A workload declaring no endpoint at all has none, and a probe +on it polls a port that does not exist — which [§5](#workload) permits the +workload to be, since a `SERVICE` MAY declare no endpoint, but which no probe +can survive. + +**Why that is an error rather than a tiebreak.** Electing the first name in sort +order would give every document an answer, and would let a new endpoint called +`api` silently re-point a probe that has worked for a year. A rule that changes +what an unedited line means is the failure [§2](#envelope) rejects a misspelled +optional field to avoid. + +**What v1 does not constrain.** Two endpoints MAY declare the same +`containerPort`, and nothing says which of them anything routing to that port +should believe. That silence is a gap rather than a considered permission, and +is recorded here so a reader can tell the two apart. Closing it rejects +documents that validate today. ### 5.3 Environment variables @@ -285,10 +359,12 @@ polls an HTTP path. `successThreshold: 1`, `failureThreshold: 3`. `endpoint` names the endpoint whose port the probe targets; null selects the -primary endpoint. A probe naming an endpoint the workload does not declare is -rejected in the `semantic` phase with `ERR_UNKNOWN_ENDPOINT`. The schema cannot -express it — the endpoint names are mapping keys elsewhere in the document, and -JSON Schema cannot constrain a value against a sibling's keys. +primary endpoint [§5.2](#endpoints) elects, and is rejected with +`ERR_AMBIGUOUS_ENDPOINT` where that section elects none. A probe naming an +endpoint the workload does not declare is rejected with `ERR_UNKNOWN_ENDPOINT`. +Both are `semantic`, and the schema can express neither — the endpoint names are +mapping keys elsewhere in the document, and JSON Schema cannot constrain a value +against a sibling's keys. **`readiness` is REQUIRED for a `SERVICE` exposing at least one `PUBLIC` endpoint**, and OPTIONAL everywhere else, including on a `SERVICE` whose @@ -351,6 +427,28 @@ marked sensitive is echoed back into logs and interfaces. The value rides on a `USER` input because that is the slot the install form already reserves for it — the user simply does not have to type it. +**A platform default derives the value from the component's own addressing.** +`platformDefault` is OPTIONAL and null by default. Where it is present, `source` +is REQUIRED and selects what is derived — `PUBLIC_URL` for the full URL of a +public endpoint, `PUBLIC_HOSTNAME` for its hostname alone — and `endpoint` names +which endpoint it is derived from. + +`endpoint` is null by default and selects the primary endpoint +[§5.2](#endpoints) elects. Since a component MAY expose several `PUBLIC` +endpoints, one that does MUST name the endpoint here: null elects nothing there +and is rejected with `ERR_AMBIGUOUS_ENDPOINT`. + +Two further rules follow the name, both `semantic`. An endpoint the workload +does not declare is `ERR_UNKNOWN_ENDPOINT` — the same code and the same reason +as a probe's. An endpoint that is declared but `PRIVATE` is +`ERR_ENDPOINT_NOT_PUBLIC`: both sources derive an externally reachable address, +and a `PRIVATE` endpoint has none to give. + +**A platform default is not a `CONNECTION`.** The value comes from the +component's own workload, never from an upstream node, which is the same line +[§6.2](#outputs) draws around an output. `suppliedBy` is unconstrained by +`platformDefault` in v1 — a gap, recorded rather than described as a decision. + ### 6.2 Outputs `schema` and `valueFrom` are REQUIRED. @@ -432,7 +530,9 @@ different text and that is expected. | `ERR_INVALID_TYPE` | `structural` | A value has the wrong type. | | `ERR_INVALID_VALUE` | `structural` | A value violates a pattern, enum, or bound. | | `ERR_UNPINNED_IMAGE` | `semantic` | An image reference carries a floating tag. | -| `ERR_UNKNOWN_ENDPOINT` | `semantic` | A probe names an endpoint the workload does not declare. | +| `ERR_UNKNOWN_ENDPOINT` | `semantic` | A probe or a platform default names an endpoint the workload does not declare. | +| `ERR_AMBIGUOUS_ENDPOINT` | `semantic` | A reference omits the endpoint, and the workload elects no primary. | +| `ERR_ENDPOINT_NOT_PUBLIC` | `semantic` | A platform default deriving a public address names a `PRIVATE` endpoint. | | `ERR_VERSION_NOT_MONOTONIC` | `capability` | A published component version is not greater than the lineage's current version. | The `parser` and `structural` rows are the shared envelope registry: the diff --git a/tools/src/semantic.ts b/tools/src/semantic.ts index 3522a4a..b13f4c9 100644 --- a/tools/src/semantic.ts +++ b/tools/src/semantic.ts @@ -113,26 +113,113 @@ function checkImageRef(document: Json, out: Diagnostic[]): void { } /** - * Component §5.4 — a probe's `endpoint` MUST name a declared endpoint. JSON - * Schema cannot: the names are mapping keys elsewhere in the document, and no - * keyword constrains a value against a sibling's keys. + * Component §5.2 — the endpoint a null reference selects. The sole endpoint + * where the workload declares exactly one, failing that its sole PUBLIC one, + * failing that nothing. + * + * §5.2 chose "nothing" over a sort-order tiebreak deliberately: a tiebreak + * lets a new endpoint named `api` silently re-point a probe that already works. */ -function checkProbeEndpoints(document: Json, out: Diagnostic[]): void { - const workload = child(child(document, 'spec'), 'workload') - const health = child(workload, 'health') - if (!isObject(health)) return +function primaryEndpoint(endpoints: Json | undefined): string | undefined { + const names = keysOf(endpoints) + if (names.length === 1) return names[0] + + const publicNames = names.filter( + (name) => child(child(endpoints, name), 'visibility') === 'PUBLIC', + ) + return publicNames.length === 1 ? publicNames[0] : undefined +} - const declared = new Set(keysOf(child(workload, 'endpoints'))) - for (const probe of keysOf(health)) { - // null selects the primary endpoint, so only a named one can be wrong. - const endpoint = asString(child(child(health, probe), 'endpoint')) - if (endpoint !== undefined && !declared.has(endpoint)) { +/** + * One place a document names an endpoint: a probe's `endpoint` (§5.4) or a + * platform default's (§6.1). `mustBePublic` is what separates them — both + * platform-default sources derive an externally reachable address. + */ +interface EndpointReference { + /** The raw value, so an explicit null and an absent key are one case. */ + readonly value: Json | undefined + readonly path: string + readonly subject: string + readonly mustBePublic: boolean +} + +/** + * Component §5.2, §5.4 and §6.1. JSON Schema can express none of this: the + * endpoint names are mapping keys elsewhere in the document, and no keyword + * constrains a value against a sibling's keys. + */ +function checkEndpointReference( + reference: EndpointReference, + endpoints: Json | undefined, + out: Diagnostic[], +): void { + const named = asString(reference.value) + + // Absent or null both select the primary, which §5.2 may elect to be nothing. + if (named === undefined) { + if (primaryEndpoint(endpoints) === undefined) { out.push({ - code: 'ERR_UNKNOWN_ENDPOINT', - path: `/spec/workload/health/${token(probe)}/endpoint`, - message: `probe targets endpoint "${endpoint}", which the workload does not declare`, + code: 'ERR_AMBIGUOUS_ENDPOINT', + path: reference.path, + message: `${reference.subject} names no endpoint, and the workload elects no primary`, }) } + return + } + + const declared = child(endpoints, named) + if (declared === undefined) { + out.push({ + code: 'ERR_UNKNOWN_ENDPOINT', + path: reference.path, + message: `${reference.subject} targets endpoint "${named}", which the workload does not declare`, + }) + return + } + + if (reference.mustBePublic && child(declared, 'visibility') !== 'PUBLIC') { + out.push({ + code: 'ERR_ENDPOINT_NOT_PUBLIC', + path: reference.path, + message: `${reference.subject} derives a public address from endpoint "${named}", which is PRIVATE`, + }) + } +} + +/** Every endpoint a component document names, in document order. */ +function endpointReferences(document: Json): EndpointReference[] { + const spec = child(document, 'spec') + const health = child(child(spec, 'workload'), 'health') + const inputs = child(child(spec, 'contract'), 'inputs') + const references: EndpointReference[] = [] + + for (const probe of keysOf(health)) { + references.push({ + value: child(child(health, probe), 'endpoint'), + path: `/spec/workload/health/${token(probe)}/endpoint`, + subject: `${probe} probe`, + mustBePublic: false, + }) + } + + for (const input of keysOf(inputs)) { + const platformDefault = child(child(inputs, input), 'platformDefault') + if (!isObject(platformDefault)) continue + references.push({ + value: child(platformDefault, 'endpoint'), + path: `/spec/contract/inputs/${token(input)}/platformDefault/endpoint`, + subject: `platform default on input "${input}"`, + mustBePublic: true, + }) + } + + return references +} + +function checkEndpointReferences(document: Json, out: Diagnostic[]): void { + const endpoints = child(child(child(document, 'spec'), 'workload'), 'endpoints') + for (const reference of endpointReferences(document)) { + checkEndpointReference(reference, endpoints, out) } } @@ -638,7 +725,7 @@ export function semanticDiagnostics( if (family.name === 'component') { checkImageRef(document, out) - checkProbeEndpoints(document, out) + checkEndpointReferences(document, out) } if (family.name === 'blueprint') { checkConnectionRoles(document, out)