diff --git a/docs/adr/model/0064-sidecars-are-workload-vocabulary.md b/docs/adr/model/0064-sidecars-are-workload-vocabulary.md index b396bf3..37b7cc8 100644 --- a/docs/adr/model/0064-sidecars-are-workload-vocabulary.md +++ b/docs/adr/model/0064-sidecars-are-workload-vocabulary.md @@ -3,7 +3,7 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#workload +normative: spec/v1/10-service-intent.md#sidecars rests-on: ["0005"] --- diff --git a/spec/v1/10-service-intent.md b/spec/v1/10-service-intent.md index 0b99a9d..0394de4 100644 --- a/spec/v1/10-service-intent.md +++ b/spec/v1/10-service-intent.md @@ -344,6 +344,60 @@ required. on; which hostname reaches it, and on what path, is stated once on the Service ([Exposure](#exposure)). +### Sidecars + +A Workload is one pod, and a pod holds more than one container three times in +this estate. `sidecars` names the others +([0064](../../docs/adr/model/0064-sidecars-are-workload-vocabulary.md)): + +```yaml +- name: postgres + image: postgres-17 + engine: postgres + provides: {postgres: 5432, metrics: 9187} # the exporter serves 9187 + placement: {memory: 2Gi, cpu: 500m} + sidecars: + - name: postgres-exporter + image: postgres-exporter + memory: 64Mi + cpu: 50m + hardening: {} # restricted, no exceptions +``` + +| field | required | shape | notes | +|---|---|---|---| +| `name` | yes | one value | The container's own name, unique among the Workload's containers — the Workload is one of them, so a sidecar may not take its name. A collision is refused at composition (chapter 40). | +| `image` | yes | an alias | Resolved to a digest through the images lock, exactly as a Workload's is. A tag would put a mutable reference in a Deliverable, which `E_FLOATING_IMAGE` (chapter 30) refuses. | +| `memory` | yes | one quantity | This container's request. Shape rules are the Workload's ([Placement](#placement)). | +| `cpu` | yes | one quantity | The same. | +| `hardening` | yes | `{exceptions: [...]}` | This container's own class and its own exception list, the same shape as the Workload's ([Pod hardening](#pod-hardening)). `{}` is `restricted` with no exceptions. | + +The split follows Kubernetes rather than a rule of the model's own: `nodeSelector` +and affinity are **pod**-level, `resources` and `securityContext` are +**container**-level. So the node dimensions — `arch`, `site`, `disk`, `gpu`, +`capabilities` — stay on the Workload and describe the pod, and a sidecar +declares neither them nor a `placement` block. `memory`, `cpu` and `hardening` +are per container, and a sidecar declares its own. + +Nothing is inherited. `postgres-exporter` meets `restricted` while `postgres` +does not, so a Workload's exception list does not reach its sidecars — pushing +one container's exception onto another would widen the estate's inventory of +what it cannot harden by containers that never needed it. + +**Eligibility sums.** A node must fit the pod's containers together, so the +placement check adds every sidecar's `memory` and `cpu` to the Workload's before +matching against allocatable +([0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md)). `postgres` at +2Gi with a 64Mi exporter needs a node with 2112Mi free, not 2Gi. This is the one +place the addition matters and the one place it is easy to miss. + +A sidecar has no identity, no probes, no exposure and no release semantics of +its own: it is not independently deployable, which is what makes it a sidecar +rather than a Workload. `provides` therefore stays on the **Workload** even when +the listener is a sidecar — `platform-postgres` declares `metrics: 9187` and the +exporter is the container that serves it, which is exactly the attribution the +model could not state before this field existed. + ### Dependencies ```yaml @@ -1845,9 +1899,11 @@ classDiagram +int port } class Sidecar { - <> +string name - +ImageRef image + +ImageAlias image + +Quantity memory + +Quantity cpu + +HardeningClass hardening } class DependencyEdge { +ServiceId service @@ -1903,7 +1959,7 @@ classDiagram +Path path } class Override { - +string field + +Derivation derivation +any value +string reason } @@ -1918,9 +1974,13 @@ classDiagram } class Grant { + +SecretEngine engine +VaultPath path +string[] keys +AccessTier access + +string role + +string key + +TransitOp[] operations +Delivery delivery +Path mountAt +FileMode fileMode @@ -1930,11 +1990,128 @@ classDiagram +Duration maxAge } +%% Every closed vocabulary in layer 1. The authored form of a capability +%% control is capability:; the angle brackets are dropped here because +%% mermaid reads them as markup. + class AccessTier { + <> + read + self-renew + self-roll + custody + } + class AlertClass { + <> + none + business-hours + urgent + page + } + class Arch { + <> + amd64 + arm64 + } + class Audience { + <> + anonymous + authenticated + internal + lan + } + class ContentPolicy { + <> + strict + admin + workflow + } + class Control { + <> + runAsRoot + writableRootFilesystem + capability:NAME + seccompUnconfined + } + class Delivery { + <> + env + file + self + } + class DurabilityClass { + <> + reconstructible + recoverable + irreplaceable + } + class Engine { + <> + postgres + rabbitmq + valkey + files + } + class HardeningClass { + <> + restricted + } + class Lifecycle { + <> + service + job + } + class Match { + <> + prefix + exact + } + class Media { + <> + nvme + ssd + hdd + } + class PlaceholderKind { + <> + secret + dependency + exposure + identity + } + class Runtime { + <> + jvm + python + node + static + none + } + class SecretEngine { + <> + kv + database + transit + } + class Tolerance { + <> + restart + reload + } + class TransitOp { + <> + sign + verify + encrypt + decrypt + rotate + } + Domain "1" *-- "1..*" Service : services Service "1" *-- "1..*" Workload : workloads Workload "1" *-- "0..*" Surface : provides Workload "1" *-- "0..*" Sidecar : sidecars + Sidecar "1" *-- "0..*" HardeningException : hardening.exceptions Workload "1" *-- "0..*" HardeningException : hardening.exceptions Workload "1" *-- "0..*" DependencyEdge : dependsOn Workload "1" *-- "0..1" Probe : probes.readiness @@ -1950,15 +2127,15 @@ classDiagram Service "1" *-- "0..*" Exposure : exposure Exposure "1" *-- "1..*" Route : routes - Route ..> Surface : names a Surface a Workload of this Service provides - DependencyEdge ..> Surface : names a Surface of another Service + Route ..> Surface : resolves by name + DependencyEdge ..> Surface : resolves by name Workload "1" *-- "1..*" EnvFile : env per workload EnvFile "1" *-- "0..*" Placeholder : resolves - Service "1" *-- "0..*" Grant : secrets (shared) - Workload "1" *-- "0..*" Grant : secrets (workload-specific) + Service "1" *-- "0..*" Grant : secrets + Workload "1" *-- "0..*" Grant : secrets Grant "1" *-- "0..1" Rotation : rotation - Placeholder ..> Grant : a secret placeholder byte-matches a granted path + Placeholder ..> Grant : byte-matches Placeholder ..> Exposure : an exposure placeholder addresses service.name ``` diff --git a/spec/v1/diagrams/10-service-intent-model.drawio.svg b/spec/v1/diagrams/10-service-intent-model.drawio.svg index f573a6d..8db08af 100644 --- a/spec/v1/diagrams/10-service-intent-model.drawio.svg +++ b/spec/v1/diagrams/10-service-intent-model.drawio.svg @@ -1,4 +1,4 @@ -Enumerations — every closed vocabulary in layer 1Domain+ DomainName domain+ string owner+ SemVer schemaVersionService+ ServiceId id+ AlertClass alertClassGrant+ SecretEngine engine+ VaultPath path+ string[] keys+ AccessTier access+ string role+ string key+ TransitOp[] operations+ Delivery delivery+ Path mountAt+ FileMode fileModeWorkload+ string name+ Lifecycle lifecycle+ ImageAlias image+ Runtime runtime+ Engine engine+ Duration startupBudget+ bool zeroDowntime+ bool stateful+ Path[] writablePaths+ HardeningClass hardeningExposure+ ExposureName name+ Fqdn host+ Audience audience+ ContentPolicy contentPolicy0..* secrets — shared1..* workloads0..* exposure1..* servicesRotation+ Tolerance tolerates+ Duration maxAge0..1 rotationRoute+ Path path+ Match match+ string workload+ string surface+ Audience audience+ Path redirectTo1..* routesSurface+ string name+ int port0..* providesSidecar+ string name+ ImageAlias image+ Quantity memory+ Quantity cpu0..* sidecarsDependencyEdge+ ServiceId service+ string surface+ bool required0..* dependsOnProbe+ Path path+ int port+ int tcp0..2 probesAsset+ Path from+ Path mountAt+ map substitute0..* assetsVolume+ ClaimName claim+ Path mountAt+ Quantity size+ DurabilityClass durability0..* volumesPlacement+ Quantity memory+ Quantity cpu+ Arch[] arch+ Site site+ Capability[] capabilities1 placementEnvFile+ ClusterTarget cluster+ dotenv entries1..* envOverride+ Derivation derivation+ any value+ string reason0..* overridesHardeningException+ Control allow+ string reason0..* exceptionsDiskRequest+ Media[] media+ Quantity sizeGpuRequest+ GpuClass class+ Quantity memoryPlaceholder+ PlaceholderKind kind+ string source0..1 disk0..1 gpu0..* resolvesnames a Surface of another Servicenames a Surface this Service providesa secret placeholder byte-matches a grant's derived read pathAn attribute's type names its enumeration. Values are exhaustive: a value absent from a list herefails schema validation.«enumeration»Lifecycleservicejob— on Workload«enumeration»Runtimejvmpythonnodestaticnone— on Workload«enumeration»Enginepostgresrabbitmqvalkeyfiles— on Workload«enumeration»HardeningClassrestricted— on Workload«enumeration»ControlrunAsRootwritableRootFilesystemcapability:<NAME>seccompUnconfined— on HardeningException«enumeration»DurabilityClassreconstructiblerecoverableirreplaceable— on Volume«enumeration»AlertClassnonebusiness-hoursurgentpage— on Service«enumeration»Audienceanonymousauthenticatedinternallan— on Exposure, Route«enumeration»ContentPolicystrictadminworkflow— on Exposure«enumeration»Matchprefixexact— on Route«enumeration»SecretEnginekvdatabasetransit— on Grant«enumeration»AccessTierreadself-renewself-rollcustody— on Grant — kv only«enumeration»TransitOpsignverifyencryptdecryptrotate— on Grant — transit only«enumeration»Deliveryenvfileself— on Grant«enumeration»Tolerancerestartreload— on Rotation«enumeration»PlaceholderKindsecretdependencyexposureidentity— on Placeholder«enumeration»Medianvmessdhdd— on DiskRequest«enumeration»Archamd64arm64— on Placement \ No newline at end of file +Enumerations — every closed vocabulary in layer 1Domain+ DomainName domain+ string owner+ SemVer schemaVersionService+ ServiceId id+ AlertClass alertClassGrant+ SecretEngine engine+ VaultPath path+ string[] keys+ AccessTier access+ string role+ string key+ TransitOp[] operations+ Delivery delivery+ Path mountAt+ FileMode fileModeWorkload+ string name+ Lifecycle lifecycle+ ImageAlias image+ Runtime runtime+ Engine engine+ Duration startupBudget+ bool zeroDowntime+ bool stateful+ Path[] writablePaths+ HardeningClass hardeningExposure+ ExposureName name+ Fqdn host+ Audience audience+ ContentPolicy contentPolicy0..* secrets1..* workloads0..* exposure1..* services0..* secretsRotation+ Tolerance tolerates+ Duration maxAge0..1 rotationRoute+ Path path+ Match match+ string workload+ string surface+ Audience audience+ Path redirectTo1..* routesSurface+ string name+ int port0..* providesSidecar+ string name+ ImageAlias image+ Quantity memory+ Quantity cpu+ HardeningClass hardening0..* sidecarsHardeningException+ Control allow+ string reason0..* exceptionsDependencyEdge+ ServiceId service+ string surface+ bool required0..* dependsOnProbe+ Path path+ int port+ int tcp0..2 probesAsset+ Path from+ Path mountAt+ map substitute0..* assetsVolume+ ClaimName claim+ Path mountAt+ Quantity size+ DurabilityClass durability0..* volumesPlacement+ Quantity memory+ Quantity cpu+ Arch[] arch+ Site site+ Capability[] capabilities1 placementScrape+ int port+ Path path0..1 scrapeEnvFile+ ClusterTarget cluster+ dotenv entries1..* envOverride+ Derivation derivation+ any value+ string reason0..* overridesDiskRequest+ Media[] mediaGpuRequest+ GpuClass class+ Quantity memoryPlaceholder+ PlaceholderKind kind+ string source0..1 disk0..1 gpu0..* resolves0..* exceptions«resolves by name»«resolves by name»«byte-matches»An attribute's type names its enumeration. Values are exhaustive: a value absent from a list herefails schema validation.«enumeration»Lifecycleservicejob— on Workload«enumeration»Runtimejvmpythonnodestaticnone— on Workload«enumeration»Enginepostgresrabbitmqvalkeyfiles— on Workload«enumeration»HardeningClassrestricted— on Workload«enumeration»ControlrunAsRootwritableRootFilesystemcapability:<NAME>seccompUnconfined— on HardeningException«enumeration»DurabilityClassreconstructiblerecoverableirreplaceable— on Volume«enumeration»AlertClassnonebusiness-hoursurgentpage— on Service«enumeration»Audienceanonymousauthenticatedinternallan— on Exposure, Route«enumeration»ContentPolicystrictadminworkflow— on Exposure«enumeration»Matchprefixexact— on Route«enumeration»SecretEnginekvdatabasetransit— on Grant«enumeration»AccessTierreadself-renewself-rollcustody— on Grant — kv only«enumeration»TransitOpsignverifyencryptdecryptrotate— on Grant — transit only«enumeration»Deliveryenvfileself— on Grant«enumeration»Tolerancerestartreload— on Rotation«enumeration»PlaceholderKindsecretdependencyexposureidentity— on Placeholder«enumeration»Medianvmessdhdd— on DiskRequest«enumeration»Archamd64arm64— on Placement \ No newline at end of file