diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml new file mode 100644 index 0000000..84c64fd --- /dev/null +++ b/.github/workflows/gate.yml @@ -0,0 +1,65 @@ +--- +# The schema gate. Runs the same checks `make validate` runs locally, but +# without Docker: CI calls `make validate.local`, so the gate has one +# definition (the Makefile) instead of one there and a copy here. +# +# What it proves: +# * the atom grammar's self-test still rejects all 19 deliberately broken +# fixtures — a gate nobody has shown capable of failing is not a gate +# * every live composition satisfies the grammar +# * every schema still validates against the meta-schema, and every +# DomainComposition against its base aggregate +# +# Deliberately NOT here: pledge, release and verify-release. Those need Vault +# credentials and are a release action, not a gate. + +name: gate + +on: + push: + branches: [main, devel] + pull_request: + branches: [main, devel] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gate-${{ github.ref }} + cancel-in-progress: true + +jobs: + gate: + name: gate + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install pinned dependencies + # requirements.txt is the lock file; using it as a constraints file + # keeps the pins without installing the linting and mutation-testing + # stack a validation run does not need. + # + # The extras are stripped because pip rejects them in a constraints + # file ("Constraints cannot have extras" — coverage[toml] is the one + # such line). Stripping keeps every pin; it only drops the optional + # dependency groups, which are irrelevant here since these packages + # are constraints, not installs. + run: | + python -m pip install --upgrade pip + sed -E 's/\[[^]]*\]//' requirements.txt > /tmp/gate-constraints.txt + pip install -c /tmp/gate-constraints.txt pyyaml jsonschema semver requests + # `requests` is not used by the validate path, but compiler.py imports + # it at module level (line 7) alongside semver, so the module will not + # load without it. Listed here rather than made lazy, because changing + # the import structure of the release tooling is not this change's job. + + - name: Gate + run: make validate.local diff --git a/CLAUDE.md b/CLAUDE.md index d1985a8..9631f03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,15 +1,34 @@ -# CIC Primitives — Claude kontextus +# CIC Storage — Claude kontextus + +## Branch szabály — KÖTELEZŐ + +**Érdemi fejlesztés kizárólag a `devel` ágon történhet.** + +- `main` — csak merge fogad (devel → main), közvetlen commit tilos +- `storage/releases/v*` — kizárólag release tag célra +- `devel` — ez az aktív fejlesztési ág + +Ha nem `devel`-en vagyunk: figyelmeztetés, és átváltás `devel`-re mielőtt bármilyen +schema, kód vagy dokumentáció változtatás történik. ## Mi ez a rendszer -A `cic-primitives` a CentralInfraCore **meta-séma rétege** — az a szint, amelyből -minden domain objektum (switch interface, kubernetes pod, service, database, policy) -schema-szinten levezethető. +A `cic-storage` egy **domain-repó** — a `cic-primitives` meta-séma rétegére épülve +storage objektumokat ír le, schema-szinten, **szándékosan provider-agnosztikus +módon**: a kívánt tárolási állapotot (bucket, tier, titkosítás, hozzáférés) +mondja ki, nem azt, hogy egy adott felhő (OCI/AWS/Azure/on-prem) hogyan +valósítja meg. A tényleges létrehozás/felügyelet külön `cic-module-` +modulok feladata a `cic:provider` WASM ABI-n keresztül. -Nem domain modell. Nem IaC tool. Nem YANG leíró. +A `schemas/atomic/`+`schemas/aggregate/` alatti fájlok **öröklöttek** a +`cic-primitives`-ból (a `base` remote-on át) — ez a repó nem definiálja őket. +A saját munka a domain-kompozíció: `schemas/examples/storage-bucket.yaml` +(`StorageBucket`). A `kubernetes-pod.yaml` a `cic-primitives` öröklött +sablon-demója, nem ennek a repónak a munkája. A primitívek azok az **irreducibilis szemantikai atomok és kompozícióik**, amelyekből -bármilyen menedzselt objektum strukturált, validálható, verziózott YAML sémává fordítható. +bármilyen menedzselt objektum strukturált, validálható, verziózott YAML sémává fordítható +— ezt a réteget a `cic-primitives` adja, nem ez a repó. Részletes architektúra: `ai/SYSTEM_CONTEXT.md` Következő konkrét feladatok: `ai/PROMPTMAP.yaml` @@ -42,16 +61,15 @@ Amíg ez a négy pont nincs meg, ne tegyél tényállításokat a primitive mode | Elem | Státusz | Megjegyzés | |---|---|---| -| git repo bootstrap | **defined** | `git merge base@0.5.0` kész | -| `dependency.yaml` | **defined** | `base@0.5.0` composition lock | -| `project.yaml` | **defined** | `x-cic.repo_type: primitive` | -| `schemas/` struktúra | **defined** | atomic/ + aggregate/ + index.yaml | -| aggregate skeletonök | **defined** | ConfigSurface, StateSurface, OperationSurface, ManagedEntity | -| atomic layer (7 atom) | **defined** | Shape, Role, Behavior, Contract, Address, Identity, Event | -| aggregate completion | **defined** | atomic ref-ek bekötve | -| domain példa | **defined** | `schemas/examples/kubernetes-pod.yaml` | +| git repo bootstrap | **defined** | `git merge base@0.5.0` a `cic-primitives`-on át (nem közvetlen) | +| `dependency.yaml` | **defined** | `base@0.5.0` composition lock (örökölt) | +| `project.yaml` | **defined** | `x-cic.repo_type: domain` | +| `schemas/` struktúra | **defined** | atomic/ + aggregate/ (örökölt) + examples/storage-bucket.yaml (saját) | +| atomic/aggregate réteg | **öröklött** | Shape, Role, Behavior, Contract, Address, Identity, Event, Access + surface-aggregate-ek | +| `StorageBucket` domain composition | **defined** | provider-agnosztikus, teljes surface-készlet, `make validate` zöld | +| Provider modul kötés | **partial** | `cic-module-oracle-cloud` implemented; aws/azure/onprem concept | | `make validate` zöld | **defined** | Docker-alapú tooling, Vault nélkül is fut | -| első signed release | **concept** | Vault + `make release VERSION=x` | +| signed release pipeline | **defined** | lefutott (lásd git tag-ek) | --- @@ -59,7 +77,7 @@ Amíg ez a négy pont nincs meg, ne tegyél tényállításokat a primitive mode ``` atomic primitive = irreducibilis szemantikai atom - Shape · Role · Behavior · Contract · Address · Identity · Event + Shape · Role · Behavior · Contract · Address · Identity · Event · Access → ezekből schema fragment generálható aggregate primitive = szemantikai kompozíció sealed/defaulted/required slot-okkal @@ -78,9 +96,9 @@ Az objektum mindig következmény, soha nem kiindulópont. ``` base-repo (upstream sablon) │ remote: base → git merge base@0.5.0 - └──► cic-primitives (ez a repo) + └──► cic-primitives │ remote: base → git merge base@0.5.0 - └──► domain repók (cic-yang, cic-network, stb.) + └──► cic-storage (ez a repo) · cic-network, cic-storage, stb. (testvér domain repók) ``` A fájlstruktúra IS az interface contract. A merge konfliktus = séma sértés. @@ -130,9 +148,10 @@ Immersion módban tilos hiányt feltételezni ott, ahol scaffold szándékos. | Repo | Remote | Mit ad | |---|---|---| -| `base-repo` | `base` | tooling, signing hook, CI, Makefile, mk/infra.mk | -| `CIC-Schemas` | referencia | template-schema minta, séma pipeline, signing lánc | -| `CIC-Relay` | — | a runtime ami a primitívekből épülő sémákat futtatja | +| `cic-primitives` | `base` | atomic/aggregate primitívák, tooling, signing hook, CI, Makefile, mk/infra.mk | +| `base-repo` | közvetett (a `cic-primitives` saját `base` remote-ja) | eredeti tooling-sablon | +| `cic-module-oracle-cloud` | — | provider modul, a `StorageBucket` intent egyik konkrét megvalósítója | +| `CIC-Relay` | — | a runtime, ami a provider modulokat futtatja a `storage-bucket.yaml` kompozíció ellen | --- diff --git a/Dockerfile b/Dockerfile index 327dd86..00e4ca0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ git \ curl \ jq \ + && curl -fsSL https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \ + -o /usr/local/bin/yq && chmod +x /usr/local/bin/yq \ && rm -rf /var/lib/apt/lists/* # Trust the mounted project directory (host uid != container uid) diff --git a/Makefile b/Makefile index 3424f42..b4b69d6 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for Schema Development Environment -.PHONY: all help up down shell validate release test repo.init infra.deps infra.coverage infra.clean fmt lint check typecheck build +.PHONY: all help up down shell validate grammar grammar.local validate.local pledge release verify-release verify-release-strict test mutation-test repo.init infra.deps infra.coverage infra.clean fmt lint check typecheck build # Default to showing help all: help @@ -29,20 +29,70 @@ build: # Main Development Tasks # ============================================================================= -validate: +validate: grammar @echo "--- Validating all schemas against the meta-schema ---" @docker compose exec builder python tools/compiler.py validate +# The atom grammar closes what index.yaml cannot: the instance form of Shape +# and Role, and the constraints that cross primitive boundaries. The self-test +# runs first on purpose — a gate nobody has shown capable of failing is not a +# gate. See proposals/atom-grammar/README.md. +grammar: + @echo "--- Atom grammar: self-test ---" + @docker compose exec builder python proposals/atom-grammar/check_grammar.py --self-test + @echo "--- Atom grammar: live compositions ---" + @docker compose exec builder sh -c \ + 'python proposals/atom-grammar/check_grammar.py schemas/examples/*.yaml \ + $$(ls schemas/domain/*.yaml 2>/dev/null)' + +# ============================================================================= +# Container-free gate — what CI runs +# ============================================================================= +# The docker targets above and these must stay equivalent. CI calls +# validate.local, so the gate has ONE definition rather than one here and a +# second one copied into the workflow YAML. + +# Every composition this repository ships. Wildcards, not a fixed list, so a +# new composition is gated the moment it lands. Both directories matter: +# examples/ holds the demonstrations, domain/ holds the real domain objects — +# and domain/ is where the richest ones live (cic-compute's compute-resource). +GRAMMAR_COMPOSITIONS := $(wildcard schemas/examples/*.yaml) $(wildcard schemas/domain/*.yaml) + +grammar.local: + @echo "--- Atom grammar: self-test ---" + @python3 proposals/atom-grammar/check_grammar.py --self-test + @echo "--- Atom grammar: live compositions ---" + @python3 proposals/atom-grammar/check_grammar.py $(GRAMMAR_COMPOSITIONS) + +validate.local: grammar.local + @echo "--- Validating all schemas against the meta-schema ---" + @python3 tools/compiler.py validate + +pledge: + @echo "--- Developer commitment: validity + createdBy signed by Vault ---" + @docker compose exec builder python tools/compiler.py pledge + release: @echo "--- Building and signing release schemas ---" @docker compose exec builder python tools/compiler.py release - @tools/release.sh project.yaml - @git add project.yaml + +verify-release: + @if [ -z "$(FILE)" ]; then echo "Usage: make verify-release FILE=release/-vX.Y.Z.yaml [STRICT=1]"; exit 1; fi + @docker compose exec builder python tools/compiler.py verify-release $(FILE) $(if $(STRICT),--strict,) + +verify-release-strict: + @if [ -z "$(FILE)" ]; then echo "Usage: make verify-release-strict FILE=release/-vX.Y.Z.yaml"; exit 1; fi + @docker compose exec builder python tools/compiler.py verify-release $(FILE) --strict test: @echo "--- Running pytest for the compiler infrastructure ---" @docker compose exec builder python -m pytest --cov=tools.compiler --cov-report=term-missing tests/ +mutation-test: + @echo "--- Running mutation tests (mutmut) ---" + @docker compose exec builder mutmut run + @docker compose exec builder mutmut results + fmt: @echo "--- Formatting Python code with Black and Isort ---" @docker compose exec builder python -m black . @@ -104,8 +154,11 @@ help: @echo "" @echo "Main Tasks:" @echo " validate Run fast, offline validation of all schemas." - @echo " release Build, checksum, and sign all non-dev schemas (requires Vault)." + @echo " pledge Generate a signed developer commitment (validity + createdBy) to commitment.yaml." + @echo " release Build, checksum, and sign all non-dev schemas (requires Vault + commitment.yaml)." + @echo " verify-release FILE= Verify a PrimitiveRelease bundle (content_hash + meta_hash)." @echo " test Run pytest for the compiler infrastructure code." + @echo " mutation-test Run mutmut mutation tests against tools/compiler.py." @echo " fmt Format Python code with Black and Isort." @echo " lint Lint Python code with Ruff and YAML files with yamllint." @echo " typecheck Run static type checking with MyPy." diff --git a/README.hu.md b/README.hu.md new file mode 100644 index 0000000..e98667c --- /dev/null +++ b/README.hu.md @@ -0,0 +1,102 @@ +# cic-storage + +> Ez nem klasszikus repo. Ez AI-operált domain schema layer. +> Emberi belépő: ez a README. AI belépő: `ai/ONBOARDING.md`. + +A `cic-storage` a `cic-primitives` **meta-séma rétegére** épülő domain-repó — +storage objektumokat ír le, a `cic-primitives` atomic/aggregate primitíváinak +kompozíciójaként. + +**Fontos architekturális elv: ez a repó szándékosan provider-agnosztikus.** +A `cic-storage` azt írja le, MIT akarunk (kívánt tárolási állapot — bucket, +tier, titkosítás, hozzáférés), nem azt, hogy egy konkrét felhő (OCI/AWS/Azure/ +on-prem) hogyan valósítja meg. A tényleges létrehozást/felügyeletet külön +`cic-module-` modulok végzik a `cic:provider` WASM ABI-n keresztül +(pl. `cic-module-oracle-cloud` — jelenleg az egyetlen implemented modul). +Provider-specifikus részletek (path-ok, HTTP-igék, SDK-anomáliák) szándékosan +NEM jelennek meg itt — azok a modul saját correspondence/binding rétegében élnek. + +--- + +## Két szint + +| Szint | Mit képvisel | Hol van | Eredet | +|---|---|---|---| +| **atomic primitive** | 8 irreducibilis atom — Shape, Role, Behavior, Contract, Address, Identity, Event, Access | `schemas/atomic/` | öröklött a `cic-primitives`-ból | +| **aggregate primitive** | Kompozíció sealed/defaulted/required slot-okkal | `schemas/aggregate/` | öröklött a `cic-primitives`-ból | +| **domain composition** | `StorageBucket` — provider-agnosztikus object-storage intent | `schemas/examples/storage-bucket.yaml` | **ennek a repónak a saját munkája** | + +A domain objektum mindig következmény, soha nem kiindulópont — ez itt a +`StorageBucket`, ami a `ManagedEntity` aggregate-et alkalmazza a `cic:storage` +domain-ra, provider-független szinten. + +--- + +## Gyors start + +```bash +make validate # séma validáció — ha ez nem zöld, semmi sem kész +make release # signed artifact (Vault szükséges) +``` + +--- + +## AI belépési pontok + +| Fájl | Mire való | +|---|---| +| `ai/ONBOARDING.md` | Boot protokoll — minden session elején | +| `ai/MAINTENANCE_CONTRACT.md` | Mit szabad, mit nem, mikor kell döntés | +| `ai/SYSTEM_CONTEXT.md` | Teljes architekturális kontextus | +| `ai/PROMPTMAP.yaml` | Task queue — mi a következő konkrét lépés | +| `ai/DECISIONS.md` | Döntési history — miért úgy van ahogy van | + +--- + +## Aktuális állapot + +| Réteg | Státusz | Megjegyzés | +|---|---|---| +| Örökölt atomic/aggregate primitívák | **defined** | `schemas/atomic/`, `schemas/aggregate/` — a `cic-primitives`-ból, `base` remote-on át | +| `StorageBucket` domain composition | **defined** | `schemas/examples/storage-bucket.yaml` — provider-agnosztikus, teljes surface-készlet (Config/State/Operation/Notification/Policy/Binding) | +| Provider modul kötés | **partial** | `cic-module-oracle-cloud` = implemented; aws/azure/onprem modulok = concept (lásd composition `derivation_chain.provider_mapping`) | +| KubernetesPod sablon-példa | **öröklött, nem storage-specifikus** | `schemas/examples/kubernetes-pod.yaml` — a `cic-primitives` demója, nem ennek a repónak a munkája | +| Signed release pipeline | **defined** | Vault Transit + ECDSA, a pipeline maga lefutott (lásd git tag-ek) | +| Production trust-chain | **not implemented** | CIC-Relay + CIC-Schemas feladata | + +--- + +## Kapcsolódó repók + +| Repo | Kapcsolat | +|---|---| +| `cic-primitives` | közvetlen upstream — atomic/aggregate primitívák + tooling, `git remote base` | +| `base-repo` | közvetett upstream (a `cic-primitives` saját `base@0.5.0` merge-én keresztül) | +| `cic-module-oracle-cloud` | provider modul — a `StorageBucket` intent egyik konkrét megvalósítója, `cic:provider` WASM ABI-n keresztül | +| `CIC-Relay` | runtime — a provider modulokat futtatja, amik a `storage-bucket.yaml` kompozíciót reconcile-olják | + +--- + +## Release artifact — GHCR + +A séma release OCI artifactként érhető el a GitHub Container Registry-ben. + +**ORAS-szal:** + +```bash +oras pull ghcr.io/centralinfracore/schema/cic-storage:v0.1.2-src2026 +``` + +**curl-lel (ORAS nélkül):** + +```bash +REPO="centralinfracore/schema/cic-storage"; TAG="v0.1.2-src2026"; \ +TOKEN=$(curl -fsSL "https://ghcr.io/token?scope=repository:${REPO}:pull" | jq -r .token); \ +DIGEST=$(curl -fsSL \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + "https://ghcr.io/v2/${REPO}/manifests/${TAG}" | jq -r '.layers[0].digest'); \ +curl -fL -H "Authorization: Bearer ${TOKEN}" \ + "https://ghcr.io/v2/${REPO}/blobs/${DIGEST}" \ + -o cic-storage-v0.1.2.yaml +``` diff --git a/README.md b/README.md index b333f72..1f240e7 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,34 @@ -# cic-primitives +# cic-storage -> Ez nem klasszikus repo. Ez AI-operált primitive schema layer. +> Ez nem klasszikus repo. Ez AI-operált domain schema layer. > Emberi belépő: ez a README. AI belépő: `ai/ONBOARDING.md`. -A CIC **meta-séma rétege** — az a szint, amelyből minden domain objektum -(switch interface, kubernetes pod, service, database, policy) schema-szinten levezethető. +A `cic-storage` a `cic-primitives` **meta-séma rétegére** épülő domain-repó — +storage objektumokat ír le, a `cic-primitives` atomic/aggregate primitíváinak +kompozíciójaként. -Nem domain modell. Nem IaC tool. Nem YANG leíró. +**Fontos architekturális elv: ez a repó szándékosan provider-agnosztikus.** +A `cic-storage` azt írja le, MIT akarunk (kívánt tárolási állapot — bucket, +tier, titkosítás, hozzáférés), nem azt, hogy egy konkrét felhő (OCI/AWS/Azure/ +on-prem) hogyan valósítja meg. A tényleges létrehozást/felügyeletet külön +`cic-module-` modulok végzik a `cic:provider` WASM ABI-n keresztül +(pl. `cic-module-oracle-cloud` — jelenleg az egyetlen implemented modul). +Provider-specifikus részletek (path-ok, HTTP-igék, SDK-anomáliák) szándékosan +NEM jelennek meg itt — azok a modul saját correspondence/binding rétegében élnek. --- ## Két szint -| Szint | Mit képvisel | Hol van | -|---|---|---| -| **atomic primitive** | 7 irreducibilis atom — Shape, Role, Behavior, Contract, Address, Identity, Event | `schemas/atomic/` | -| **aggregate primitive** | Kompozíció sealed/defaulted/required slot-okkal | `schemas/aggregate/` | +| Szint | Mit képvisel | Hol van | Eredet | +|---|---|---|---| +| **atomic primitive** | 8 irreducibilis atom — Shape, Role, Behavior, Contract, Address, Identity, Event, Access | `schemas/atomic/` | öröklött a `cic-primitives`-ból | +| **aggregate primitive** | Kompozíció sealed/defaulted/required slot-okkal | `schemas/aggregate/` | öröklött a `cic-primitives`-ból | +| **domain composition** | `StorageBucket` — provider-agnosztikus object-storage intent | `schemas/examples/storage-bucket.yaml` | **ennek a repónak a saját munkája** | -A domain objektum mindig következmény, soha nem kiindulópont. +A domain objektum mindig következmény, soha nem kiindulópont — ez itt a +`StorageBucket`, ami a `ManagedEntity` aggregate-et alkalmazza a `cic:storage` +domain-ra, provider-független szinten. --- @@ -42,10 +53,50 @@ make release # signed artifact (Vault szükséges) --- +## Aktuális állapot + +| Réteg | Státusz | Megjegyzés | +|---|---|---| +| Örökölt atomic/aggregate primitívák | **defined** | `schemas/atomic/`, `schemas/aggregate/` — a `cic-primitives`-ból, `base` remote-on át | +| `StorageBucket` domain composition | **defined** | `schemas/examples/storage-bucket.yaml` — provider-agnosztikus, teljes surface-készlet (Config/State/Operation/Notification/Policy/Binding) | +| Provider modul kötés | **partial** | `cic-module-oracle-cloud` = implemented; aws/azure/onprem modulok = concept (lásd composition `derivation_chain.provider_mapping`) | +| KubernetesPod sablon-példa | **öröklött, nem storage-specifikus** | `schemas/examples/kubernetes-pod.yaml` — a `cic-primitives` demója, nem ennek a repónak a munkája | +| Signed release pipeline | **defined** | Vault Transit + ECDSA, a pipeline maga lefutott (lásd git tag-ek) | +| Production trust-chain | **not implemented** | CIC-Relay + CIC-Schemas feladata | + +--- + ## Kapcsolódó repók | Repo | Kapcsolat | |---|---| -| `base-repo` | upstream tooling (Makefile, CI, compiler) — `git merge base@0.5.0` | -| `CIC-Relay` | runtime — primitívekből épülő sémákat futtatja | -| domain repók | leszármazottak — `cic-primitives` a base-jük | +| `cic-primitives` | közvetlen upstream — atomic/aggregate primitívák + tooling, `git remote base` | +| `base-repo` | közvetett upstream (a `cic-primitives` saját `base@0.5.0` merge-én keresztül) | +| `cic-module-oracle-cloud` | provider modul — a `StorageBucket` intent egyik konkrét megvalósítója, `cic:provider` WASM ABI-n keresztül | +| `CIC-Relay` | runtime — a provider modulokat futtatja, amik a `storage-bucket.yaml` kompozíciót reconcile-olják | + +--- + +## Release artifact — GHCR + +The schema release is available as an OCI artifact in GitHub Container Registry. + +**With ORAS:** + +```bash +oras pull ghcr.io/centralinfracore/schema/cic-storage:v0.1.2-src2026 +``` + +**With curl (no ORAS required):** + +```bash +REPO="centralinfracore/schema/cic-storage"; TAG="v0.1.2-src2026"; \ +TOKEN=$(curl -fsSL "https://ghcr.io/token?scope=repository:${REPO}:pull" | jq -r .token); \ +DIGEST=$(curl -fsSL \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + "https://ghcr.io/v2/${REPO}/manifests/${TAG}" | jq -r '.layers[0].digest'); \ +curl -fL -H "Authorization: Bearer ${TOKEN}" \ + "https://ghcr.io/v2/${REPO}/blobs/${DIGEST}" \ + -o cic-storage-v0.1.2.yaml +``` diff --git a/ai/DECISIONS.md b/ai/DECISIONS.md index e5c5e6a..cb1b73d 100644 --- a/ai/DECISIONS.md +++ b/ai/DECISIONS.md @@ -1,7 +1,12 @@ # Tervezési döntések -A fogalmak kialakulásának history-ja. Ha egy döntés eredetét keresed, -itt keresd először. Részletes thread: `primitive.txt`. +**D-001–D-014 lent a `cic-primitives` saját, öröklött döntési naplója** — a +primitíva-grammatika tervezési háttere, nem a `cic-storage` saját döntései. A +`primitive.txt` hivatkozott részletes thread **nem létezik ebben a repóban** +(a `cic-primitives`-ban él). + +A `cic-storage` saját, storage-specifikus tervezési döntése: **D-015** legalul, +a `StorageBucket` domain composition provider-agnosztikus tervezéséről. --- @@ -34,9 +39,12 @@ Az aggregate adja a rendszer jelentését. Az atomic csak a backend részlet. --- -## D-003 — 7 atom mint irreducibilis szint (2026-04-30) +## D-003 — 8 atom mint irreducibilis szint (2026-04-30, bővítve 2026-05-04) + +**Döntés:** Shape, Role, Behavior, Contract, Address, Identity, Event, Access — ez a 8 atom. -**Döntés:** Shape, Role, Behavior, Contract, Address, Identity, Event — ez a 7 atom. +Az Access (D-011) a 8. irreducibilis atom: value-szintű jogosultsági burok, ortogonális +a Role atomhoz. Minden Shape értéken implicit jelen van. **Miért:** Ezek nem YANG fogalmak, nem RESTCONF fogalmak — ezek a menedzsment-szemantika irreducibilis atomjai. YANG, RESTCONF, OpenAPI mind ezek egyik kifejezési módja. @@ -87,8 +95,10 @@ a `project.schema.yaml`-ban és a `compiler.py`-ban. **Jelenlegi állapot:** A `repo_type` nem `compiler_settings` alatt van — a compiler még nem ismeri. A szándék `x-cic.repo_type: primitive`-ként dokumentálva a `project.yaml`-ban. -**Miért nem schema:** A cic-primitives nem egyszerű schema repo — schema-képző primitive -repo. Ha `schema` típus kerülne be, szemantikailag torzítaná a toolingot. +**Miért nem schema:** A `cic-primitives` (ahonnan ez a döntés öröklődött) nem egyszerű +schema repo — schema-képző primitive repo. Ha `schema` típus kerülne be, szemantikailag +torzítaná a toolingot. (Ez a repo `x-cic.repo_type: domain` — ez a döntés csak a +primitíva-réteg saját típusára vonatkozott.) **Következmény:** A `compiler.py` és `project.schema.yaml` kiterjesztése a base-repo-ban szükséges. Addig: `x-cic.repo_type: primitive` a szándék hordozója, `compiler_settings`-ben @@ -212,6 +222,46 @@ Runtime: mTLS cert kiértékelés az Access szabályok ellen. --- +## D-012 — Access atom: conformance dimenzió (2026-05-06) + +**Döntés:** Az Access atom `conformance` mezővel bővül, amely az eszköz-szintű +implementációs státuszt hordozza — elkülönítve a jogosultság-alapú láthatóságtól. + +**Két alapvetően különböző eset:** + +| | Jogosultság hiány | Nem implementált | +|---|---|---| +| Olvasás | null / mező nem látszik | null / mező nem látszik | +| Írás | PERMISSION DENIED | HARD REJECT — "not implemented on device X" | +| Jelentés | a mező létezik, te nem férsz hozzá | a mező NEM LÉTEZIK az eszközön | + +**Miért nem ugyanaz:** Ha egy write csendes elfogadás helyett hard reject nélkül elveszik, +az operátor azt hiheti a konfig alkalmazva lett — miközben az eszközön semmi sem történt. +Példa: `dhcp_enabled: true` beírva egy DHCP-t nem támogató switch-re → silent failure → +az operátor DHCP-t feltételez ahol nincs. + +**A `conformance` mező értékei:** +``` +implemented (default) — mező létezik és kezelhető az eszközön +not_implemented — mező NEM létezik az eszközön, write HARD REJECT-tel zár +deprecated — mező létezik de kerülendő, write WARNING + elfogadás +``` + +**Runtime enforcement `not_implemented` esetén:** +- Olvasás: `default_injection` értéke (tipikusan null → mező nem látszik) +- Írás: hard error, NEM permission denied — "field 'X' is not implemented on device Y" +- Csendes elfogadás: TILOS — a Relay nem droppolhatja a write-ot + +**Hol jelenik meg:** Az adapter binding deklarálja device-szinten melyik mezők +`not_implemented`. A séma (RFC-derivált) teljes mezőlistát tartalmaz — a conformance +az adapter runtime annotációja, nem séma-szintű eltávolítás. + +**Következmény:** `schemas/atomic/access.yaml` kiegészítendő `conformance` mezővel. +A Relay runtime enforcelja: `not_implemented` mezőre érkező write-ot nem droppolja, +hanem explicit hibával visszadobja a kérőnek. + +--- + ## D-010 — Shape atom: permission-aware value wrapper (2026-05-04) **Döntés:** Minden Shape értéknek két szintaktikailag ekvivalens reprezentációja van: @@ -249,3 +299,94 @@ szabályok alapján. A runtime mindig a kibontott formán dolgozik. A schema-k r **Következmény:** A Shape atom belső struktúrája ez — nem két különböző típus. A Phase 8 implementáció ezt a struktúrát dolgozza ki részletesen (atomic szintű spec, compiler normalizáció, runtime kiértékelés). + +--- + +## D-013 — build_hash és source_hash szétválasztása (2026-05-08) + +**Státusz:** nyitott — döntés szükséges (BACKLOG B-005) + +**Helyzet:** +A `compiler.py` `run_release()` függvényében: +```python +build_hash = source_hash # egyelőre — tényleges build env nincs még +``` +A két hash mindig azonos. A signed release-ben mindkettő szerepel, de tartalmilag redundáns. + +**Döntés:** +Ha a CIC pipeline valaha schema fordítási lépést kap (pyang compile, OpenAPI generátor, +code gen), a `build_hash` a build artifact hash-e lesz — nem a forrásé. +Addig a placeholder elfogadható, de explicit KNOWN_LIMITATION státusszal kell jelölni. + +**Azonnali teendő:** +A `project.yaml` release blokkjában dokumentálni: +```yaml +release: + _known_limitation: build_hash == source_hash (no separate build step yet) +``` +Így a signed artifact önleíró marad. + +--- + +## D-014 — Cross-domain referenciák típuskezelése (2026-05-08) + +**Státusz:** nyitott — döntés szükséges (BACKLOG B-008) + +**Helyzet:** +CIC objektumok közötti hivatkozások (pl. StorageResource → ComputeResource, KubernetesNode → KubernetesCluster) +jelenleg plain `string` típusú mezők. A `logical_id` formátum (`cic:{domain}:{...}`) dokumentált, +de schema szinten nincs kényszerítve. + +**Döntés opciók:** +1. **Szemantikus típus a Shape atomban:** `type: cic-reference` — a compiler ellenőrzi a formátumot, + a runtime (Relay) ellenőrzi a target létezését. Ez a legtisztább megoldás. +2. **Pattern constraint:** a meglévő string mezőhöz `contract: [{type: pattern, expression: "^cic:[a-z]+:"}]` + — minimális változás, de nem ad domain-specifikus információt. +3. **Elfogadás:** a cross-domain referencia runtime constraint, nem schema constraint. + A Relay validálja, a schema nem. + +**Ajánlott:** opció 1 — a CIC modell értéke részben a tipizált referencia láncon múlik. +Ha ez csak string, a schema-driven tooling nem tudja feloldani a dependency gráfot. + +--- + +## D-015 — StorageBucket: provider-agnosztikus domain composition (2026-09-07) + +**Státusz:** lezárva — a `cic-storage` saját, első döntése (nem öröklött a `cic-primitives`-ból) + +**Helyzet:** +A `cic-storage` mellett fut egy konkrét provider modul (`cic-module-oracle-cloud`), +ami az OCI Object Storage Bucket API-t implementálja `config`/`state`/`operations` +JSON Schema triádként (draft-07, `x-cic-*` vendor extension-ökkel — teljesen külön +dialektus, lásd a modul saját dokumentációját). Az első próbálkozás a +`StorageBucket` domain compositionra közvetlenül az OCI SDK Bucket API alakját +másolta le (OCI-specifikus `storageTier` enum, `namespace`/POST-nem-PUT SDK +anomáliák a binding_surface-ben, OCI path-struktúra a derivation_chain-ben). + +**Probléma:** ez összekeverte a két réteget. Az eredeti elgondolás — amit a +tervezés csak utólag tett explicitté — az volt, hogy a `cic-storage` egy +**általános, provider-független tárolási intent-et** ír le (mit akarunk), és +a provider modulok (oracle/aws/azure/on-prem) **külön-külön** valósítják meg/ +felügyelik ezt az intentet a saját API-juk ellen. Az OCI-specifikus alak +közvetlenül a domain rétegbe szivárgott volna — pontosan azt a réteget törve +össze, aminek a szétválasztását ez az egész architektúra szolgálja. + +**Döntés:** +1. `config_surface`/`state_surface` csak olyan mezőket tartalmazhat, ami minden + ismert/tervezett object-storage providerre értelmes absztrakció (pl. + `storage_class: standard|infrequent-access|archive` — nem OCI enumértékek). +2. `binding_surface`/`derivation_chain` NEM egy konkrét provider REST API-jára + mutat (szemben a `cic-network`-kel, ahol a RESTCONF/YANG maga a hálózati + eszközök valós protokollja — ott ez helyénvaló). A storage domain "vezetéke" + a `cic:provider` WASM ABI (describe/validate/plan/execute/observe/destroy/ + invoke/poll) — ezt implementálja egyformán minden `cic-module-`. +3. Provider-specifikus részletek (path-ok, HTTP-igék, SDK-anomáliák, enum- + leképezések) kizárólag a modul saját correspondence/binding rétegében élnek + (`cic-module-oracle-cloud/module/correspondence/*.json` mintájára) — a + domain-repóban explicit `derivation_chain.provider_mapping` csak felsorolja + a modulokat és státuszukat (oracle-cloud: implemented; aws/azure/onprem: concept). + +**Következmény:** ha egy mező csak egyetlen provider natív fogalmával írható le +értelmesen, az NEM kerülhet a domain compositionba — vagy általánosítani kell +(pl. `object_count_approx` best-effort, providerenkénti conformance-szal), vagy +ki kell hagyni és a modul saját state-jébe kell tenni, amit a domain réteg nem lát. diff --git a/ai/MAINTENANCE_CONTRACT.md b/ai/MAINTENANCE_CONTRACT.md index 17f91c3..ed031ec 100644 --- a/ai/MAINTENANCE_CONTRACT.md +++ b/ai/MAINTENANCE_CONTRACT.md @@ -1,6 +1,6 @@ # AI Maintenance Contract -A `cic-primitives` repo AI üzemeltetési kézikönyve. +A `cic-storage` repo AI üzemeltetési kézikönyve. --- @@ -82,7 +82,7 @@ Ha nem tudod fájl-szinten alátámasztani: ne mondd `defined`-nak. |---|---|---| | Sealed slot felülírása DomainComposition-ben | compiler.py elkapja, de séma inkonzisztens | D-005 olvasása | | `make validate` nélküli commit | rejtett séma törés | hook + fegyelem | -| Új fogalom bevezetése meglévő helyett | fragmentált modell | D-003: 7 atom végleges | +| Új fogalom bevezetése meglévő helyett | fragmentált modell | D-003: 8 atom végleges | | Domain objektumból kiindulás (Pod, Switch) | aggregate helyett instance-t tervezel | D-002 | | `TBD` típus döntés nélküli kitöltése | D-008 merge stratégia nyitott | megkérdezni | diff --git a/ai/ONBOARDING.md b/ai/ONBOARDING.md index 6af95ba..15b7bd0 100644 --- a/ai/ONBOARDING.md +++ b/ai/ONBOARDING.md @@ -3,7 +3,7 @@ ## 1 perc alatt - **Mi ez:** CIC Primitives — a meta-séma réteg. Nem domain modell, nem IaC tool. -- **Két szint:** atomic (7 atom) + aggregate (szemantikai kompozíció) +- **Két szint:** atomic (8 atom) + aggregate (szemantikai kompozíció) - **Kompozíció:** git remote merge — nem YAML override rules - **Státusz:** minden concept/draft, git bootstrap előtt - **Mérce:** `make validate` — ha nem zöld, semmi sem kész @@ -14,7 +14,7 @@ 2. Olvasd: `ai/SYSTEM_CONTEXT.md` 3. Nézd: `ai/PROMPTMAP.yaml` — mi a következő konkrét lépés -## A 7 atom (nem bontható tovább) +## A 8 atom (nem bontható tovább) | Atom | Kérdés amire válaszol | |---|---| @@ -25,6 +25,7 @@ | Address | Hogyan érhető el? | | Identity | Mi az (típus szinten)? | | Event | Milyen async jelzést bocsát ki? | +| Access | Ki olvashatja, ki írhatja — cert-alapú identity? | ## Aggregate = kompozíció, nem örökség diff --git a/ai/PROMPTMAP.yaml b/ai/PROMPTMAP.yaml index 7a9f39f..8c0449d 100644 --- a/ai/PROMPTMAP.yaml +++ b/ai/PROMPTMAP.yaml @@ -1,5 +1,13 @@ version: 1 -context: cic-primitives +context: cic-storage + +# MEGJEGYZÉS: az alábbi entries (Phase 1-8) teljes egészében a `cic-primitives` +# saját, öröklött (scaffold-másolással idekerült) bootstrap-története — mind +# "done", és mind a primitíva-réteg (atomic/aggregate schemas) LÉTREHOZÁSÁRÓL +# szól, amit ez a repo sosem hajtott végre, csak örökölt. +# +# Ez a repo (cic-storage) saját, valódi munkája a lenti "Phase 9" entry: +# a StorageBucket domain composition, provider-agnosztikus tervezéssel. # Státuszok: pending | in_progress | done | blocked @@ -482,3 +490,34 @@ entries: Dockerfile-ba kellett: git, curl, jq + safe.directory /app build_hash = source_hash egyelőre (tényleges build env nincs) yq PATH issue: make release-ben tools/release.sh fallback-kel futott + + # ── Phase 9: StorageBucket domain composition (cic-storage saját munkája) ─── + # Kiváltó: a cic-module-oracle-cloud (OCI provider modul) mellé kellett egy + # domain-szintű intent-leírás, amit a modul(ok) reconcile-olnak. Első kísérlet + # OCI Bucket API-ra húzva tévedés volt (provider-specifikus enumok/path-ok a + # domain rétegbe szivárogtak) — javítva provider-agnosztikus tervezésre. + + - task: "storage-bucket-domain-composition" + status: done + priority: 27 + milestone: example + blocked_by: "domain-specialization-compatibility-check" + prompt: | + Hozd létre a StorageBucket domain compositiont: schemas/examples/storage-bucket.yaml + Provider-agnosztikus tervezés — a config_surface/state_surface csak olyan + mezőket tartalmazhat, ami minden object-storage providerre (OCI/AWS/Azure/ + on-prem) értelmes absztrakció szintjén értelmezhető: + ConfigSurface.nodes: bucket_name, storage_class (absztrakt enum), + versioning_enabled, public_access, encryption_enabled, kms_key_ref, labels + StateSurface.nodes: provisioning_state, reported_storage_class, + endpoint_ref (opak string), object_count_approx (best-effort), + created_at, last_observed + OperationSurface.operations: reconcile, empty-and-delete, rotate-encryption-key + BindingSurface + derivation_chain NEM egy konkrét provider API-ra mutat — + a "vezeték" a cic:provider WASM ABI (describe/validate/plan/execute/ + observe/destroy/invoke/poll), amit minden cic-module- egyformán + implementál. Provider-specifikus részletek (path, HTTP-ige, SDK-anomália) + a modul saját correspondence rétegében élnek, nem itt — + derivation_chain.provider_mapping csak a modulokat sorolja fel + (oracle-cloud: implemented; aws/azure/onprem: concept). + accept: make validate (storage-bucket.yaml sealed/required/defaulted kompatibilis) diff --git a/ai/SYSTEM_CONTEXT.md b/ai/SYSTEM_CONTEXT.md index ce00e27..1c20648 100644 --- a/ai/SYSTEM_CONTEXT.md +++ b/ai/SYSTEM_CONTEXT.md @@ -6,10 +6,19 @@ Olvasd el mielőtt bármit módosítasz. ## Mi ez a rendszer? -A `cic-primitives` a CentralInfraCore **meta-séma rétege**. - -Nem domain modell, nem IaC tool, nem YANG leíró — hanem az a szint, -amelyből minden domain objektum schema-szinten levezethető. +A `cic-storage` egy **domain-repó** a `cic-primitives` (a CentralInfraCore +meta-séma rétege) fölött — storage objektumokat lenne hivatva leírni. + +Ez a fájl az öröklött primitíva-modellt magyarázza (a `cic-primitives`-ból +származik) — ez a repó maga nem a meta-séma réteg. Saját domain compositionja +van: `schemas/examples/storage-bucket.yaml` (`StorageBucket`), **szándékosan +provider-agnosztikus** — leírja mit akarunk (kívánt tárolási állapot), nem azt +hogy egy adott felhő (OCI/AWS/Azure/on-prem) hogyan valósítja meg. A tényleges +létrehozást/felügyeletet külön `cic-module-` modulok végzik a +`cic:provider` WASM ABI-n (describe/validate/plan/execute/observe/destroy/ +invoke/poll) keresztül — lásd a composition `derivation_chain.module_abi` és +`provider_mapping` blokkját. Jelenleg egyedül a `cic-module-oracle-cloud` a +`status: implemented` provider; aws/azure/onprem `status: concept`. A primitívek két szinten léteznek: @@ -45,7 +54,7 @@ ManagedEntity = ``` base-repo └─[remote: base]─► cic-primitives - └─[remote: base]─► domain repók + └─[remote: base]─► cic-storage (ez a repo), cic-network, ... ``` A fájlstruktúra IS az interface contract. Ha a leszármazott repo eltér a @@ -58,9 +67,9 @@ Választott mechanizmus (döntés: 2026-04-30): --- -## A séma infrastruktúra (örökölt base-repo-ból) +## A séma infrastruktúra (örökölt a `cic-primitives`-on, végső soron a `base-repo`-n keresztül) -A `base-repo` merge után elérhetők: +A `cic-primitives` `base` remote-jából merge-elve elérhetők: ``` tools/compiler.py CLI: validate, release, get-name @@ -138,25 +147,29 @@ spec: | Repo | Kapcsolat | Irány | |---|---|---| -| `base-repo` | git remote `base` | upstream → cic-primitives | +| `cic-primitives` | git remote `base` | upstream → cic-storage (ez a repo) | +| `base-repo` | közvetett (a `cic-primitives` saját `base` remote-ja) | tooling eredete | | `CIC-Schemas` | referencia minta | signing lánc minta | -| `CIC-Relay` | consumer | primitívekből épülő sémák futtatása | -| domain repók (tervezett) | git remote `base` | cic-primitives → domain | +| `CIC-Relay` | consumer | (még nincs mit futtatnia ebből a repóból) | --- -## Jelenlegi állapot (2026-05-01) - -Phase 1–7 végrehajtva. A repo első signed release-szel lezárt. +## Jelenlegi állapot -| Elem | Státusz | +**A "Phase 1–7" történet fentebb (git bootstrap, atomic/aggregate réteg, +`make validate` zöld) a `cic-primitives` saját fejlesztési naplója** — ez a +repó ezt öröklés útján kapta meg, nem maga hajtotta végre. A `cic-storage` saját, +valódi státusza: a release pipeline lefutott (git tag-ek tanúsítják), és +**van saját, provider-agnosztikus domain composition** — +`schemas/examples/storage-bucket.yaml` (`StorageBucket`), `make validate` alatt +zöld — lásd a README "Aktuális állapot" táblázatát. |---|---| | git init + `git merge base@0.5.0` | **defined** | | `dependency.yaml` (D-007) | **defined** | | `project.yaml` | **defined** | | `schemas/` struktúra | **defined** | | aggregate skeletonök (4 db) | **defined** | -| atomic layer (7 atom) | **defined** | +| atomic layer (8 atom) | **defined** | | aggregate completion (atomic ref-ek) | **defined** | | domain példa (`schemas/examples/kubernetes-pod.yaml`) | **defined** | | `make validate` zöld | **defined** | @@ -166,15 +179,15 @@ Phase 1–7 végrehajtva. A repo első signed release-szel lezárt. | első signed release (`primitives/@v0.1.0`) | **defined** — Phase 7 | | ExecutionSurface aggregate | **concept** — D-009, Relay modell után | | build_hash tényleges build env-vel | **concept** — jelenleg = source_hash | -| `make release` yq PATH fix | **concept** — release.sh Makefile integrációban | +| `make release` yq PATH fix | **defined** — yq telepítve a Dockerfile-ban | --- -## Release folyamat (Phase 7 tanulságok) +## Release folyamat (a `cic-primitives`-tól örökölt tanulságok, `storage/*` névtérre igazítva) ```bash # Előfeltételek -git checkout -b primitives/releases/vX.Y.Z +git checkout -b storage/releases/vX.Y.Z tools/vault-sign-agent.sh -k -c # Release @@ -183,10 +196,8 @@ export VAULT_TOKEN=$(cat $XDG_RUNTIME_DIR/vault/sign-token) export VAULT_SKIP_VERIFY=1 make release -# Ha a make release yq PATH hibán bukik: -tools/release.sh project.yaml git add project.yaml -git tag -a "primitives/@vX.Y.Z" -m "release: X.Y.Z" +git tag -a "storage/@vX.Y.Z" -m "release: X.Y.Z" ``` -Dockerfile követelmény: `git`, `curl`, `jq` + `safe.directory /app`. +Dockerfile követelmény: `git`, `curl`, `jq`, `yq` + `safe.directory /app`. diff --git a/docs/en/concept/primitive-model.md b/docs/en/concept/primitive-model.md index d779499..e977431 100644 --- a/docs/en/concept/primitive-model.md +++ b/docs/en/concept/primitive-model.md @@ -22,7 +22,7 @@ The primitive model exists to prevent this. A primitive is an **irreducible semantic atom** — something that cannot be broken down further without losing infrastructure management meaning. -There are exactly 7 atomic primitives: +There are exactly 8 atomic primitives: | Atom | What it captures | |---|---| @@ -33,6 +33,7 @@ There are exactly 7 atomic primitives: | **Address** | How the entity is reachable | | **Identity** | What it is (type identity, not instance identity) | | **Event** | What async signals it can emit | +| **Access** | Who can read, who can write — cert-based identity (D-011) | These are not YANG concepts, not RESTCONF concepts — they are the semantic layer *beneath* any specific schema language. diff --git a/docs/en/concept/primitive-model.meta.yaml b/docs/en/concept/primitive-model.meta.yaml index 481c5ce..df767e0 100644 --- a/docs/en/concept/primitive-model.meta.yaml +++ b/docs/en/concept/primitive-model.meta.yaml @@ -4,6 +4,6 @@ entrypoint: true related_nodes: [] used_in: [] description: | - Why the primitive layer exists: the schema proliferation problem, 7 atomic + Why the primitive layer exists: the schema proliferation problem, 8 atomic primitives, sealed/defaulted/required slot contract, git-based composition, the three closure questions. diff --git a/docs/hu/concept/primitiv-modell.md b/docs/hu/concept/primitiv-modell.md index a51d42c..5c466a5 100644 --- a/docs/hu/concept/primitiv-modell.md +++ b/docs/hu/concept/primitiv-modell.md @@ -23,7 +23,7 @@ A primitív modell ezt akadályozza meg. Primitív = **irreducibilis szemantikai atom** — ami nem bontható tovább anélkül, hogy infrastruktúra-menedzsment szemantikát veszítene. -Pontosan 7 atomi primitív van: +Pontosan 8 atomi primitív van: | Atom | Mit ragad meg | |---|---| @@ -34,6 +34,7 @@ Pontosan 7 atomi primitív van: | **Address** | Hogyan érhető el az entitás | | **Identity** | Mi az (típusazonosság, nem példányazonosság) | | **Event** | Milyen aszinkron jelzéseket tud kibocsátani | +| **Access** | Ki olvashatja, ki írhatja — cert-alapú identity (D-011) | Ezek nem YANG fogalmak, nem RESTCONF fogalmak — ezek az a szemantikai réteg, amely *alatt* minden konkrét sémanyelv van. diff --git a/docs/hu/concept/primitiv-modell.meta.yaml b/docs/hu/concept/primitiv-modell.meta.yaml index 693df06..b5071c4 100644 --- a/docs/hu/concept/primitiv-modell.meta.yaml +++ b/docs/hu/concept/primitiv-modell.meta.yaml @@ -4,5 +4,5 @@ entrypoint: true related_nodes: [] used_in: [] description: | - Miért létezik a primitív réteg: a sémaproliferáció probléma, 7 atomi primitív, + Miért létezik a primitív réteg: a sémaproliferáció probléma, 8 atomi primitív, sealed/defaulted/required slot contract, git-alapú kompozíció, a három lezárási kérdés. diff --git a/project.schema.yaml b/project.schema.yaml index 87d04e4..ec700d3 100644 --- a/project.schema.yaml +++ b/project.schema.yaml @@ -90,28 +90,48 @@ properties: type: string meta_schema_file: type: string + primitive_schema_file: + type: string + description: "Path to the primitive meta-schema (schemas/index.yaml). Enables primitive YAML validation pass." vault_key_name: type: string + x-cic: + type: object + description: "CIC-specific extension fields — repo type declaration." + properties: + repo_type: + type: string + description: "Semantic repo type (primitive | schema | domain | adapter). Not yet enforced by compiler." + enum: [primitive, schema, domain, adapter] + release: type: object - description: "Contains the cryptographic proof for a given release version. This block is populated by the CI/CD pipeline." + description: "Cryptographic provenance block — present only on releases/* branches and tagged commits. Populated by the release pipeline, not hand-edited." properties: version: type: string - description: "The semantic version of this release." + description: "Semantic version of this release." timestamp: type: string format: date-time - description: "The exact timestamp when this release was built and signed." + description: "UTC timestamp of the release build." repository_tree_hash: type: string - description: "The Git tree hash of the entire repository at the moment of release, proving the integrity of the full codebase." + description: "Reproducible SHA256 (base64) of the Git tree at release — source provenance." + build_hash: + type: string + description: "Reproducible SHA256 (base64) of the build artifact." sign: type: string - description: "A cryptographic signature from Vault for the 'release' block content." + description: "Vault Transit ECDSA signature over the release block content." + cert: + type: string + description: "PEM-encoded X.509 developer certificate used for signing (mTLS identity anchor)." required: - version - timestamp - repository_tree_hash - - sign \ No newline at end of file + - build_hash + - sign + - cert \ No newline at end of file diff --git a/project.yaml b/project.yaml index f565938..1f84223 100644 --- a/project.yaml +++ b/project.yaml @@ -1,8 +1,8 @@ project: - name: cic-primitives - description: CIC meta-schema layer — atomic and aggregate primitives + name: cic-storage + description: CIC storage domain — provider-agnostic storage intent schemas (StorageBucket) composed from cic-primitives atoms/aggregates; provider-specific creation/management is delegated to separate cic-module- modules via the cic:provider ABI license: CC-BY-NC-SA-4.0 - main_branch: primitives/main + main_branch: storage/main owner: Gabor Zoltan Sinko maintenance: status: active @@ -12,43 +12,15 @@ project: value: sinkog@centralinfracore.hu links: - name: repository - url: https://github.com/CentralInfraCore/cic-primitives.git + url: https://github.com/CentralInfraCore/cic-storage.git compiler_settings: meta_schemas_dir: ./ source_dir: schemas meta_schema_file: md.meta.schema.yaml primitive_schema_file: schemas/index.yaml vault_key_name: cic-my-sign-key + vault_cert_path: "cic-my-sign-key/crt/bar" + owner_email: sinkog@centralinfracore.hu + validity_days: 365 x-cic: - repo_type: primitive -release: - version: 0.1.2 - timestamp: '2026-05-04T12:43:51.537399+00:00' - repository_tree_hash: ICIjhrN+UjF+tiTxgIxgzZ9Tpd2DQYrCnRXhXWYEe5g= - build_hash: qSEb6nHZ2/ghL0gfeg26q5Ln0obNCxh3QHLiH6+xybQ= - sign: vault:v1:MEQCIH1llzAAh9oZ0JDiuZxS2IyIejtKgnM+kCvSkHUp6nwKAiBPS6E4IazLadvEz7kMsXsJj/vcNJX3IyGD3/3cBwVZQQ== - cert: '-----BEGIN CERTIFICATE----- - - MIICBjCCAaygAwIBAgIUSnRMR6RPnEbg296XWPOqq/u5PCwwCgYIKoZIzj0EAwIw - - QzELMAkGA1UEBhMCSFUxGTAXBgNVBAoMEENlbnRyYWxJbmZyYUNvcmUxGTAXBgNV - - BAMMEENJQyBEZXZlbG9wZXIgQ0EwHhcNMjYwMzIwMTMyMjU5WhcNMjYxMjMxMTMy - - MjU5WjBFMQswCQYDVQQGEwJIVTEZMBcGA1UECgwQQ2VudHJhbEluZnJhQ29yZTEb - - MBkGA1UEAwwSR2Fib3IgWm9sdGFuIFNpbmtvMFkwEwYHKoZIzj0CAQYIKoZIzj0D - - AQcDQgAEIG2CVmTfmLB9pLLclj7YmP2eedAjklpy4LGrU2ijoiy6Xqpuybv7OgJe - - i+ez31s65NEV8+X/ByeX1cstR988z6N8MHowCQYDVR0TBAIwADAdBgNVHQ4EFgQU - - yZN6AIX/TNnIJ9GwAa/NRN3ujHAwHwYDVR0jBBgwFoAUXn6CHYzPUqU4JVP8g+OS - - WeDYjhcwDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEF - - BQcDBDAKBggqhkjOPQQDAgNIADBFAiEA+bFzXRoJ4PCQbhAAtpkcMjt0vNj5rEW0 - - lOMBGDNyaWkCIB1vmM7PcZzv/c9bIrxF5kqv6QXomouhByUfeNUTbpKW - - -----END CERTIFICATE-----' + repo_type: domain diff --git a/proposals/atom-grammar/README.md b/proposals/atom-grammar/README.md new file mode 100644 index 0000000..5f68ed1 --- /dev/null +++ b/proposals/atom-grammar/README.md @@ -0,0 +1,426 @@ +# Atom-grammatika — P0.1 / P0.2 / P0.5 lezárása + +**Státusz:** `implemented` — a `make grammar` a `make validate` **előfeltétele**, +tehát valódi kapu, nem dokumentum. Mérve: egy injektált defekt (`replicas` +default 1 → 0 a saját `range: "1..1000"` contractja ellen) `make validate`-et +`Error 1`-gyel elhasította, és a compiler el sem indult. +A `proposals/` alatti hely szándékos: a `compiler.py` egyik globja sem szedi +fel, tehát a grammatika **kapuz, de nem kerül a release-bundle-be** és nem lesz +aláírt séma-artifact. Ha ez megváltozik, az külön döntés. +**Készült:** 2026-08-10, orchestrátori munka. +**Hatókör:** a külső review P0-listájából az a három tétel, amihez a lezárt +`cic-object-model` nem ért hozzá: + +| P0 | Tétel | Itt | +|---|---|---| +| 1 | Shape típusalgebra | §2 | +| 2 | Role kombinációs szabályok | §3 | +| 5 | Primitívek közötti constraintek | §4 | + +**Nincs benne** (szándékosan): P0.3 (Contract typed payload), P0.4 (Access +policy-grammatika), P0.6 (teljes meta-séma csere). A P0.4 érdemi részét a +`cic-object-model` SPEC §6.4 már megírta; az a repo archivált, de a szöveg +kiemelhető. A P0.3 önálló munka. + +--- + +## 1. Mit rögzít ez a dokumentum, és mit nem + +A `schemas/atomic/*.yaml` fájlok **fogalomkatalógusok**: leírják, mi az a Shape +és mi az a Role. Amit nem írnak le: **hogyan néz ki egy konkrét példány**. Ez a +dokumentum a példányformát rögzíti, és csak azt. + +A grammatika a **korpusz tényleges kódolására** épül, nem elméleti alakra. Mérve +2026-08-10, két élő kompozíción (`primitives/schemas/examples/kubernetes-pod.yaml`, +`cic-compute` `main:schemas/domain/compute-resource.yaml`), összesen 83 `shape_type` +és 32 `role` előfordulás. + +Ez tudatos döntés: a korpusz prefix-kódolást használ (`shape_type:`, +`scalar_type:`, `collection_variant:`), nem csoportosat (`shape: {type: ...}`). +Az archivált objektummodell a csoportosat írta elő (INV-023 környéke), de az a +keret nem él. Egy grammatika, ami a meglévő 65+ előfordulást elsőre elutasítja, +nem grammatika, hanem migrációs teher. **A csoportos alak később, egy expliciten +verziózott lépésben jöhet — de akkor konverterrel, ne kézzel.** + +### Amit a korpusz mérése kiderített + +| Megfigyelés | Következmény | +|---|---| +| `role` **7 értéket** definiál, a korpusz **4-et használ**: `config` (12), `state` (7), `operational` (7), `key` (6) | a `reference`, `derived`, `volatile` **nulla** példánnyal van alátámasztva — §3 ezért nem egyenrangúként kezeli őket | +| a `reference` a korpuszban **nem** `shape_type`, hanem `scalar` + `semantic_type: cic-reference` + `reference_target` | §2.4 ezt teszi normatívvá, a `shape_type: reference`-t elavulttá | +| `mandatory:` (11) és `optional:` (21) egymás mellett élő, egymást átfedő jelölés | §2.5 egyetlen tengelyre teszi őket | +| `item_fields` (6) és `key_fields` (1) — a collection kulcsa létezik | §2.3 rögzíti, kötelezővé teszi `list` variánsnál | + +--- + +## 2. Shape — típusalgebra (P0.1) + +### 2.1 A típushalmaz zárt + +``` +shape_type ∈ { scalar, composite, collection, choice, opaque } +``` + +Öt érték, és **csak** ez az öt. A katalógus `reference` típusa megszűnik +(→ §2.4), az `opaque` új (→ §2.6). + +Minden `shape_type`-hoz **pontosan egy** kötelező kísérőmező tartozik. Ez az, +ami ma hiányzik: a katalógus felsorolja a típusokat, de nem mondja meg, mi +teszi őket teljessé. + +| `shape_type` | Kötelező kísérő | Tilos | +|---|---|---| +| `scalar` | `scalar_type` | `fields`, `item_fields`, `cases` | +| `composite` | `fields` (≥1 elem) | `scalar_type`, `item_fields`, `cases` | +| `collection` | `collection_variant` + (lásd §2.3) | `fields`, `cases` | +| `choice` | `cases` (≥2 elem) | `scalar_type`, `fields`, `item_fields` | +| `opaque` | — | minden szerkezeti kísérő | + +### 2.2 `scalar` + +``` +scalar_type ∈ { string, integer, boolean, number, bytes } +``` + +A katalógus ezt így sorolja fel, és a korpusz csak `string`/`integer`-t használ. +A halmaz zárt marad; új skalártípus **verzió-inkrementum**. + +Eldöntve: a `scalar_type` **külön mező**, nem a `shape_type` altípusa. A review +felveti, hogy lehetne `type: integer` közvetlenül. Nem lehet: a `shape_type` +a *szerkezeti arity* (skalár-e vagy összetett), a `scalar_type` az *érték +tartománya*. Összevonva a `composite`-nak nem lenne párja, és a korpusz 74 +`scalar_type` előfordulását át kellene írni. Külön marad. + +### 2.3 `collection` + +``` +collection_variant ∈ { list, set } +``` + +| | `list` | `set` | +|---|---|---| +| Elemleírás | `item_fields` (≥1) | `item_scalar_type` | +| Kulcs | lásd lent — **kötelező**, de nem mindig kiírva | tilos | +| Rendezettség | `ordered: bool` (default `false`) | mindig rendezetlen | +| Elemszám | `min_items` / `max_items` (opcionális, `0 ≤ min ≤ max`) | ua. | + +**Minden listának van kulcsa.** Kulcs nélkül egy elem címe a pozíciója, és a +pozíció beszúráskor elmozdul. Egy rendszerben, ahol a bizonyíték állapotra +hivatkozik, a néma jelentésváltozás defektus. (Ez ugyanaz az érv, amivel az +archivált objektummodell az anonim Contract-listát elutasította — az érv +túléli a keretet.) + +A kulcs **kétféleképpen** deklarálható, és a szabály feltételes: + +| Eset | Szabály | +|---|---| +| pontosan egy `item_fields` elem visel `role: key`-t | a kulcs **levezethető**, nem kell kiírni | +| kettő vagy több visel | `item_key: [név, ...]` **KÖTELEZŐ** — az összetett kulcs **sorrendje** különben definiálatlan | +| egyik sem visel | hiba: a listának nincs kulcsa | + +Ha az `item_key` ki van írva, minden eleme MUST szerepeljen az `item_fields` +között, és MUST `role`-ja `key` legyen (→ §4, C8). + +> **Miért `item_key` és nem `key_fields`.** A `key_fields` név a korpuszban +> **már foglalt**, és mást jelent: a `binding_surface.addresses[].key_fields` +> az Address kulcskomponenseit sorolja fel `{name, type, values}` objektumként +> (`compute-resource.yaml:489`). A két fogalom nem ugyanaz és nem is azonos +> alakú. Az első változat ezt a nevet vitte el — a korpusz futtatása derítette +> ki, nem az átolvasás. + +> **Mérés:** a két élő kompozíció mind a **6** listája egykulcsú, tehát ma +> egyik sem igényel `item_key`-t. A feltételes szabály tehát nem migrációs +> teher — a jövőbeli összetett kulcsot zárja le, amit ma semmi nem definiál. + +### 2.4 `reference` — annotáció, nem típus + +A katalógus a `reference`-t `shape_type` értékként sorolja fel. A korpusz nem +így írja: + +```yaml +- name: network + shape_type: scalar + scalar_type: string + semantic_type: cic-reference + reference_target: "cic:network:NetworkInterface" +``` + +**A korpusz nyer.** Egy referencia a huzalon string; hogy referencia, az +szemantikai annotáció, nem szerkezeti arity. A `shape_type: reference` elavult, +és a grammatika elutasítja. + +#### A referencia-nyelvtan (két külön dolog, ma összekeverve) + +| | Mi | Alak | Hol | +|---|---|---|---| +| **Céltípus** | melyik Kind-ra mutat | `{namespace}:{Kind}` | `reference_target` | +| **Érték** | melyik példányra mutat | `cic:{domain}:{backend}:{provider}:{location}:{id}` | a mező futásidejű értéke | + +A `namespace` **már tartalmazza** a `cic:` prefixet (`identity.yaml:81,87,93`: +`"cic:core"`, `"cic:network"`, `"cic:kubernetes"`). + +**Defektus, amit ez javít:** a `shape.yaml:83` a céltípust +`cic:{namespace}:{Kind}` alakban dokumentálja. Behelyettesítve a saját +namespace-értékeit: `cic:cic:network:NetworkInterface`. A korpusz helyesen +`"cic:network:NetworkInterface"`-t ír, tehát `{namespace}:{Kind}`. +**A dokumentált formátum a hibás, nem a korpusz.** A `shape.yaml:83` +javítandó. + +**Második defektus:** `identity.yaml:55` `base` példája `"cic:ManagedEntity"` +— két szegmens, namespace nélkül. Az ugyanennek a fájlnak a `:89`/`:95` példái +`"cic:core:ManagedEntity"`-t írnak. A kétszegmensű alak nem érvényes; +`identity.yaml:55` javítandó. + +`semantic_type: cic-reference` és `reference_target` **együtt kötelezők**, egyik +sem állhat a másik nélkül (→ §4, C9). + +### 2.5 `optional` / `mandatory` / `nullable` / `default` + +Ma a `mandatory` (11 előfordulás) és az `optional` (21) egymás mellett él, +egymás tagadásaként — de semmi nem tiltja, hogy mindkettő `true` legyen. + +**Egyetlen tengely, `presence`:** + +``` +presence ∈ { mandatory, optional } default: mandatory +``` + +A `mandatory: true` és `optional: true` rövid alakok **megmaradnak** +(a korpusz ezeket írja), de: + +- egyszerre legfeljebb az egyik jelenhet meg; +- `mandatory: false` és `optional: false` **tilos** — a tagadó alak nem + fejez ki semmit, csak a másik mezőt tenné kétértelművé. + +**Az érvényes kombinációk teljes mátrixa:** + +| `presence` | `nullable` | `default` | Érvényes? | Jelentés | +|---|---|---|---|---| +| mandatory | false | — | ✔ | jelen kell lennie, értékkel | +| mandatory | true | — | ✔ | jelen kell lennie, lehet `null` | +| mandatory | — | van | ✘ | **kötelező mezőnek nincs értelme default** | +| optional | false | — | ✔ | hiányozhat; ha hiányzik, nincs érték | +| optional | false | van | ✔ | hiányozhat; ha hiányzik, a default lép be | +| optional | true | — | ✔ | hiányozhat vagy `null` — **két különböző állapot** | +| optional | true | `null` | ✘ | a default `null` nem különbözik a hiánytól | + +Az utolsó két sor a lényeg: `optional + nullable` esetén a „hiányzik" és a +„`null`" **nem ugyanaz**, és aki ezt kihasználja, annak a Contract oldalon meg +kell mondania, melyik mit jelent. A `default: null` viszont a kettőt +összemossa, ezért tilos. + +### 2.6 `opaque` + +Új típus, mert a korpuszban ma nincs mód azt mondani, hogy „ez egy blob, a +séma nem néz bele". Enélkül ilyet vagy `composite`-nak hazudnak (és akkor a +validátor a belsejét is állítja tudni), vagy `string`-nek (és akkor elveszik, +hogy szerkezet van benne). + +Az `opaque` **terminális**: nincs alatta séma-ismert gyermek, és a +validátor nem lép bele. A `contract` továbbra is alkalmazható rá +(pl. `pattern` a szerializált alakra), de a belső szerkezetére nem. + +--- + +## 3. Role — kombinációs algebra (P0.2) + +A review központi megfigyelése helyes: a hét felsorolt érték **nem egy +dimenzió**. Három tengely van, és a mai lapos lista összekeveri őket. + +``` +authority ∈ { config, state, operational } pontosan egy, KÖTELEZŐ +structural ⊆ { key, reference } nulla vagy több +lifecycle ∈ { derived, volatile } nulla vagy egy +``` + +### 3.1 Rövid és hosszú alak + +A korpusz mind a 32 helyen rövid alakot ír (`role: config`, `role: key`). Ez +megmarad: + +```yaml +role: config # ⇔ role: { authority: config } +role: state # ⇔ role: { authority: state } +role: operational # ⇔ role: { authority: operational } +role: key # ⇔ role: { authority: config, structural: [key] } +role: derived # ⇔ role: { authority: state, lifecycle: derived } +role: volatile # ⇔ role: { authority: state, lifecycle: volatile } +``` + +Hosszú alak akkor kell, ha egynél több tengelyt akarsz megnevezni: + +```yaml +role: + authority: state + structural: [reference] + lifecycle: volatile +``` + +**A rövid alak feloldása kötött, nem ízlés kérdése:** + +- a `key` `authority`-ja `config`, mert a lista kulcsát a management plane adja + meg létrehozáskor; +- a `derived` és a `volatile` `authority`-ja `state`, mert mindkettő kizárja a + `config`-ot (tehát az alapértelmezés nem alkalmazható rájuk), a `role.yaml` + mindkettőt `config false` / GET-only alakra képezi, és a korpuszban mindkettő + `state_surface`-en áll. Aki `operational`-t akar velük, hosszú alakot ír; +- a `reference` `authority`-ja **nem** vezethető le — ezért a `role: reference` + rövid alak **tilos**, hosszú alakot kell írni. + +> **Korrekció (a fanout mérése).** Korábban azt írtam ide, hogy a `derived`, +> `volatile` és `reference` **nulla** korpusz-példánnyal rendelkezik. Ez két +> kompozíció mérésén alapult, és a `derived`/`volatile` esetében **téves**: a +> `cic-network` `schemas/examples/network-interface.yaml`-ja mindkettőt rövid +> alakban írja, pontosan a `role.yaml` saját példáival (`effective_state`, +> `last_seen`). A grammatika első változata ezért elutasította volna egy létező, +> helyes kompozíciót. A `reference`-re az állítás továbbra is áll: 0 előfordulás. + +### 3.2 Az érvényes kombinációk + +| Szabály | Indok | +|---|---| +| `authority` pontosan egy — kihagyva `config` | egy mező vagy kívánt állapot, vagy megfigyelt, vagy számított; a három kizárja egymást | +| `derived` **kizárja** a `config`-ot | számított értéket nem lehet kívánt állapotként beállítani | +| `volatile` **kizárja** a `config`-ot | nem perzisztens értéknek nincs kívánt állapota | +| `key` **megköveteli** a `config`-ot | a kulcsot a kérő adja meg | +| `key` **megköveteli** a `mandatory`-t | kulcs nem hiányozhat | +| `key` **csak** `collection.key_fields`-ben álló mezőn | máshol nincs mit azonosítania | +| `key` **kizárja** a `derived`-et és a `volatile`-t | a kulcs a létrehozás után nem változhat | +| `reference` **megköveteli** a `semantic_type: cic-reference`-t | különben nincs mire mutatnia | + +**Ami így legális és ma leírhatatlan** — pont a review példája: + +```yaml +role: + authority: state + structural: [reference] + lifecycle: volatile +``` + +Megfigyelt, másik entitásra mutató, nem perzisztált érték. Ma ezt `role: state`-nek +kellene írni, és a másik két tulajdonság elveszne. + +### 3.3 A három alátámasztatlan érték + +A `reference`, `derived`, `volatile` **nulla** korpusz-példánnyal rendelkezik. +Nem törlöm őket — a `derived` és a `volatile` a `lifecycle` tengelyt hordozza, +és annak a hiánya valódi kifejezőerő-hiány lenne. De jelezni kell: ezek +`concept` státuszúak, és az első valódi használatuk fogja kideríteni, hogy a +fenti szabályok helyesek-e. + +--- + +## 4. Primitívek közötti contractek (P0.5) + +Ez az, amit ma **semmi** nem kényszerít ki, és amiért a nyolc atom nyolc +külön dokumentum marad ahelyett, hogy egy nyelv lenne. + +| # | Szabály | Miért | +|---|---|---| +| **C1** | `authority ∈ {state, operational}` → `access.modify` csak adapter-mintát tartalmazhat | megfigyelt állapotot nem a felhasználó ír; ha mégis, az nem state, hanem config | +| **C2** | `lifecycle = derived` → `access.modify` üres | számított értékre az írás értelmezhetetlen | +| **C3** | `default` MUST kielégítse a node **összes** `contract` bejegyzését | ma egy `default: 0` és egy `range: "1..256"` békésen megfér egymás mellett | +| **C4** | `mandatory` és `default` együtt tilos | §2.5 mátrix | +| **C5** | `contract type: enum` → a `default` MUST az értéklistában legyen | a C3 speciális esete, külön nevesítve, mert ez a leggyakoribb | +| **C6** | `behavior.input` / `.output` MUST létező Shape-re oldódjon fel | ma bármilyen string állhat ott | +| **C7** | `event.payload` MUST létező Shape-re oldódjon fel | ua. | +| **C8** | minden listának van kulcsa; összetett kulcsnál `item_key` kötelező, és minden eleme `role: key`-es `item_fields` elem | §2.3 | +| **C9** | `semantic_type: cic-reference` ⇔ `reference_target` | egyik sem állhat a másik nélkül | +| **C10** | `reference_target` MUST `{namespace}:{Kind}` alakú legyen, és a `{namespace}` MUST `cic:`-vel kezdődjön | §2.4 | + +### Amit szándékosan NEM zárok le + +**A `capability` mechanizmus — és amit a `conformance`-ról elárul.** + +A korpusz futtatása egy olyan mezőpárt talált, amit egyik atom sem ismer: + +```yaml +- name: availability_zone + shape_type: scalar + scalar_type: string + role: config + optional: true + capability: cloud # ← csak cloud backenden létezik + +- name: power_state + capability_values: + paused: hypervisor_suspend # ← ez az ÉRTÉK csak ezzel a capabilityvel áll elő +``` + +Mérve a `cic-compute` `main:schemas/domain/compute-resource.yaml`-ben: +**19 `capability`** és 1 `capability_values` előfordulás, mind mezőszinten. +Ezzel szemben: + +- `grep -rn capability schemas/atomic/` → **0 találat**. Egyetlen atom sem ismeri. +- `managed-entity.yaml:105` — `capability_surface: {type: TBD, status: placeholder, + blocked_by: "CapabilitySurface aggregate (no model yet)"}`. + +Vagyis a `capability_surface` **placeholderként, blokkoltként** van deklarálva +az aggregate rétegben, miközben a mechanizmus a példány rétegben **már 19 +helyen élesben fut**, séma és validáció nélkül. + +Ez a `conformance`-kérdés harmadik, független bizonyítéka. A három együtt: + +1. a külső review szerint a `conformance` szemantikailag kilóg az Accessből; +2. maga a D-012 szövege mondja: *„the adapter's runtime annotation"*; +3. a korpuszban **már van** egy adapter-feltételes mezőlétezés-mechanizmus + (`capability`), csak nincs modellezve. + +A `capability: cloud` (a mező csak bizonyos backenden létezik) és a +`conformance: not_implemented` (a mező ezen az eszközön nem létezik) +**ugyanaz a fogalom, két néven**. A grammatika ezért mindkettőt átengedi +ismert, de nem validált tagként — nem az én dolgom eldönteni, melyik marad. + +**A `conformance` mező helye.** A `schemas/atomic/access.yaml` hordozza +(D-012, `574a1f1`), a `v0.1.0` release még nem tartalmazta, és maga a D-012 +szövege mondja: *„conformance is the adapter's runtime annotation, not a +schema-level removal."* A külső review szerint szemantikailag kilóg az +Accessből. Egyetértek — de az, hogy hova tartozik (BindingSurface? +CapabilitySurface? önálló atom?), **döntés, nem levezetés**. Amíg nincs +eldöntve, a grammatika nem érinti. + +**Az `access` belső nyelvtana (P0.4).** Az `inherit` háromállapotúsága +(`true` / `false` / `0`) booleanként nem stabil típus — a review-nak igaza van, +és az archivált objektummodell sem javította, szó szerint átvette. Valódi enum +kell (`inherit` / `no-inherit` / `reset`), de az `access` teljes +policy-grammatikájával együtt, nem külön. + +**Az Address kontra ManagedEntity ellentmondás.** `address.yaml`: *„An entity +may exist without an address"*; `managed-entity.yaml` `binding_surface: +mode: required`. Ez a kettő nem fér meg. A feloldás valószínűleg az, hogy a +`binding_surface` `defaulted`, üres alapértékkel — de ez az aggregate réteg +döntése, nem az atomoké. + +--- + +## 5. Hogyan ellenőrizhető + +``` +proposals/atom-grammar/ + instance-grammar.schema.yaml ← JSON Schema: §2 szerkezeti szabályok + check_grammar.py ← séma + a §3/§4 keresztszabályok (amit JSON Schema nem tud) +``` + +**Kapuként** (ez fut a `make validate` előtt): + +```bash +make grammar # self-test, majd a repo kompozíciói +make validate # a grammar az előfeltétele +``` + +**Közvetlenül**, konténer nélkül — ez a fontosabb futtatási mód: + +```bash +python3 proposals/atom-grammar/check_grammar.py --self-test +python3 proposals/atom-grammar/check_grammar.py schemas/examples/kubernetes-pod.yaml +``` + +A `check_grammar.py` **PyYAML + jsonschema**, konténer nélkül fut, mert egy +grammatika, amit csak a saját pipeline-ja tud futtatni, nem ellenőrizhető +harmadik fél által. Többdokumentumos (`---`) bemenetre is felkészült, és +szintaktikailag hibás YAML-on leletet ad, nem tracebacket — a repo saját +negatív fixture-jei ilyenek. + +**A kapu vakfoltja ellen:** a `check_grammar.py --self-test` szándékosan hibás +példányokat futtat át, és elhasal, ha bármelyiket átengedi. Egy validátor, +amiről nem mérted le, hogy tud bukni, nem validátor. diff --git a/proposals/atom-grammar/check_grammar.py b/proposals/atom-grammar/check_grammar.py new file mode 100644 index 0000000..40bebcd --- /dev/null +++ b/proposals/atom-grammar/check_grammar.py @@ -0,0 +1,567 @@ +#!/usr/bin/env python3 +"""Validate CIC composition instances against the atom grammar. + +Two layers, deliberately separated: + + * section 2 (structural type algebra) -> instance-grammar.schema.yaml, JSON Schema + * sections 3 and 4 (role algebra, inter-primitive contracts) -> this file, + because they cross node and primitive boundaries and JSON Schema cannot + express them + +Usage: + check_grammar.py [ ...] + check_grammar.py --self-test + +Dependencies: PyYAML, jsonschema. Runs outside any container on purpose: a +grammar only its own pipeline can run is not third-party verifiable. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +import yaml +from jsonschema import Draft202012Validator + +SCHEMA_PATH = Path(__file__).with_name("instance-grammar.schema.yaml") + +# Certificate patterns that count as an adapter identity for C1. The adapter is +# the only writer of observed state; anything else writing it means the field +# was misclassified. +ADAPTER_PATTERN = re.compile(r"OU=adapters\b") + +RANGE_RE = re.compile(r"^\s*(-?\d+)\s*\.\.\s*(-?\d+)\s*$") +NAMESPACE_RE = re.compile(r"^cic:[a-z][a-z0-9-]*$") + +SCALAR_PY_TYPES = { + "string": str, + "integer": int, + "boolean": bool, + "number": (int, float), + "bytes": str, +} + + +class Finding: + def __init__(self, rule: str, path: str, message: str): + self.rule = rule + self.path = path + self.message = message + + def __str__(self) -> str: + return f" [{self.rule}] {self.path}\n {self.message}" + + +# ────────────────────────────────────────────────────────────────────────────── +# Role normalization (README section 3.1) +# ────────────────────────────────────────────────────────────────────────────── + +SHORT_ROLE_EXPANSION = { + "config": {"authority": "config", "structural": [], "lifecycle": None}, + "state": {"authority": "state", "structural": [], "lifecycle": None}, + "operational": {"authority": "operational", "structural": [], "lifecycle": None}, + # A key is supplied by the requester at creation, so its authority is config. + "key": {"authority": "config", "structural": ["key"], "lifecycle": None}, + # `derived` and `volatile` are lifecycle values, and both exclude authority + # `config` — so the bare default cannot apply to them. They expand to + # `state`: role.yaml maps both to `config false`, GET only, and every corpus + # occurrence sits on a state_surface. Anyone wanting `operational` with + # either must write the long form. + "derived": {"authority": "state", "structural": [], "lifecycle": "derived"}, + "volatile": {"authority": "state", "structural": [], "lifecycle": "volatile"}, +} + + +def expand_role(raw, path: str, out: list[Finding]) -> dict | None: + """Return the long form of a role, or None if it is unusable.""" + if raw is None: + return {"authority": "config", "structural": [], "lifecycle": None} + if isinstance(raw, str): + if raw == "reference": + out.append(Finding( + "R-SHORT", path, + "`role: reference` has no short form: a reference's authority " + "cannot be derived. Write the long form with an explicit authority.")) + return None + if raw not in SHORT_ROLE_EXPANSION: + out.append(Finding( + "R-SHORT", path, + f"`{raw}` is not a short role form " + f"({', '.join(sorted(SHORT_ROLE_EXPANSION))})")) + return None + return dict(SHORT_ROLE_EXPANSION[raw]) + if isinstance(raw, dict): + return { + "authority": raw.get("authority", "config"), + "structural": list(raw.get("structural") or []), + "lifecycle": raw.get("lifecycle"), + } + out.append(Finding("R-SHORT", path, "`role` must be a string or a mapping")) + return None + + +# ────────────────────────────────────────────────────────────────────────────── +# Node walking +# ────────────────────────────────────────────────────────────────────────────── + +def walk_nodes(obj, path: str = "$", in_item_key: frozenset[str] = frozenset()): + """Yield (node, path, is_key_position) for every dict carrying `shape_type`.""" + if isinstance(obj, dict): + if "shape_type" in obj: + name = obj.get("name", "?") + here = f"{path}.{name}" + yield obj, here, name in in_item_key + # An explicit item_key wins; with none, a single `role: key` item + # field is the key by derivation (section 2.3). + keys = frozenset(obj.get("item_key") or [ + f.get("name") for f in (obj.get("item_fields") or []) + if isinstance(f, dict) and is_key_role(f.get("role"))]) + for child in obj.get("fields") or []: + yield from walk_nodes(child, here, frozenset()) + for child in obj.get("item_fields") or []: + yield from walk_nodes(child, here + "[]", keys) + for case in obj.get("cases") or []: + if isinstance(case, dict): + for child in case.get("fields") or []: + yield from walk_nodes(child, f"{here}<{case.get('name','?')}>", + frozenset()) + return + for k, v in obj.items(): + yield from walk_nodes(v, f"{path}.{k}", in_item_key) + elif isinstance(obj, list): + for item in obj: + yield from walk_nodes(item, path, in_item_key) + + +def collect_shape_names(doc) -> set[str]: + return {n.get("name") for n, _, _ in walk_nodes(doc) if n.get("name")} + + +# ────────────────────────────────────────────────────────────────────────────── +# Section 4 — inter-primitive contracts +# ────────────────────────────────────────────────────────────────────────────── + +def contract_entries(node) -> list[dict]: + return [c for c in (node.get("contract") or []) if isinstance(c, dict)] + + +def check_default_against_contracts(node, path, out: list[Finding]) -> None: + """C3 and C5 — a default must satisfy the node's own constraints.""" + if "default" not in node: + return + default = node["default"] + + scalar_type = node.get("scalar_type") + if scalar_type in SCALAR_PY_TYPES and default is not None: + expected = SCALAR_PY_TYPES[scalar_type] + # bool is a subclass of int in Python; keep integer strict. + if scalar_type == "integer" and isinstance(default, bool): + ok = False + else: + ok = isinstance(default, expected) + if not ok: + out.append(Finding( + "C3", path, + f"default {default!r} is not compatible with scalar_type " + f"`{scalar_type}`")) + + for c in contract_entries(node): + ctype, expr = c.get("type"), c.get("expression") + if ctype == "range" and isinstance(expr, str) and isinstance(default, int): + m = RANGE_RE.match(expr) + if m: + lo, hi = int(m.group(1)), int(m.group(2)) + if not (lo <= default <= hi): + out.append(Finding( + "C3", path, + f"default {default} is outside the declared range " + f"{lo}..{hi}")) + elif ctype == "enum": + values = expr if isinstance(expr, list) else None + if values is None and isinstance(expr, str): + values = [v.strip() for v in expr.strip("[]").split(",") if v.strip()] + if values and default not in values: + out.append(Finding( + "C5", path, + f"default {default!r} is not among the enum values {values}")) + elif ctype == "pattern" and isinstance(expr, str) and isinstance(default, str): + try: + if not re.search(expr, default): + out.append(Finding( + "C3", path, + f"default {default!r} does not match pattern {expr!r}")) + except re.error: + out.append(Finding( + "C3", path, f"pattern {expr!r} is not a valid regex")) + + +def check_access_against_role(node, role, path, out: list[Finding]) -> None: + """C1 and C2 — who may write is constrained by what the node means.""" + access = node.get("access") + if not isinstance(access, dict): + return + modify = access.get("modify") + if modify is None: + return + patterns = modify if isinstance(modify, list) else [modify] + patterns = [p for p in patterns if isinstance(p, str)] + + if role["lifecycle"] == "derived" and patterns: + out.append(Finding( + "C2", path, + "a derived value is computed, so it cannot be written: " + f"access.modify must be empty, found {patterns}")) + return + + if role["authority"] in ("state", "operational"): + foreign = [p for p in patterns if not ADAPTER_PATTERN.search(p)] + if foreign: + out.append(Finding( + "C1", path, + f"role authority `{role['authority']}` is observed state, so only " + f"an adapter may write it; these are not adapter patterns: {foreign}")) + + +def check_role_algebra(node, role, path, is_key_position, out: list[Finding]) -> None: + """README section 3.2.""" + authority = role["authority"] + structural = set(role["structural"]) + lifecycle = role["lifecycle"] + + if authority not in ("config", "state", "operational"): + out.append(Finding("R-AUTH", path, f"`{authority}` is not a valid authority")) + return + + if lifecycle in ("derived", "volatile") and authority == "config": + out.append(Finding( + "R-LIFE", path, + f"`{lifecycle}` excludes authority `config`: a {lifecycle} value has " + "no desired state")) + + if "key" in structural: + if authority != "config": + out.append(Finding( + "R-KEY", path, + f"a key is supplied by the requester, so its authority must be " + f"`config`, not `{authority}`")) + if node.get("optional"): + out.append(Finding("R-KEY", path, "a key cannot be optional")) + if lifecycle: + out.append(Finding( + "R-KEY", path, + f"a key is fixed at creation, so it cannot be `{lifecycle}`")) + if not is_key_position: + out.append(Finding( + "R-KEY", path, + "`role: key` is only meaningful on a field listed in the " + "enclosing collection's item_key")) + + if "reference" in structural and node.get("semantic_type") != "cic-reference": + out.append(Finding( + "R-REF", path, + "structural role `reference` requires `semantic_type: cic-reference`")) + + +def is_key_role(role) -> bool: + return role == "key" or (isinstance(role, dict) + and "key" in (role.get("structural") or [])) + + +def check_collection(node, path, out: list[Finding]) -> None: + """C8 — every list has a key, and a composite key states its order.""" + if node.get("collection_variant") != "list": + return + items = [f for f in (node.get("item_fields") or []) if isinstance(f, dict)] + item_names = {f.get("name") for f in items} + by_name = {f.get("name"): f for f in items} + declared = node.get("item_key") + keyed = [f.get("name") for f in items if is_key_role(f.get("role"))] + + if not declared: + if not keyed: + out.append(Finding( + "C8", path, + "a list has no key: declare `item_key`, or mark an item field " + "`role: key`. Without one an element's address is its position, " + "and a position moves when a neighbour is inserted")) + elif len(keyed) > 1: + out.append(Finding( + "C8", path, + f"{len(keyed)} item fields carry `role: key` ({keyed}), so the key " + "is composite and its order is undefined: declare `item_key` " + "explicitly")) + return + + for key in declared: + if key not in item_names: + out.append(Finding( + "C8", path, f"item_key names `{key}`, which is not an item field")) + continue + krole = by_name[key].get("role") + krole_name = krole if isinstance(krole, str) else (krole or {}).get("structural") + if not is_key_role(krole): + out.append(Finding( + "C8", path, + f"item field `{key}` is named in item_key but its role is " + f"`{krole_name}`, not key")) + + +def check_reference_target(node, path, out: list[Finding]) -> None: + """C10 — {namespace}:{Kind}, and the namespace itself carries the cic: prefix.""" + target = node.get("reference_target") + if not isinstance(target, str): + return + parts = target.split(":") + if len(parts) != 3: + out.append(Finding( + "C10", path, + f"reference_target `{target}` must be {{namespace}}:{{Kind}} where the " + "namespace is itself cic-prefixed, e.g. cic:network:NetworkInterface")) + return + namespace = ":".join(parts[:2]) + if not NAMESPACE_RE.match(namespace): + out.append(Finding( + "C10", path, f"`{namespace}` is not a valid namespace")) + if not parts[2][:1].isupper(): + out.append(Finding( + "C10", path, f"`{parts[2]}` is not a Kind (Kinds are PascalCase)")) + + +def check_shape_references(doc, out: list[Finding]) -> None: + """C6 and C7 — a named input/output/payload must resolve to a declared shape.""" + known = collect_shape_names(doc) + + def scan(obj, path): + if isinstance(obj, dict): + for member in ("input", "output", "payload"): + val = obj.get(member) + if isinstance(val, str) and val not in known: + rule = "C7" if member == "payload" else "C6" + out.append(Finding( + rule, f"{path}.{member}", + f"`{val}` does not resolve to any Shape declared in this " + "composition")) + for k, v in obj.items(): + scan(v, f"{path}.{k}") + elif isinstance(obj, list): + for item in obj: + scan(item, path) + + scan(doc, "$") + + +# ────────────────────────────────────────────────────────────────────────────── +# Driver +# ────────────────────────────────────────────────────────────────────────────── + +def load_schema(): + with SCHEMA_PATH.open() as fh: + return Draft202012Validator(yaml.safe_load(fh)) + + +def check_document(doc, validator) -> list[Finding]: + out: list[Finding] = [] + for node, path, is_key_position in walk_nodes(doc): + for err in sorted(validator.iter_errors(node), key=lambda e: list(e.path)): + out.append(Finding("S2", path, err.message)) + role = expand_role(node.get("role"), path, out) + if role is not None: + check_role_algebra(node, role, path, is_key_position, out) + check_access_against_role(node, role, path, out) + check_default_against_contracts(node, path, out) + check_collection(node, path, out) + check_reference_target(node, path, out) + check_shape_references(doc, out) + return out + + +# ────────────────────────────────────────────────────────────────────────────── +# Self-test — the gate must be shown capable of failing +# ────────────────────────────────────────────────────────────────────────────── + +MUST_REJECT = { + "scalar without scalar_type": { + "name": "a", "shape_type": "scalar", "role": "config"}, + "scalar carrying composite fields": { + "name": "a", "shape_type": "scalar", "scalar_type": "string", + "fields": [{"name": "b", "shape_type": "scalar", "scalar_type": "string"}]}, + "mandatory and optional together": { + "name": "a", "shape_type": "scalar", "scalar_type": "string", + "mandatory": True, "optional": True}, + "mandatory with a default": { + "name": "a", "shape_type": "scalar", "scalar_type": "integer", + "mandatory": True, "default": 1}, + "default outside its own range": { + "name": "a", "shape_type": "scalar", "scalar_type": "integer", + "optional": True, "default": 0, + "contract": [{"type": "range", "expression": "1..256"}]}, + "default not in its own enum": { + "name": "a", "shape_type": "scalar", "scalar_type": "string", + "optional": True, "default": "sideways", + "contract": [{"type": "enum", "expression": ["up", "down"]}]}, + "default of the wrong scalar type": { + "name": "a", "shape_type": "scalar", "scalar_type": "integer", + "optional": True, "default": "twelve"}, + "list with neither item_key nor a key-roled field": { + "name": "a", "shape_type": "collection", "collection_variant": "list", + "item_fields": [{"name": "b", "shape_type": "scalar", + "scalar_type": "string"}]}, + "composite key with undefined order": { + "name": "a", "shape_type": "collection", "collection_variant": "list", + "item_fields": [ + {"name": "b", "shape_type": "scalar", "scalar_type": "string", + "role": "key", "mandatory": True}, + {"name": "c", "shape_type": "scalar", "scalar_type": "string", + "role": "key", "mandatory": True}]}, + "item_key naming a non-key field": { + "name": "a", "shape_type": "collection", "collection_variant": "list", + "item_key": ["b"], + "item_fields": [{"name": "b", "shape_type": "scalar", + "scalar_type": "string", "role": "config"}]}, + "derived with a desired state": { + "name": "a", "shape_type": "scalar", "scalar_type": "string", + "role": {"authority": "config", "lifecycle": "derived"}}, + "user writing observed state": { + "name": "a", "shape_type": "scalar", "scalar_type": "string", + "role": "state", "access": {"modify": ["O=acme"]}}, + "writing a derived value": { + "name": "a", "shape_type": "scalar", "scalar_type": "string", + "role": {"authority": "state", "lifecycle": "derived"}, + "access": {"modify": ["OU=adapters,O=cic"]}}, + "reference without a target": { + "name": "a", "shape_type": "scalar", "scalar_type": "string", + "semantic_type": "cic-reference"}, + "double-prefixed reference target": { + "name": "a", "shape_type": "scalar", "scalar_type": "string", + "semantic_type": "cic-reference", + "reference_target": "cic:cic:network:NetworkInterface"}, + "two-segment identity reference": { + "name": "a", "shape_type": "scalar", "scalar_type": "string", + "semantic_type": "cic-reference", + "reference_target": "cic:ManagedEntity"}, + "shape_type reference, the retired form": { + "name": "a", "shape_type": "reference", "scalar_type": "string"}, + "short form role: reference": { + "name": "a", "shape_type": "scalar", "scalar_type": "string", + "role": "reference"}, +} + +MUST_ACCEPT = { + "a plain mandatory scalar": { + "name": "cpu_cores", "shape_type": "scalar", "scalar_type": "integer", + "role": "config", "mandatory": True, + "contract": [{"type": "range", "expression": "1..256"}]}, + "an optional scalar with a valid default": { + "name": "replicas", "shape_type": "scalar", "scalar_type": "integer", + "role": "config", "optional": True, "default": 1, + "contract": [{"type": "range", "expression": "1..1000"}]}, + "a keyed list with a cross-domain reference": { + "name": "network_interfaces", "shape_type": "collection", + "collection_variant": "list", "role": "config", "optional": True, + "item_key": ["name"], + "item_fields": [ + {"name": "name", "shape_type": "scalar", "scalar_type": "string", + "role": "key", "mandatory": True}, + {"name": "network", "shape_type": "scalar", "scalar_type": "string", + "semantic_type": "cic-reference", + "reference_target": "cic:network:NetworkInterface", + "role": {"authority": "config", "structural": ["reference"]}}, + ]}, + "a single-key list, the shape the corpus writes": { + "name": "env", "shape_type": "collection", "collection_variant": "list", + "role": "config", "optional": True, + "item_fields": [ + {"name": "name", "shape_type": "scalar", "scalar_type": "string", + "role": "key", "mandatory": True}, + {"name": "value", "shape_type": "scalar", "scalar_type": "string"}]}, + "the short lifecycle forms the corpus writes": { + "name": "effective_state", "shape_type": "scalar", "scalar_type": "string", + "role": "derived"}, + "the combination the flat model could not express": { + "name": "last_seen_peer", "shape_type": "scalar", "scalar_type": "string", + "semantic_type": "cic-reference", + "reference_target": "cic:network:NetworkInterface", + "role": {"authority": "state", "structural": ["reference"], + "lifecycle": "volatile"}}, +} + + +def self_test(validator) -> int: + failures = 0 + print("--- must be rejected ---") + for label, node in MUST_REJECT.items(): + findings = check_document(node, validator) + if findings: + print(f" \033[92mrejected\033[0m {label} [{findings[0].rule}]") + else: + print(f" \033[91mLEAKED\033[0m {label}") + failures += 1 + + print("\n--- must be accepted ---") + for label, node in MUST_ACCEPT.items(): + findings = check_document(node, validator) + if not findings: + print(f" \033[92maccepted\033[0m {label}") + else: + print(f" \033[91mFALSE POSITIVE\033[0m {label}") + for f in findings: + print(f) + failures += 1 + + total = len(MUST_REJECT) + len(MUST_ACCEPT) + print(f"\nself-test: {total - failures}/{total}") + return 1 if failures else 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("files", nargs="*", type=Path) + ap.add_argument("--self-test", action="store_true") + args = ap.parse_args() + + validator = load_schema() + + if args.self_test: + return self_test(validator) + + if not args.files: + ap.error("give at least one composition, or --self-test") + + status = 0 + for path in args.files: + # Multi-document safe: the repository's own negative fixtures are + # `---` separated, and a gate that tracebacks on its input is not a gate. + try: + with path.open() as fh: + docs = [d for d in yaml.safe_load_all(fh) if d is not None] + except yaml.YAMLError as exc: + status = 1 + first = str(exc).splitlines()[0] or exc.__class__.__name__ + print(f"\033[91m✗\033[0m {path} (unparseable: {first})") + continue + + findings: list[Finding] = [] + node_count = 0 + for i, doc in enumerate(docs): + prefix = "" if len(docs) == 1 else f"[doc {i}]" + node_count += sum(1 for _ in walk_nodes(doc)) + for f in check_document(doc, validator): + f.path = prefix + f.path + findings.append(f) + + if findings: + status = 1 + print(f"\n\033[91m✗\033[0m {path} ({node_count} node, " + f"{len(findings)} finding)") + for f in findings: + print(f) + else: + print(f"\033[92m✓\033[0m {path} ({node_count} node)") + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/proposals/atom-grammar/instance-grammar.schema.yaml b/proposals/atom-grammar/instance-grammar.schema.yaml new file mode 100644 index 0000000..507b02e --- /dev/null +++ b/proposals/atom-grammar/instance-grammar.schema.yaml @@ -0,0 +1,253 @@ +--- +# JSON Schema for a single CIC node instance (a member of a surface's `nodes`, +# or of a composite's `fields`, or of a collection's `item_fields`). +# +# Scope: README.md section 2 only — the structural type algebra. Rules that +# cross node boundaries or primitive boundaries (sections 3 and 4) are NOT +# expressible here and live in check_grammar.py. +# +# Draft 2020-12. + +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "https://cic.dev/schemas/instance-grammar/node.schema.yaml" +title: CIC node instance + +type: object +required: [name, shape_type] +additionalProperties: false + +properties: + # ── identity of the node within its parent ────────────────────────────────── + name: + type: string + minLength: 1 + pattern: "^[a-z][a-z0-9_]*$" + + description: + type: string + + atomic_ref: + type: string + + role_ref: + type: string + + # ── section 2.1 — the closed type set ─────────────────────────────────────── + shape_type: + enum: [scalar, composite, collection, choice, opaque] + + # ── section 2.2 — scalar ──────────────────────────────────────────────────── + scalar_type: + enum: [string, integer, boolean, number, bytes] + + # ── section 2.3 — collection ──────────────────────────────────────────────── + collection_variant: + enum: [list, set] + + item_fields: + type: array + minItems: 1 + items: {$ref: "#"} + + item_scalar_type: + enum: [string, integer, boolean, number, bytes] + + # Named `item_key`, not `key_fields`: the corpus already uses `key_fields` + # in binding_surface.addresses for the Address key components, which are a + # different thing with a different shape. + item_key: + type: array + minItems: 1 + items: + type: string + minLength: 1 + + # Adapter-conditional existence. Used 19x in cic-compute's live + # compute-resource.yaml, modelled by no atom (see README section 4). + capability: + type: string + minLength: 1 + + capability_values: + type: object + + ordered: + type: boolean + + min_items: + type: integer + minimum: 0 + + max_items: + type: integer + minimum: 0 + + # ── composite ─────────────────────────────────────────────────────────────── + fields: + type: array + minItems: 1 + items: {$ref: "#"} + + # ── choice ────────────────────────────────────────────────────────────────── + cases: + type: array + minItems: 2 + items: + type: object + required: [name, fields] + properties: + name: {type: string, minLength: 1} + fields: + type: array + minItems: 1 + items: {$ref: "#"} + + # ── section 2.4 — reference as annotation ─────────────────────────────────── + semantic_type: + enum: [cic-reference] + + reference_target: + type: string + # {namespace}:{Kind} where namespace itself starts with "cic:" + pattern: "^cic:[a-z][a-z0-9-]*:[A-Z][A-Za-z0-9]*$" + + # ── section 2.5 — presence ────────────────────────────────────────────────── + mandatory: + const: true # `mandatory: false` is forbidden; omit it instead + optional: + const: true # `optional: false` is forbidden; omit it instead + nullable: + type: boolean + default: {} + + # ── attached primitives ───────────────────────────────────────────────────── + role: + oneOf: + - enum: [config, state, operational, key, derived, volatile] # short form, 3.1 + - type: object + required: [authority] + additionalProperties: false + properties: + authority: + enum: [config, state, operational] + structural: + type: array + uniqueItems: true + items: + enum: [key, reference] + lifecycle: + enum: [derived, volatile] + + contract: + type: array + minItems: 1 + items: + type: object + required: [type, expression] + properties: + type: + enum: [range, pattern, must, when, enum, mandatory, unique] + expression: {} + error_message: {type: string} + description: {type: string} + + access: + type: object + +allOf: + # ── section 2.1 — required and forbidden companions, per type ─────────────── + - if: + properties: {shape_type: {const: scalar}} + required: [shape_type] + then: + required: [scalar_type] + not: + anyOf: + - required: [fields] + - required: [item_fields] + - required: [cases] + - required: [collection_variant] + + - if: + properties: {shape_type: {const: composite}} + required: [shape_type] + then: + required: [fields] + not: + anyOf: + - required: [scalar_type] + - required: [item_fields] + - required: [cases] + - required: [collection_variant] + + - if: + properties: {shape_type: {const: collection}} + required: [shape_type] + then: + required: [collection_variant] + not: + anyOf: + - required: [fields] + - required: [cases] + + - if: + properties: {shape_type: {const: choice}} + required: [shape_type] + then: + required: [cases] + not: + anyOf: + - required: [scalar_type] + - required: [fields] + - required: [item_fields] + + - if: + properties: {shape_type: {const: opaque}} + required: [shape_type] + then: + not: + anyOf: + - required: [scalar_type] + - required: [fields] + - required: [item_fields] + - required: [cases] + - required: [collection_variant] + + # ── section 2.3 — list needs item_fields; the key rule is conditional and + # lives in check_grammar.py (JSON Schema cannot count key-roled children) ─ + - if: + properties: {collection_variant: {const: list}} + required: [collection_variant] + then: + required: [item_fields] + + - if: + properties: {collection_variant: {const: set}} + required: [collection_variant] + then: + required: [item_scalar_type] + not: + anyOf: + - required: [item_key] + - required: [item_fields] + + # ── section 2.5 — presence is one axis ───────────────────────────────────── + - not: + required: [mandatory, optional] + + - if: + required: [mandatory] + then: + not: + required: [default] + + # ── section 2.4 — the two reference members are inseparable ──────────────── + - if: + required: [semantic_type] + then: + required: [reference_target] + + - if: + required: [reference_target] + then: + required: [semantic_type] diff --git a/release.schema.yaml b/release.schema.yaml new file mode 100644 index 0000000..8db77b3 --- /dev/null +++ b/release.schema.yaml @@ -0,0 +1,121 @@ +--- +type: object +title: "PrimitiveRelease Bundle Schema" +description: "Defines the structure of a signed release artifact produced by 'python tools/compiler.py release'. One file per release version under release/." +required: + - kind + - version + - timestamp + - validity + - createdBy + - specs + - release + +properties: + kind: + type: string + const: PrimitiveRelease + description: "Fixed discriminator — identifies this as a primitive release bundle." + + version: + type: string + description: "Semantic version of this release (e.g. '0.1.5')." + pattern: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$" + + timestamp: + type: string + format: date-time + description: "UTC timestamp of the release build (ISO 8601)." + + validity: + type: object + description: "Developer maintenance commitment window. Taken from commitment.yaml at release time." + required: + - from + - until + properties: + from: + type: string + format: date + description: "Start date of the maintenance commitment (ISO 8601 date)." + until: + type: string + format: date + description: "End date of the maintenance commitment (ISO 8601 date)." + + createdBy: + type: object + description: "Developer identity block. The certificate is bound inside the build_hash before signing." + required: + - name + - email + - certificate + properties: + name: + type: string + description: "Full name of the developer." + email: + type: string + format: email + description: "Contact email of the developer." + certificate: + type: string + description: "PEM-encoded X.509 developer certificate (identity anchor, cryptographically bound to build_hash)." + + specs: + type: array + description: "Inline schema definitions. Each entry is one source schema file, inlined at release time." + minItems: 1 + items: + type: object + required: + - id + - source_path + - meta_hash + - spec + properties: + id: + type: string + description: "Schema identifier — basename of the source file without extension." + source_path: + type: string + description: "Relative path to the original source file in the repo (traceability)." + meta_hash: + type: string + description: "SHA256 (base64) of the raw source file bytes at release time." + spec: + type: object + description: "Full inline content of the schema file." + + release: + type: object + description: "Cryptographic provenance block. build_hash is computed over canonical JSON of {createdBy (developer), releasedBy (issuer), specs, validity} — both certificates are cryptographically bound before signing. Future: build_hash will become a Merkle tree root over CI artifact layers." + required: + - createdBy + - build_hash + - sign + properties: + createdBy: + type: object + description: "Release issuer identity. Certificate is bound inside build_hash before Vault signs." + required: + - name + - email + - certificate + properties: + name: + type: string + description: "Full name of the release issuer." + email: + type: string + format: email + description: "Contact email of the release issuer." + certificate: + type: string + description: "PEM-encoded X.509 release issuer certificate (cryptographically bound to build_hash)." + build_hash: + type: string + description: "SHA256 (base64) of canonical JSON of {createdBy (developer), releasedBy (issuer), specs, validity}. Tamper-proof seal over both identities and all schema content. Future: Merkle tree root." + sign: + type: string + description: "Vault Transit ECDSA signature over build_hash (prehashed=true), verifiable with release.createdBy.certificate." diff --git a/schemas/aggregate/config-surface.yaml b/schemas/aggregate/config-surface.yaml index 5c41bda..d98321f 100644 --- a/schemas/aggregate/config-surface.yaml +++ b/schemas/aggregate/config-surface.yaml @@ -61,7 +61,7 @@ spec: description: > Írási policy referencia — ki módosíthatja ezt a config surface-t. Alapértelmezés szerint az entitás szülőjétől örökli. - Jövőbeli atomic: PolicyRef (nem része az első 7 atomnak). + Jövőbeli PolicyRef aggregate — az Access atom (8. atom) alapján. semantic_mapping: yang: diff --git a/schemas/aggregate/managed-entity.yaml b/schemas/aggregate/managed-entity.yaml index 4eea179..1e4261b 100644 --- a/schemas/aggregate/managed-entity.yaml +++ b/schemas/aggregate/managed-entity.yaml @@ -3,9 +3,9 @@ metadata: name: ManagedEntity version: v0.0.dev description: > - A CIC alap kompozíciós egysége — minden domain objektum ebből specializálódik. - Nem önálló objektum: ez a szemantikai contract amelyre switch interface, kubernetes pod, - database, service és bármi más ráül. Az objektum mindig következmény, soha nem kiindulópont. + The base compositional unit of CIC — every domain object specializes from this. + Not a standalone object: it is the semantic contract on which switch interfaces, kubernetes pods, + databases, services, and anything else are built. The object is always a consequence, never a starting point. owner: Gabor Zoltan Sinko tags: - primitive @@ -30,7 +30,7 @@ spec: used_by: [notification_surface] - atomic: Access ref: schemas/atomic/access.yaml - used_by: [policy_surface, minden field értéke implicit] + used_by: [policy_surface, implicit on every field value] aggregate_dependencies: - aggregate: ConfigSurface @@ -52,24 +52,24 @@ spec: type: Identity atomic_ref: schemas/atomic/identity.yaml description: > - Típusazonosság — Identity atom. Meghatározza mi ez az entitás a CIC-ben. - Komponensek: kind, namespace, version, base (örökíthető Identity chain). + Type identity — Identity atom. Defines what this entity is within CIC. + Components: kind, namespace, version, base (inheritable Identity chain). config_surface: mode: required type: ConfigSurface aggregate_ref: schemas/aggregate/config-surface.yaml description: > - Az írható, kívánt állapot felülete. Kötelező — minden ManagedEntity-nek - van konfigurálható állapota. + The writable desired-state surface. Required — every ManagedEntity + has a configurable state. state_surface: mode: required type: StateSurface aggregate_ref: schemas/aggregate/state-surface.yaml description: > - A csak olvasható, aktuális állapot felülete. Kötelező — minden ManagedEntity-nek - van lekérdezhető aktuális állapota. + The read-only observed-state surface. Required — every ManagedEntity + has a queryable current state. operation_surface: mode: defaulted @@ -77,18 +77,19 @@ spec: aggregate_ref: schemas/aggregate/operation-surface.yaml default: empty description: > - A végrehajtható műveletek felülete. Lehet üres — nem minden entitásnak vannak - explicit műveletei (pl. egy egyszerű config node-nak nincs restart-ja). + The executable operations surface. May be empty — not every entity has + explicit operations (e.g. a simple config node has no restart). notification_surface: mode: defaulted type: "Event[]" + status: placeholder + blocked_by: NotificationSurface aggregate (no model yet — notification routing prerequisite) atomic_ref: schemas/atomic/event.yaml default: empty description: > - Az aszinkron jelzések felülete — Event atomok listája. - Lehet üres. Jövőbeli NotificationSurface aggregate fogja köré burkolni - a subscription és routing logikát. + The asynchronous notifications surface — list of Event atoms. + May be empty. Formal NotificationSurface aggregate follows the notification routing model. policy_surface: mode: defaulted @@ -96,22 +97,39 @@ spec: aggregate_ref: schemas/aggregate/policy-surface.yaml default: open description: > - Objektum-szintű alapértelmezett jogosultsági szabályok. - Az Access atom field-szintű wrapper `inherit: true` esetén innen veszi az alapértéket. - Ha hiányzik: minden mező elérhető (open default — backward compatible). - Kiértékelési sorrend: field Access → surface_rules → default_access/modify → örökölt. + Object-level default access control rules. + The Access atom's field-level wrapper uses this as the default when inherit: true. + If missing: all fields are accessible (open default — backward compatible). + Evaluation order: field Access → surface_rules → default_access/modify → inherited. capability_surface: mode: defaulted type: TBD + status: placeholder + blocked_by: CapabilitySurface aggregate (no model yet — Relay capability declaration system prerequisite) default: inherit description: > - Mit tud ez az entitás — feature flags, supported operations, protocol versions. - Jövőbeli CapabilitySurface aggregate (nem része az első körnek). + What this entity can do — feature flags, supported operations, protocol versions. + Formal CapabilitySurface aggregate follows the Relay capability model. + in_use_ahead_of_model: > + WARNING — the mechanism is already live at field level while this slot + is still a placeholder. Measured 2026-08-10 in cic-compute + main:schemas/domain/compute-resource.yaml: `capability:` appears 19 + times and `capability_values:` once, on individual nodes, meaning + "this field (or this value) only exists when the adapter supports X". + No atomic primitive declares either member, and nothing validates them. + This is the same concept as the Access atom's `conformance` field + (D-012, "not implemented on this device") under a second name. Which of + the two survives, and where it lives, is an open decision — see + proposals/atom-grammar/README.md section 4. Until it is decided, the + grammar accepts both as known but unvalidated members rather than + inventing a home for them. lifecycle_surface: mode: sealed - type: object + type: TBD + status: placeholder + blocked_by: LifecycleSurface aggregate (no model yet — Relay execution model prerequisite) states: [create, active, degraded, terminating, terminated] transitions: create: [active] @@ -119,27 +137,27 @@ spec: degraded: [active, terminating] terminating: [terminated] description: > - Lifecycle állapotgép — minden ManagedEntity-nek van életciklusa, nem elhagyható. - Jövőbeli LifecycleSurface aggregate fogja formálisan definiálni. + Lifecycle state machine — every ManagedEntity has a lifecycle, non-optional. + Formal LifecycleSurface aggregate follows the Relay execution model. binding_surface: mode: required type: "Address[]" atomic_ref: schemas/atomic/address.yaml description: > - Legalább egy Address atom kötelező — hogyan érhető el ez az entitás. - Address rétegek: schema_path, api_path, logical_id. - Jövőbeli BindingSurface aggregate köré burkolja az adapter referenciát is. + At least one Address atom is required — how this entity is reachable. + Address layers: schema_path, api_path, logical_id. + A future BindingSurface aggregate will also wrap the adapter reference. semantic_mapping: yang: construct: container note: > - A ManagedEntity egy YANG container-ré fordul. Benne: + ManagedEntity maps to a YANG container. Inside: config_surface → container config { ... } state_surface → container state { config false; ... } - operation_surface → rpc/action definíciók - notification_surface → notification definíciók + operation_surface → rpc/action definitions + notification_surface → notification definitions api: base_path: "/{resource-type}/{resource-id}" endpoints: @@ -153,5 +171,5 @@ spec: runtime: role: reconcile-loop-entry note: > - A reconciler minden ManagedEntity-re: observe state_surface → - compare config_surface → ha drift → execute operation vagy direkt apply. + For every ManagedEntity, the reconciler: observe state_surface → + compare config_surface → if drift → execute operation or direct apply. diff --git a/schemas/atomic/access.yaml b/schemas/atomic/access.yaml index 5b0e174..0701cc9 100644 --- a/schemas/atomic/access.yaml +++ b/schemas/atomic/access.yaml @@ -65,6 +65,28 @@ spec: <érték> = injektált default (pl. 0, "", false) — egységes viselkedés, nem szivárog ki hogy a mező létezik-e és mi az értéke + conformance: + type: enum + values: [implemented, not_implemented, deprecated] + default: implemented + description: > + Eszköz-szintű implementációs státusz — elkülönül a jogosultság-alapú + láthatóságtól (D-012). + + implemented (default) — mező létezik és kezelhető az eszközön + not_implemented — mező NEM létezik az eszközön: + olvasás: default_injection értéke (tipikusan null) + írás: HARD REJECT — "field not implemented on device X" + TILOS csendes elfogadás — a Relay visszadobja a write-ot + deprecated — mező létezik de kerülendő: + olvasás: értéke visszaadva + írás: WARNING + elfogadás + + Különbség a jogosultság hiánytól: + access: [] → PERMISSION DENIED (te nem férsz hozzá) + not_implemented → HARD REJECT (az eszköz nem tudja kezelni) + A kérező különböző hibát kap — nem összetéveszthető. + cert_pattern: description: > X.509 cert alapú identity pattern. A kérező mTLS cert-je ellen értékelődik. @@ -95,6 +117,10 @@ spec: runtime: evaluation: mTLS cert kiértékelés a CertPattern lista ellen miss_behavior: default_injection visszaadása ha nincs access joga + conformance_enforcement: > + not_implemented mezőre érkező write-ot a Relay NEM droppolja — + explicit "field not implemented on device X" hibával visszadobja. + Különbözik a permission denied hibától: a kérő tudja mi a valódi ok. yang: note: > YANG NACM (RFC 8341) analóg — de field szinten, nem path alapon. diff --git a/schemas/atomic/identity.yaml b/schemas/atomic/identity.yaml index a00b263..bebd96c 100644 --- a/schemas/atomic/identity.yaml +++ b/schemas/atomic/identity.yaml @@ -3,10 +3,10 @@ metadata: name: Identity version: v0.0.dev description: > - Típusazonosságot leíró atomic primitive — nem példány azonosítás, hanem - annak leírása MI ez az entitás a CIC modellben. Az Identity mondja meg az - entitás típusát, névterét, verzióját és örökítési láncolatát. - Hasonló a YANG identity/identityref-hez, de általánosabb és verziózott. + Atomic primitive describing type identity — not instance identification, but + describing WHAT this entity is in the CIC model. Identity defines an entity's + type, namespace, version, and inheritance chain. + Analogous to YANG identity/identityref, but more general and versioned. owner: Gabor Zoltan Sinko tags: - primitive @@ -20,60 +20,64 @@ spec: kind: AtomicPrimitive fields: - description: Egy Identity definíciójának kötelező és opcionális mezői + description: Required and optional fields of an Identity definition properties: kind: type: string description: > - A típus neve — PascalCase. Ez azonosítja az entitás-típust a névteren belül. - Pl. NetworkInterface, KubernetesPod, PostgresDatabase, ManagedSwitch. + Type name — PascalCase. Identifies the entity type within its namespace. + E.g. NetworkInterface, KubernetesPod, PostgresDatabase, ManagedSwitch. mandatory: true namespace: type: string description: > - CIC névtér — fordított domain vagy cic: prefix konvencióval. - Pl. cic:network, cic:kubernetes, cic:compute, cic:storage. - A kind-dal együtt globálisan egyedi típusazonosítót ad. + CIC namespace — reverse domain or cic: prefix convention. + E.g. cic:network, cic:kubernetes, cic:compute, cic:storage. + Together with kind, provides a globally unique type identifier. mandatory: true version: type: string description: > - Az Identity verziója — szemantikus verziózás. - A séma változásakor inkrementálódik, nem az entitás példány verziója. + Identity version — semantic versioning. + Incremented when the schema changes, not when entity instances change. pattern: "^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(\\.dev)?$" mandatory: true base: type: reference description: > - Örökítési lánc — melyik Identity-ből specializálódik ez a típus. - Több szintű örökítés lehetséges: KubernetesPod → ManagedEntity. - Ha nincs megadva: ez egy gyökér Identity (pl. maga a ManagedEntity). + Inheritance chain — which Identity this type specializes from. + Multi-level inheritance is supported: KubernetesPod → ManagedEntity. + If absent: this is a root Identity (e.g. ManagedEntity itself). + Format: "{namespace}:{Kind}" — the same three-segment form the + examples below and every live composition use. The earlier + two-segment "cic:ManagedEntity" omitted the namespace and is not + valid. optional: true - example: "cic:ManagedEntity" + example: "cic:core:ManagedEntity" abstract: type: boolean default: false description: > - Ha true, ez az Identity nem példányosítható közvetlenül — csak - alapként szolgálhat más Identity-k számára. Pl. ManagedEntity abstract. + If true, this Identity cannot be instantiated directly — it may only + serve as a base for other Identities. E.g. ManagedEntity is abstract. description: type: string - description: Mit képvisel ez a típus a domain-ben + description: What this type represents in the domain optional: true inheritance_rules: description: > - Az Identity örökítés szabályai — hogyan terjed a szemantika a chain mentén. + Rules for Identity inheritance — how semantics propagate along the chain. rules: - - A leszármazott megörökli az alap aggregate surface-eit (ConfigSurface, StateSurface, stb.) - - A leszármazott szűkítheti (sealed) vagy bővítheti (required/defaulted) az alap slot-jait - - Az örökítési lánc mentén minden szint validálható önállóan - - Körkörös örökítés tiltott + - A derived type inherits the aggregate surfaces of its base (ConfigSurface, StateSurface, etc.) + - A derived type may narrow (sealed) or extend (required/defaulted) the base slots + - Every level of the inheritance chain is independently validatable + - Circular inheritance is forbidden examples: managed_entity: @@ -99,8 +103,8 @@ spec: identity: "identity { base ; }" identityref: "type identityref { base ; }" note: > - A YANG identity csak névteret és örökítést ír le — a CIC Identity ennél - több: verziózott és aggregate surface-öket hordoz. + YANG identity only describes namespace and inheritance — CIC Identity + adds versioning and carries aggregate surfaces. api: note: Identity → OpenAPI discriminator / x-cic-kind extension runtime: diff --git a/schemas/atomic/role.yaml b/schemas/atomic/role.yaml index dda6d07..c7c8cfd 100644 --- a/schemas/atomic/role.yaml +++ b/schemas/atomic/role.yaml @@ -1,11 +1,13 @@ --- metadata: name: Role - version: v0.0.dev + version: v0.1.dev description: > - Egy node menedzsment szemantikáját leíró atomic primitive — mit jelent ez a node - a management plane szempontjából. A Role nem az adatot írja le (azt a Shape teszi), - hanem azt hogy hogyan kezeli a rendszer: írható-e, csak olvasható-e, kulcs-e, stb. + Atomic primitive describing the management semantics of a node — what this node means + from the management plane perspective. Role does not describe the data (that is Shape's job), + but how the system handles it: writable, read-only, key, etc. + The seven values are NOT one dimension. They fall on three orthogonal axes, and a node + carries one value from each axis it uses — see spec.axes. owner: Gabor Zoltan Sinko tags: - primitive @@ -13,61 +15,122 @@ metadata: - role validatedBy: name: cic-primitives - version: v0.0.dev + version: v0.1.dev spec: kind: AtomicPrimitive - values: - config: - description: > - Írható, kívánt állapot. A management plane állítja be — ez a desired state. - yang_mapping: "leaf/container without 'config false'" - api_mapping: PUT/PATCH request body - runtime_mapping: desired state store — reconciler target + # ── Where the normative form lives ────────────────────────────────────────── + normative_form: + ref: proposals/atom-grammar/README.md + section: "3" + note: > + This file is normative for what each value MEANS. The instance form — how a + role is written on a node, which combinations are legal, what the short form + expands to — is normative in the atom grammar and enforced by + proposals/atom-grammar/check_grammar.py, which runs in `make validate`. + Where the two disagree, the grammar wins for form and this file wins for + meaning. This file must not restate form. - state: - description: > - Csak olvasható, aktuális állapot. Az adapter tölti fel a valós rendszerből. - yang_mapping: "config false" - api_mapping: GET only - runtime_mapping: observed state store — reconciler source + # ── The three axes ────────────────────────────────────────────────────────── + axes: - operational: + authority: description: > - Operacionális adat — futásidőben számolt vagy gyűjtött érték (pl. byte counter, - uptime, error count). Nem konfigurálható, nem tárolt desired state-ként. - yang_mapping: "config false — tipikusan state container alatt" - api_mapping: GET only - runtime_mapping: telemetry / metrics feed + Who owns the value, and in which direction the reconciler moves it. + The three values are mutually exclusive: a field is either desired state, + or observed state, or computed at runtime. + cardinality: exactly one + default: config + values: - key: - description: > - Lista kulcs — egyedi azonosítója a list példánynak. Kötelezően jelen kell lennie, - nem módosítható a lista elem létrehozása után. - yang_mapping: "key statement in list" - api_mapping: URL path parameter (pl. /interfaces/interface={name}) - runtime_mapping: lookup key in state/config store + config: + description: > + Writable desired state. Set by the management plane — this is the desired state. + yang_mapping: "leaf/container without 'config false'" + api_mapping: PUT/PATCH request body + runtime_mapping: desired state store — reconciler target - reference: - description: > - Más node-ra mutató pointer. A hivatkozott node-nak léteznie kell (referential integrity). - yang_mapping: leafref - api_mapping: string value — external resource identifier - runtime_mapping: foreign key / join key + state: + description: > + Read-only current state. Populated by the adapter from the real system. + yang_mapping: "config false" + api_mapping: GET only + runtime_mapping: observed state store — reconciler source + + operational: + description: > + Operational data — computed or collected at runtime (e.g. byte counter, + uptime, error count). Not configurable, not stored as desired state. + yang_mapping: "config false — typically under state container" + api_mapping: GET only + runtime_mapping: telemetry / metrics feed - derived: + structural: description: > - Más mezőkből számolt érték — nem tárolható önállóan, mindig kiszámítható. - Pl. admin_state + oper_state → effective_state. - yang_mapping: "config false — computed, nem adapterből jön" - api_mapping: GET only — read-only computed field - runtime_mapping: computed on read, not stored + What the value means for addressing and for referential integrity. + Independent of authority: a key is a key whether or not it is also a reference. + cardinality: zero or more + values: + + key: + description: > + List key — unique identifier of a list instance. Must always be present, + not modifiable after the list entry is created. + yang_mapping: "key statement in list" + api_mapping: URL path parameter (e.g. /interfaces/interface={name}) + runtime_mapping: lookup key in state/config store - volatile: + reference: + description: > + Pointer to another node. The referenced node must exist (referential integrity). + Requires semantic_type: cic-reference on the Shape side. + yang_mapping: leafref + api_mapping: string value — external resource identifier + runtime_mapping: foreign key / join key + + lifecycle: description: > - Rövid életű, nem tartós állapot. Nem kerül a desired state-be, a cache TTL után eldobható. - Pl. last_seen timestamp, connection_count pillanatképe. - yang_mapping: "config false" - api_mapping: GET only — best-effort freshness - runtime_mapping: short-lived cache, not persisted + How the value comes into existence and whether it survives. Independent of + both other axes. + cardinality: zero or one + values: + + derived: + description: > + Value computed from other fields — cannot be stored independently, always derivable. + E.g. admin_state + oper_state → effective_state. + yang_mapping: "config false — computed, not sourced from adapter" + api_mapping: GET only — read-only computed field + runtime_mapping: computed on read, not stored + + volatile: + description: > + Short-lived, non-persistent state. Not stored in desired state, discardable after cache TTL. + E.g. last_seen timestamp, instantaneous connection_count snapshot. + yang_mapping: "config false" + api_mapping: GET only — best-effort freshness + runtime_mapping: short-lived cache, not persisted + + # ── Why three axes and not one list ───────────────────────────────────────── + rationale: > + The flat seven-value list this file carried until v0.1.dev could not express a + node that is observed, points at another entity, and is not persisted — a + perfectly ordinary "last seen peer" field. Written flat it had to be called + `state`, and the other two properties were lost. The axes are what the values + always were; the list simply did not say so. + + # ── What the axes forbid ──────────────────────────────────────────────────── + # Enforced by check_grammar.py; stated here so the reason survives next to the + # values it constrains. + exclusions: + - rule: derived excludes authority config + reason: a computed value cannot be set as desired state + - rule: volatile excludes authority config + reason: a non-persistent value has no desired state + - rule: key requires authority config + reason: the key is supplied by the requester at creation + - rule: key excludes derived and volatile + reason: a key is fixed at creation and must not move + - rule: reference requires semantic_type cic-reference + reason: without it there is nothing to point at diff --git a/schemas/atomic/shape.yaml b/schemas/atomic/shape.yaml index 3cbd3b4..beab1fe 100644 --- a/schemas/atomic/shape.yaml +++ b/schemas/atomic/shape.yaml @@ -1,11 +1,11 @@ --- metadata: name: Shape - version: v0.0.dev + version: v0.1.dev description: > - Az adat struktúráját leíró atomic primitive — milyen mezők, milyen típusok. - A Shape mondja meg hogyan néz ki egy config vagy state node adatszerkezetileg. - Minden ConfigSurface.nodes és StateSurface.nodes Shape-ekből épül fel. + Atomic primitive describing data structure — what fields exist and what types they have. + Shape defines how a config or state node looks from a data-structure perspective. + All ConfigSurface.nodes and StateSurface.nodes are composed of Shape atoms. owner: Gabor Zoltan Sinko tags: - primitive @@ -13,55 +13,137 @@ metadata: - shape validatedBy: name: cic-primitives - version: v0.0.dev + version: v0.1.dev spec: kind: AtomicPrimitive + # ── Where the normative form lives ────────────────────────────────────────── + normative_form: + ref: proposals/atom-grammar/README.md + section: "2" + note: > + This file is normative for what each type MEANS. The instance form — which + companion members each type requires and forbids, and what a valid field + descriptor looks like — is normative in the atom grammar and enforced by + proposals/atom-grammar/check_grammar.py, which runs in `make validate`. + Where the two disagree, the grammar wins for form and this file wins for + meaning. This file must not restate form. + type_system: description: > - A Shape lehetséges típusai. Minden field rendelkezik pontosan egy type-tal. + The possible types for a Shape. Every field has exactly one type. + The set is closed: a new type is a version increment. values: scalar: - description: Egyszerű érték — string, integer, boolean, number + description: Simple value — string, integer, boolean, number subtypes: [string, integer, boolean, number, bytes] composite: - description: Beágyazott struktúra — named field-ek halmaza (YANG container) - note: rekurzív — composite tartalmazhat composite-ot + description: Nested structure — a set of named fields (YANG container) + note: recursive — composite may contain composite collection: - description: Elemek listája vagy asszociatív halmaza (YANG list / leaf-list) + description: Ordered list or associative set of elements (YANG list / leaf-list) variants: - list: rendezett vagy kulcsolt elemek (YANG list with key) - set: kulcs nélküli elemek (YANG leaf-list) - reference: - description: Más node-ra mutató pointer (YANG leafref) - note: a cél node-nak léteznie kell a sémában + list: keyed elements (YANG list with key) — every list has a key + set: unkeyed scalar elements (YANG leaf-list) choice: - description: Kizáró alternatívák — pontosan egy aktív (YANG choice/case) + description: Mutually exclusive alternatives — exactly one active (YANG choice/case) + opaque: + description: > + A terminal blob the schema deliberately does not look inside. + Without it, a structure the schema does not model has to be misdeclared + as composite (claiming knowledge of its interior) or as string (losing + that it has structure at all). + note: > + Terminal — no schema-known children below it, and the validator does not + descend. Contracts still apply to the serialized value (e.g. a pattern), + never to its interior. + + # `reference` is NOT a type. It was listed as one until v0.1.dev, but no + # composition has ever written `shape_type: reference`: the corpus writes a + # scalar carrying `semantic_type: cic-reference` plus `reference_target`. A + # reference is a string on the wire; being a reference is a semantic + # annotation, not a structural arity. The grammar rejects the retired form. + reference_annotation: + replaces: type_system.reference (retired v0.1.dev) + written_as: + shape_type: scalar + scalar_type: string + semantic_type: cic-reference + reference_target: "{namespace}:{Kind}" + subtypes: + leafref: intra-schema reference — target lives in the same schema + cic-reference: > + Cross-domain CIC object reference. The field's runtime VALUE is the + logical_id of another domain entity, formatted + cic:{domain}:{backend}:{provider}:{location}:{id}. + The reference_target member names the target Kind — a different thing + with a different shape, see the reference_target field below. fields: - description: Egy Shape field leírásának struktúrája + description: Structure of a Shape field descriptor properties: name: type: string - description: A field neve — CamelCase vagy snake_case, konzekvens a namespace-en belül - type: + description: Field name — CamelCase or snake_case, consistent within the namespace + shape_type: type: string - description: A field típusa a type_system értékei szerint - optional: - type: boolean - default: false - description: Ha true, a field hiányozhat a példányból + description: > + Field type, one of the type_system values. Named `shape_type` on the + node, not `type` — every composition writes it that way, and `type` is + already taken by Contract entries. Was documented as `type` until + v0.1.dev, which no instance ever matched. + presence: + description: > + Whether the field may be absent. ONE axis, written as one of two short + members. `mandatory: false` and `optional: false` are both invalid — + omit the member instead of negating it, or the other one becomes + ambiguous. + members: + mandatory: + type: "true" + description: The field must be present. This is the default when neither is written. + optional: + type: "true" + description: The field may be absent from an instance. nullable: type: boolean default: false - description: Ha true, a field értéke lehet null + description: > + If true, the field value may be null. Under `optional` this is a second, + distinct state: "absent" and "present but null" are not the same, and + whoever relies on the difference must say what each means. default: type: any - description: Alapértelmezett érték ha a field hiányzik és optional true + description: > + Value used when an optional field is absent. Invalid together with + `mandatory` — a field that must be present needs no default. Must + satisfy the field's own contracts and its scalar_type; `default: null` + is invalid under `nullable`, since it would erase the distinction above. description: type: string - description: A field szemantikája — mit jelent, mire való + description: Field semantics — what it means and what it is for + semantic_type: + type: string + optional: true + description: > + Semantic type annotation — refines the meaning of scalar_type: string. + cic-reference: the field value is the logical_id of a CIC managed entity. + Must be used together with the reference_target field. + reference_target: + type: string + optional: true + description: > + The target CIC Kind — only meaningful when semantic_type: cic-reference. + Format: "{namespace}:{Kind}" (e.g. "cic:compute:ComputeResource"). + The namespace already carries the cic: prefix (see identity.yaml + namespace: "cic:network"), so it is NOT repeated here — the earlier + "cic:{namespace}:{Kind}" wording expanded to cic:cic:compute:Kind, + which no composition has ever written. + Distinct from the reference VALUE: reference_target names the target + type, while the field's runtime value is the target instance's + logical_id (see reference_annotation.subtypes.cic-reference). + The Relay uses this to resolve and validate the reference. semantic_mapping: yang: @@ -69,8 +151,11 @@ spec: composite: container collection.list: list (with key) collection.set: leaf-list - reference: leafref choice: choice + case + opaque: > + no direct YANG construct — anydata is the closest, and it carries the + same "structure present, schema absent" meaning + reference_annotation: leafref (the annotation, not a type — see above) api: note: Shape → OpenAPI schema properties / JSON Schema runtime: diff --git a/schemas/examples/storage-bucket.yaml b/schemas/examples/storage-bucket.yaml new file mode 100644 index 0000000..9a15e96 --- /dev/null +++ b/schemas/examples/storage-bucket.yaml @@ -0,0 +1,386 @@ +--- +metadata: + name: StorageBucket + version: v0.1.dev + description: > + Provider-agnosztikus object-storage bucket mint ManagedEntity specializáció. + Ez a kompozíció azt írja le, MIT akarunk (kívánt tárolási állapot), nem azt, + HOGYAN valósítja meg egy adott felhő. A tényleges létrehozást/felügyeletet + provider-specifikus modulok végzik (cic-module-oracle-cloud, jövőben + cic-module-aws, cic-module-azure, cic-module-onprem) a cic:provider ABI-n + keresztül — lásd binding_surface és derivation_chain.module_abi. + Egyetlen mezőt sem szabad úgy megfogalmazni, hogy csak egy konkrét provider + API-jának felel meg; a provider-specifikus részletek (path-ok, enum-értékek, + SDK-anomáliák) a modul saját correspondence/binding rétegében élnek, NEM itt. + owner: Gabor Zoltan Sinko + tags: + - example + - domain-composition + - storage + - provider-agnostic + validatedBy: + name: cic-primitives + version: v0.1.5 + +spec: + kind: DomainComposition + base: + aggregate: ManagedEntity + ref: schemas/aggregate/managed-entity.yaml + + # ── Identity ──────────────────────────────────────────────────────────────── + identity: + atomic_ref: schemas/atomic/identity.yaml + kind: StorageBucket + namespace: "cic:storage" + version: v0.1.5 + base: "cic:core:ManagedEntity" + abstract: false + description: > + Objektum-tárolási konténer (bucket/container) — a legkisebb közös nevező + OCI Object Storage Bucket, AWS S3 Bucket, Azure Blob Container és + S3-kompatibilis on-prem tárolók (pl. MinIO) között. + + # ── ConfigSurface (kívánt állapot — provider-független) ────────────────────── + config_surface: + aggregate_ref: schemas/aggregate/config-surface.yaml + nodes: + - name: bucket_name + atomic_ref: schemas/atomic/shape.yaml + shape_type: scalar + scalar_type: string + role: config + mandatory: true + description: > + Logikai bucket-név a CIC domain-en belül. A provider modul felelőssége + leképezni a saját névtér-szabályaira (pl. OCI namespace+bucket kettős + címzés, S3 globális egyediség, Azure storage-account+container pár) — + ez a mező maga provider-semleges azonosító. + contract: + - type: pattern + expression: "^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$" + error_message: bucket_name kisbetűs, kötőjeleket tartalmazó, 3-63 karakteres azonosító + + - name: storage_class + atomic_ref: schemas/atomic/shape.yaml + shape_type: scalar + scalar_type: string + role: config + optional: true + default: standard + description: > + Absztrakt tárolási osztály/tier. Minden provider modul a saját natív + enumjára képezi le (pl. OCI Standard/InfrequentAccess/Archive, + AWS Standard/Standard-IA/Glacier, Azure Hot/Cool/Archive) — a leképezés + nem szükségszerűen 1:1, a modul dönt a legközelebbi megfelelőről és ezt + state_surface.reported_storage_class-ban visszajelzi. + contract: + - type: enum + expression: [standard, infrequent-access, archive] + error_message: storage_class csak standard, infrequent-access vagy archive lehet + + - name: versioning_enabled + atomic_ref: schemas/atomic/shape.yaml + shape_type: scalar + scalar_type: boolean + role: config + optional: true + default: false + description: Objektum-verziózás kívánt állapota. Nem minden provider/tier támogatja azonos szemantikával. + + - name: public_access + atomic_ref: schemas/atomic/shape.yaml + shape_type: scalar + scalar_type: string + role: config + optional: true + default: private + description: Absztrakt hozzáférési szint — a provider modul fordítja le saját ACL/policy modelljére. + contract: + - type: enum + expression: [private, public-read] + error_message: public_access csak private vagy public-read lehet + + - name: encryption_enabled + atomic_ref: schemas/atomic/shape.yaml + shape_type: scalar + scalar_type: boolean + role: config + optional: true + default: true + description: Titkosítási szándék — kívánt állapot, provider-semleges. + + - name: kms_key_ref + atomic_ref: schemas/atomic/shape.yaml + shape_type: scalar + scalar_type: string + role: config + optional: true + description: > + Provider-semleges logikai kulcs-hivatkozás (pl. Vault Transit key név). + A modul felelőssége leképezni a saját KMS/HSM integrációjára, vagy + jelezni, ha a provider csak platform-managed kulcsot kínál. Csak + encryption_enabled=true esetén értelmezett. + contract: + - type: when + expression: "../encryption_enabled = 'true'" + error_message: kms_key_ref csak encryption_enabled=true esetén engedélyezett + + - name: labels + atomic_ref: schemas/atomic/shape.yaml + shape_type: collection + collection_variant: list + role: config + optional: true + description: > + Generikus kulcs-érték címkék. A provider modul felelőssége leképezni a saját + tag/label mechanizmusára (pl. OCI freeform + defined tags két külön namespace-e + egyetlen listaként jelenik meg itt — a szétválasztás modul-specifikus konverzió). + item_fields: + - name: key + shape_type: scalar + scalar_type: string + role: key + - name: value + shape_type: scalar + scalar_type: string + + # ── StateSurface (megfigyelt állapot — a modul jelenti vissza) ─────────────── + state_surface: + aggregate_ref: schemas/aggregate/state-surface.yaml + source: provider-module + freshness: + ttl: 5m + strategy: polling + nodes: + - name: provisioning_state + atomic_ref: schemas/atomic/shape.yaml + shape_type: scalar + scalar_type: string + role_ref: schemas/atomic/role.yaml + role: state + description: A modul ABI observe hívásának normalizált eredménye + contract: + - type: enum + expression: [creating, active, updating, deleting, error, unknown] + + - name: reported_storage_class + atomic_ref: schemas/atomic/shape.yaml + shape_type: scalar + scalar_type: string + role_ref: schemas/atomic/role.yaml + role: state + description: > + A provider által ténylegesen alkalmazott tier, a modul saját enumjából + a config_surface.storage_class absztrakt értékére visszafordítva. + Eltérés a kívánt és jelentett érték között drift-jelzés (lásd runtime.reconcile_loop). + + - name: endpoint_ref + atomic_ref: schemas/atomic/shape.yaml + shape_type: scalar + scalar_type: string + role_ref: schemas/atomic/role.yaml + role: operational + description: > + Provider-specifikus elérési azonosító/URL, opakán kezelve ezen a rétegen + (pl. OCI-nál namespace+region-alapú URL, S3-nál virtual-hosted URL) — + ezen a szinten csak átlátszó string, nem parszolt szerkezet. + + - name: object_count_approx + atomic_ref: schemas/atomic/shape.yaml + shape_type: scalar + scalar_type: integer + role_ref: schemas/atomic/role.yaml + role: volatile + optional: true + description: > + Becsült objektumszám, ha a provider olcsón szolgáltatja (best-effort, + nem minden modul tölti ki — conformance jelzi provider-modulonként). + access: + value: null + access: ["*"] + modify: [] + inherit: true + default_injection: null + conformance: not_implemented + + - name: created_at + atomic_ref: schemas/atomic/shape.yaml + shape_type: scalar + scalar_type: string + role_ref: schemas/atomic/role.yaml + role: state + description: A modul által jelentett létrehozási időbélyeg, ISO-8601 formában normalizálva + + - name: last_observed + atomic_ref: schemas/atomic/shape.yaml + shape_type: scalar + scalar_type: string + role_ref: schemas/atomic/role.yaml + role: volatile + description: Legutóbbi sikeres observe hívás időbélyege — cache TTL után eldobható + + # ── OperationSurface (a modul ABI-n átfolyó absztrakt akciók) ──────────────── + operation_surface: + aggregate_ref: schemas/aggregate/operation-surface.yaml + operations: + - name: reconcile + atomic_ref: schemas/atomic/behavior.yaml + type: action + idempotent: true + description: > + config_surface vs. state_surface diff alapján plan+execute a kötött + provider modulon keresztül (cic:provider ABI: plan → execute). Ez az + egyetlen módja a bucket létrehozásának/módosításának — nincs külön + create/update action, mert azok providerenként eltérő műveletsorra + bomlanak (lásd derivation_chain.module_abi). + input: null + output: + - name: provisioning_state + shape_type: scalar + scalar_type: string + + - name: empty-and-delete + atomic_ref: schemas/atomic/behavior.yaml + type: action + idempotent: false + description: > + Minden objektum törlése a bucketből, majd maga a bucket törlése + (cic:provider ABI: execute a "destroy" intent-tel). Explicit, külön + akcióként modellezve, mert visszafordíthatatlan és sok providernél + csak üres bucket törölhető — ez a domain-szintű üzleti szabály, nem + provider-részlet. + input: null + output: + - name: deleted_at + shape_type: scalar + scalar_type: string + + - name: rotate-encryption-key + atomic_ref: schemas/atomic/behavior.yaml + type: operation + idempotent: false + description: > + encryption.kms_key_ref cseréje futó re-encryption job-bal. Async — + job-referenciát ad vissza (cic:provider ABI: invoke + poll). + input: + - name: new_kms_key_ref + shape_type: scalar + scalar_type: string + output: + - name: job_id + shape_type: scalar + scalar_type: string + + # ── NotificationSurface ────────────────────────────────────────────────────── + notification_surface: + events: + - name: provisioning-state-changed + atomic_ref: schemas/atomic/event.yaml + trigger: state_change + severity: warning + description: provisioning_state megváltozott (pl. active → error) + payload: + - name: previous_state + shape_type: scalar + scalar_type: string + - name: current_state + shape_type: scalar + scalar_type: string + + - name: storage-class-drift-detected + atomic_ref: schemas/atomic/event.yaml + trigger: threshold + severity: error + description: reported_storage_class eltér a config_surface.storage_class kívánt értékétől + payload: + - name: desired + shape_type: scalar + scalar_type: string + - name: reported + shape_type: scalar + scalar_type: string + + # ── PolicySurface ──────────────────────────────────────────────────────────── + policy_surface: + aggregate_ref: schemas/aggregate/policy-surface.yaml + default_access: ["*"] + default_modify: ["OU=storage-operators,O=cic"] + surface_rules: + - surface: state_surface + access: ["*"] + modify: [] + - surface: config_surface + access: ["*"] + modify: ["OU=storage-operators,O=cic"] + inherit_policy: true + + # ── BindingSurface ─────────────────────────────────────────────────────────── + # Szándékosan NEM egy konkrét provider REST API-jára mutat (mint a hálózati + # domainnél a RESTCONF, ami maga a hálózati eszközök valós protokollja). + # A storage domain "vezetéke" a CIC saját, minden provider modulra egyformán + # kötelező cic:provider WASM ABI-ja — ez a provider-független megfelelője. + binding_surface: + addresses: + - atomic_ref: schemas/atomic/address.yaml + schema_path: "/cic-storage:buckets/bucket={bucket_name}" + logical_id: "cic:storage:bucket:{bucket_name}" + namespace: "cic:storage" + adapter: cic-provider-module + adapter_contract_ref: derivation_chain.module_abi + +# ── Levezetési lánc (reality check) ─────────────────────────────────────────── +derivation_chain: + + module_abi: + description: > + A generikus intent és a provider-specifikus végrehajtás közötti kötelező + érintkezési felület. Minden cic-module- ugyanezt a 8 műveletet + implementálja (lásd cic-module-oracle-cloud/module/provider.go — jelenleg + az egyetlen implemented provider modul), a domain composition csak eddig + a határig "lát": + ops: + describe: "Modul metaadatai — mely resource-okat/verziókat támogatja" + validate: "config_surface szintaktikai/szemantikai ellenőrzése provider-oldalon (pl. bucket_name ütközik-e a provider névtér-szabályaival)" + plan: "config_surface + state_surface → tervezett műveletsor (create/update/no-op), provider-specifikus lépésekre bontva, de itt nem látható részletekkel" + execute: "plan végrehajtása a valós provider API ellen" + observe: "provider API lekérdezése → state_surface normalizált frissítése" + destroy: "empty-and-delete action mögötti tényleges törlés" + invoke: "async provider-oldali akció indítása (pl. rotate-encryption-key)" + poll: "async akció állapotának lekérdezése (job_id → állapot)" + note: > + A path-ok, HTTP-igék, enum-értékek, SDK-anomáliák (pl. OCI namespace + tenancy-szintű mező, vagy hogy UpdateBucket POST és nem PUT) KIZÁRÓLAG + a provider modul saját correspondence/binding rétegében élnek + (cic-module-oracle-cloud/module/correspondence/*.json mintájára) — ezen + a domain-repón ilyen részlet szándékosan NEM jelenik meg. + + provider_mapping: + - provider: oracle-cloud + module: cic-module-oracle-cloud + status: implemented + note: Object Storage Bucket API-ra fordít; namespace+bucket kettős címzés a modul belső ügye. + - provider: aws + module: cic-module-aws + status: concept + note: S3 Bucket API-ra fordítana; bucket_name globális egyediségi szabálya modul-oldali validate feladat. + - provider: azure + module: cic-module-azure + status: concept + note: Blob Storage Container-re fordítana; storage-account+container pár leképezése modul-oldali probléma. + - provider: onprem + module: cic-module-onprem + status: concept + note: S3-kompatibilis (pl. MinIO) végpontra fordítana. + + runtime: + reconcile_loop: | + 1. observe: provider-module.observe(bucket_name) → state_surface + 2. compare: diff(config_surface, state_surface) → drift? + 3. if drift: + provider-module.plan(config_surface, state_surface) → lépéslista + provider-module.execute(lépéslista) → provisioning_state frissül + 4. emit: provisioning-state-changed / storage-class-drift-detected event ha eltérés + conformance_enforcement: > + object_count_approx not_implemented egy adott provider modulon: olvasáskor + null-t ad vissza, NEM hibázik — best-effort mező, nem kötelező garancia. diff --git a/schemas/index.yaml b/schemas/index.yaml index 6bd4c68..030cf7f 100644 --- a/schemas/index.yaml +++ b/schemas/index.yaml @@ -97,7 +97,7 @@ spec: type: string enum: [sealed, defaulted, required] - # DomainComposition must have base.aggregate + # DomainComposition must have base.aggregate and base.ref - if: properties: spec: @@ -111,7 +111,9 @@ spec: properties: base: type: object - required: [aggregate] + required: [aggregate, ref] properties: aggregate: type: string + ref: + type: string diff --git a/tests/test_compiler.py b/tests/test_compiler.py index 7101f60..353b4a1 100644 --- a/tests/test_compiler.py +++ b/tests/test_compiler.py @@ -5,6 +5,7 @@ import sys import hashlib import base64 +import datetime from jsonschema import ValidationError @@ -265,6 +266,259 @@ def git_cmd(cmd): assert component == "primitives/" +# ── _verify_cert_signature ─────────────────────────────────────────────────── + +def test_verify_cert_signature_ok(): + private_key, cert_pem = _make_test_key_and_cert() + content_hash = compiler.get_sha256_b64(b"test payload") + signature = _sign_hash(private_key, content_hash) + ok, reason = compiler._verify_cert_signature(cert_pem, signature, content_hash) + assert ok, reason + + +def test_verify_cert_signature_wrong_key(): + _, cert_pem = _make_test_key_and_cert() + other_key, _ = _make_test_key_and_cert() + content_hash = compiler.get_sha256_b64(b"test payload") + # Sign with a different key — verification against cert must fail + signature = _sign_hash(other_key, content_hash) + ok, reason = compiler._verify_cert_signature(cert_pem, signature, content_hash) + assert not ok + + +def test_verify_cert_signature_bad_format(): + _, cert_pem = _make_test_key_and_cert() + ok, reason = compiler._verify_cert_signature(cert_pem, "not-vault-format", "AAAA") + assert not ok + assert "format" in reason.lower() + + +def test_verify_cert_signature_invalid_cert(): + ok, reason = compiler._verify_cert_signature("NOT A CERT", "vault:v1:AAAA", "AAAA") + assert not ok + + +# ── _load_valid_commitment ──────────────────────────────────────────────────── + +def test_load_valid_commitment_missing_file(tmp_path, mocker): + mocker.patch("os.path.isfile", return_value=False) + with pytest.raises(SystemExit) as e: + compiler._load_valid_commitment() + assert e.value.code == 1 + + +def test_load_valid_commitment_wrong_kind(tmp_path, mocker): + mocker.patch("os.path.isfile", return_value=True) + mocker.patch("tools.compiler.load_yaml", return_value={"kind": "WrongKind"}) + with pytest.raises(SystemExit) as e: + compiler._load_valid_commitment() + assert e.value.code == 1 + + +def test_load_valid_commitment_expired(mocker): + mocker.patch("os.path.isfile", return_value=True) + mocker.patch("tools.compiler.load_yaml", return_value={ + "kind": "DeveloperCommitment", + "validity": {"from": "2020-01-01", "until": "2021-01-01"}, + }) + with pytest.raises(SystemExit) as e: + compiler._load_valid_commitment() + assert e.value.code == 1 + + +def test_load_valid_commitment_ok(mocker): + mocker.patch("os.path.isfile", return_value=True) + today = datetime.datetime.now(datetime.timezone.utc).date() + mocker.patch("tools.compiler.load_yaml", return_value={ + "kind": "DeveloperCommitment", + "validity": {"from": str(today), "until": "2099-01-01"}, + "createdBy": {"name": "Test", "email": "t@example.com", "certificate": "PEM"}, + }) + result = compiler._load_valid_commitment() + assert result["kind"] == "DeveloperCommitment" + + +# ── run_verify_release ──────────────────────────────────────────────────────── + +def _make_test_key_and_cert(): + """Generates an ephemeral ECDSA P-256 key pair and self-signed certificate for tests.""" + from cryptography.hazmat.primitives.asymmetric.ec import generate_private_key, SECP256R1 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography import x509 + from cryptography.x509.oid import NameOID + + private_key = generate_private_key(SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test Dev")]) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .not_valid_before(datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)) + .not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=365)) + .serial_number(x509.random_serial_number()) + .public_key(private_key.public_key()) + .sign(private_key, hashes.SHA256()) + ) + cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode() + return private_key, cert_pem + + +def _sign_hash(private_key, content_hash_b64): + """Signs a pre-hashed digest with an ECDSA private key, returns vault:v1:... format.""" + from cryptography.hazmat.primitives.asymmetric.ec import ECDSA + from cryptography.hazmat.primitives.asymmetric import utils as asym_utils + from cryptography.hazmat.primitives import hashes + + hash_bytes = base64.b64decode(content_hash_b64) + sig_bytes = private_key.sign(hash_bytes, ECDSA(asym_utils.Prehashed(hashes.SHA256()))) + return "vault:v1:" + base64.b64encode(sig_bytes).decode() + + +def _make_valid_bundle(specs=None, private_key=None, cert_pem=None, + release_private_key=None, release_cert_pem=None): + """Builds a PrimitiveRelease bundle with a real ECDSA signature.""" + if private_key is None or cert_pem is None: + private_key, cert_pem = _make_test_key_and_cert() + if release_private_key is None or release_cert_pem is None: + release_private_key, release_cert_pem = _make_test_key_and_cert() + if specs is None: + specs = [{"id": "shape", "source_path": "schemas/atomic/shape.yaml", + "meta_hash": "abc", "spec": {"kind": "AtomicPrimitive"}}] + validity = {"from": "2026-01-01", "until": "2099-01-01"} + created_by = {"name": "Test Dev", "email": "dev@example.com", "certificate": cert_pem} + release_created_by = {"name": "Test Releaser", "email": "releaser@example.com", + "certificate": release_cert_pem} + hash_payload = { + "createdBy": created_by, + "releasedBy": release_created_by, + "specs": specs, + "validity": validity, + } + build_hash = compiler.get_sha256_b64(compiler.to_canonical_json(hash_payload)) + signature = _sign_hash(release_private_key, build_hash) + return { + "kind": "PrimitiveRelease", + "version": "0.1.5", + "timestamp": "2026-05-24T00:00:00+00:00", + "validity": validity, + "createdBy": created_by, + "specs": specs, + "release": { + "createdBy": release_created_by, + "build_hash": build_hash, + "sign": signature, + }, + } + + +def test_verify_release_ok(mocker, tmp_path): + bundle = _make_valid_bundle() + artifact = tmp_path / "release.yaml" + artifact.write_text(yaml.dump(bundle)) + mocker.patch("os.path.isfile", side_effect=lambda p: str(p) == str(artifact)) + compiler.run_verify_release(str(artifact)) + + +def test_verify_release_missing_cert(mocker, tmp_path): + bundle = _make_valid_bundle() + del bundle["createdBy"]["certificate"] + artifact = tmp_path / "release.yaml" + artifact.write_text(yaml.dump(bundle)) + mocker.patch("os.path.isfile", side_effect=lambda p: str(p) == str(artifact)) + with pytest.raises(SystemExit) as e: + compiler.run_verify_release(str(artifact)) + assert e.value.code == 1 + + +def test_verify_release_hash_mismatch(mocker, tmp_path): + bundle = _make_valid_bundle() + bundle["release"]["build_hash"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + artifact = tmp_path / "release.yaml" + artifact.write_text(yaml.dump(bundle)) + mocker.patch("os.path.isfile", side_effect=lambda p: str(p) == str(artifact)) + with pytest.raises(SystemExit) as e: + compiler.run_verify_release(str(artifact)) + assert e.value.code == 1 + + +def test_verify_release_bad_signature(mocker, tmp_path): + """A tampered signature (wrong key) must fail verification.""" + bundle = _make_valid_bundle() + # Replace signature with one from a different key + other_key, _ = _make_test_key_and_cert() + bundle["release"]["sign"] = _sign_hash(other_key, bundle["release"]["build_hash"]) + artifact = tmp_path / "release.yaml" + artifact.write_text(yaml.dump(bundle)) + mocker.patch("os.path.isfile", side_effect=lambda p: str(p) == str(artifact)) + with pytest.raises(SystemExit) as e: + compiler.run_verify_release(str(artifact)) + assert e.value.code == 1 + + +def test_verify_release_wrong_kind(mocker, tmp_path): + bundle = _make_valid_bundle() + bundle["kind"] = "SomethingElse" + artifact = tmp_path / "release.yaml" + artifact.write_text(yaml.dump(bundle)) + mocker.patch("os.path.isfile", side_effect=lambda p: str(p) == str(artifact)) + with pytest.raises(SystemExit) as e: + compiler.run_verify_release(str(artifact)) + assert e.value.code == 1 + + +def test_verify_release_strict_meta_hash_mismatch_fails(tmp_path, mocker): + """--strict: meta_hash mismatch against a local source file is a hard failure.""" + src = tmp_path / "shape.yaml" + src.write_bytes(b"different content") + specs = [{"id": "shape", "source_path": str(src), "meta_hash": "AAAA", "spec": {}}] + bundle = _make_valid_bundle(specs=specs) + artifact = tmp_path / "release.yaml" + artifact.write_text(yaml.dump(bundle)) + # signature verification passes — testing strict meta_hash logic specifically + mocker.patch("tools.compiler._verify_cert_signature", return_value=(True, "OK")) + with pytest.raises(SystemExit) as e: + compiler.run_verify_release(str(artifact), strict=True) + assert e.value.code == 1 + + +def test_verify_release_non_strict_meta_hash_mismatch_warns(tmp_path, capsys, mocker): + """Default (non-strict): meta_hash mismatch is only a warning.""" + src = tmp_path / "shape.yaml" + src.write_bytes(b"different content") + specs = [{"id": "shape", "source_path": str(src), "meta_hash": "AAAA", "spec": {}}] + bundle = _make_valid_bundle(specs=specs) + artifact = tmp_path / "release.yaml" + artifact.write_text(yaml.dump(bundle)) + mocker.patch("tools.compiler._verify_cert_signature", return_value=(True, "OK")) + compiler.run_verify_release(str(artifact), strict=False) + captured = capsys.readouterr() + assert "WARNING" in captured.out or "⚠" in captured.out + + +# ── run_pledge ──────────────────────────────────────────────────────────────── + +def test_run_pledge_no_vault_vars(mocker): + mocker.patch.object(os, "getenv", return_value=None) + mocker.patch("tools.compiler.validate_release_prerequisites", return_value=("0.1.5", "primitives/")) + with pytest.raises(SystemExit) as e: + compiler.run_pledge() + assert e.value.code == 1 + + +def test_run_pledge_no_cert_path(mocker): + mocker.patch.object(os, "getenv", side_effect=lambda k, d=None: { + "VAULT_ADDR": "https://vault:8200", + "VAULT_TOKEN": "token", + }.get(k, d)) + mocker.patch("tools.compiler.load_project_config", return_value={ + "project": {"owner": "Dev"}, + "compiler_settings": {"vault_key_name": "key", "vault_cert_path": None, "owner_email": "", "validity_days": 365}, + }) + with pytest.raises(SystemExit) as e: + compiler.run_pledge() + assert e.value.code == 1 + + # ── main ────────────────────────────────────────────────────────────────────── def test_main_no_arguments(mocker): @@ -279,3 +533,19 @@ def test_main_unknown_command(mocker): with pytest.raises(SystemExit) as e: compiler.main() assert e.value.code == 1 + + +def test_main_help(mocker, capsys): + mocker.patch.object(sys, "argv", ["compiler.py", "--help"]) + compiler.main() + captured = capsys.readouterr() + assert "validate" in captured.out + assert "pledge" in captured.out + assert "verify-release" in captured.out + + +def test_main_verify_release_missing_arg(mocker): + mocker.patch.object(sys, "argv", ["compiler.py", "verify-release"]) + with pytest.raises(SystemExit) as e: + compiler.main() + assert e.value.code == 1 diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..44ab3b3 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,68 @@ +import os +import glob +import yaml +import pytest +from jsonschema import validate, ValidationError +from tools import compiler + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +INVALID_DIR = os.path.join(REPO_ROOT, "schemas", "examples", "invalid") + + +@pytest.fixture(autouse=True) +def repo_cwd(): + original = os.getcwd() + os.chdir(REPO_ROOT) + yield + os.chdir(original) + + +def _load_invalid_docs(filename): + """Load both YAML documents from an invalid example file.""" + path = os.path.join(INVALID_DIR, filename) + with open(path) as f: + docs = list(yaml.safe_load_all(f)) + # First doc: preamble (why_invalid, expected_error) + # Second doc: the actual invalid schema + assert len(docs) == 2, f"{filename}: expected 2 YAML documents" + return docs[0], docs[1] + + +def test_validate_real_schemas(): + compiler.run_validation() + compiler.run_primitive_validation() + compiler.run_domain_compatibility_check() + + +def test_invalid_missing_required_metadata(): + """missing-required-metadata.yaml must fail primitive schema validation.""" + index = compiler.load_yaml("schemas/index.yaml") + meta_schema = index["spec"] + _, schema_doc = _load_invalid_docs("missing-required-metadata.yaml") + with pytest.raises(ValidationError): + validate(instance=schema_doc, schema=meta_schema) + + +def test_invalid_aggregate_slot_mode(): + """aggregate-invalid-slot-mode.yaml must fail primitive schema validation.""" + index = compiler.load_yaml("schemas/index.yaml") + meta_schema = index["spec"] + _, schema_doc = _load_invalid_docs("aggregate-invalid-slot-mode.yaml") + with pytest.raises(ValidationError): + validate(instance=schema_doc, schema=meta_schema) + + +def test_invalid_domain_sealed_override(): + """domain-sealed-override.yaml overrides a sealed slot — verify the violation is detected.""" + _, domain = _load_invalid_docs("domain-sealed-override.yaml") + base = compiler.load_yaml("schemas/aggregate/managed-entity.yaml") + base_slots = base.get("spec", {}).get("slots", {}) + domain_spec = domain.get("spec", {}) + domain_slot_keys = set(domain_spec.keys()) - {"kind", "base"} + + sealed_violations = [ + name for name, slot_def in base_slots.items() + if slot_def.get("mode") == "sealed" and name in domain_slot_keys + ] + assert sealed_violations, "Expected sealed slot violations but found none" + assert "lifecycle_surface" in sealed_violations diff --git a/tools/compiler.py b/tools/compiler.py index 241b8ab..bcd4292 100644 --- a/tools/compiler.py +++ b/tools/compiler.py @@ -58,6 +58,17 @@ def get_sha256_b64(data_bytes): return base64.b64encode(hashlib.sha256(data_bytes).digest()).decode('utf-8') +def _inject_version(obj, version_str): + """Recursively replaces the dev placeholder 'v0.0.dev' with the release version.""" + if isinstance(obj, dict): + return {k: _inject_version(v, version_str) for k, v in obj.items()} + if isinstance(obj, list): + return [_inject_version(item, version_str) for item in obj] + if obj == 'v0.0.dev': + return version_str + return obj + + def get_reproducible_repo_hash(tree_id): """ Calculates a reproducible SHA256 hash of a given git tree object. @@ -68,25 +79,32 @@ def get_reproducible_repo_hash(tree_id): # Create a tar archive from the tree object archive_proc = subprocess.Popen( ['git', 'archive', '--format=tar', tree_id], - stdout=subprocess.PIPE + stdout=subprocess.PIPE, + stderr=subprocess.PIPE ) - # Hash the tar stream digest_proc = subprocess.Popen( ['openssl', 'dgst', '-sha256', '-binary'], stdin=archive_proc.stdout, stdout=subprocess.PIPE ) - # Base64 encode the hash b64_proc = subprocess.Popen( ['openssl', 'base64', '-A'], stdin=digest_proc.stdout, stdout=subprocess.PIPE, text=True ) - archive_proc.stdout.close() # Allow archive_proc to receive a SIGPIPE + archive_proc.stdout.close() repo_hash_b64 = b64_proc.communicate()[0].strip() + archive_proc.wait() + digest_proc.wait() + if archive_proc.returncode != 0: + print("\033[91m✗ ERROR: git archive failed.\033[0m") + sys.exit(1) + if digest_proc.returncode != 0: + print("\033[91m✗ ERROR: openssl dgst failed.\033[0m") + sys.exit(1) if b64_proc.returncode != 0: print("\033[91m✗ ERROR: Failed to calculate reproducible repository hash.\033[0m") sys.exit(1) @@ -121,12 +139,8 @@ def validate_release_prerequisites(): print("--- Validating Release Prerequisites ---") project_config = load_project_config(full_config=True)['project'] - # Sanitize the component name by removing any trailing '/main' or '|main' - # to ensure it represents the core component prefix. raw_component_name = project_config.get('main_branch', 'main') - print(f"'{raw_component_name}'") component_name = re.sub(r'main$', '', raw_component_name) - print(f"'{component_name}'") # 1. Check for clean git state git_status = run_git_command(['git', 'status', '--porcelain']) @@ -223,60 +237,314 @@ def run_validation(): print("\nAll schemas are valid.") +def _vault_sign(content_hash_b64, vault_addr, vault_token, key_name, verify_tls): + """Signs a pre-hashed digest via Vault Transit. Returns the signature string.""" + headers = {'X-Vault-Token': vault_token} + payload = {'input': content_hash_b64, 'prehashed': True} + resp = requests.post( + f"{vault_addr}/v1/transit/sign/{key_name}", + json=payload, headers=headers, verify=verify_tls + ) + if resp.status_code != 200: + print(f"\033[91m✗ ERROR: Vault sign failed ({resp.status_code}): {resp.text}\033[0m") + sys.exit(1) + return resp.json()['data']['signature'] + + +def _vault_get_cert(vault_addr, vault_token, cert_path, verify_tls): + """Reads a PEM cert from Vault KV v2. cert_path format: 'mount/secret-name/key'.""" + parts = cert_path.split('/', 2) + if len(parts) != 3: + print("\033[91m✗ ERROR: VAULT_CERT_PATH must be 'mount/secret-name/key'.\033[0m") + sys.exit(1) + mount, secret, key = parts + headers = {'X-Vault-Token': vault_token} + resp = requests.get( + f"{vault_addr}/v1/{mount}/data/{secret}", + headers=headers, verify=verify_tls + ) + if resp.status_code != 200: + print(f"\033[91m✗ ERROR: Vault cert fetch failed ({resp.status_code}): {resp.text}\033[0m") + sys.exit(1) + return resp.json()['data']['data'][key] + + +def _verify_cert_signature(cert_pem, vault_signature, content_hash_b64): + """Verifies a Vault Transit ECDSA signature against a PEM certificate's public key. + + vault_signature format: "vault:v1:" + content_hash_b64: base64-encoded SHA256 digest (the pre-hashed input to Vault Transit) + + Returns (ok: bool, reason: str) + """ + try: + from cryptography.x509 import load_pem_x509_certificate + from cryptography.hazmat.primitives.asymmetric.ec import ECDSA + from cryptography.hazmat.primitives.asymmetric import utils as asym_utils + from cryptography.hazmat.primitives import hashes + from cryptography.exceptions import InvalidSignature + except ImportError: + return False, "cryptography library not available (pip install cryptography)" + + try: + cert = load_pem_x509_certificate(cert_pem.encode('utf-8')) + public_key = cert.public_key() + except Exception as e: + return False, f"Certificate parse error: {e}" + + # Certificate temporal validity + now = datetime.datetime.now(datetime.timezone.utc) + try: + not_before = cert.not_valid_before_utc + not_after = cert.not_valid_after_utc + except AttributeError: + # cryptography < 42 compat + not_before = cert.not_valid_before.replace(tzinfo=datetime.timezone.utc) + not_after = cert.not_valid_after.replace(tzinfo=datetime.timezone.utc) + + if now < not_before or now > not_after: + return False, f"Certificate not valid: {not_before.date()} → {not_after.date()}" + + # Extract DER signature bytes from Vault Transit format + prefix = "vault:v1:" + if not vault_signature.startswith(prefix): + return False, f"Unexpected signature format (expected 'vault:v1:...'): {vault_signature[:24]}" + try: + sig_bytes = base64.b64decode(vault_signature[len(prefix):]) + hash_bytes = base64.b64decode(content_hash_b64) + except Exception as e: + return False, f"Base64 decode error: {e}" + + try: + public_key.verify(sig_bytes, hash_bytes, ECDSA(asym_utils.Prehashed(hashes.SHA256()))) + return True, "OK" + except InvalidSignature: + return False, "Signature does not match certificate public key" + except Exception as e: + return False, f"Verification error: {e}" + + +def _load_valid_commitment(): + """Loads commitment.yaml and verifies it is present and within its validity window.""" + if not os.path.isfile('commitment.yaml'): + print("\033[91m✗ ERROR: commitment.yaml not found. Run 'make pledge' first.\033[0m") + sys.exit(1) + commitment = load_yaml('commitment.yaml') + if commitment.get('kind') != 'DeveloperCommitment': + print(f"\033[91m✗ ERROR: commitment.yaml has unexpected kind: {commitment.get('kind')}\033[0m") + sys.exit(1) + validity = commitment.get('validity', {}) + today = datetime.datetime.now(datetime.timezone.utc).date() + valid_from = datetime.date.fromisoformat(validity.get('from', '1970-01-01')) + valid_until = datetime.date.fromisoformat(validity.get('until', '1970-01-01')) + if not (valid_from <= today <= valid_until): + print(f"\033[91m✗ ERROR: Developer commitment is not valid today. Run 'make pledge' to renew.\033[0m") + print(f" valid_from: {valid_from}, valid_until: {valid_until}, today: {today}") + sys.exit(1) + print(f" \033[92m✓ Developer commitment valid: {valid_from} → {valid_until}\033[0m") + return commitment + + +def run_pledge(): + """Generates a signed developer commitment (validity + createdBy) to commitment.yaml. + + This is a pre-release step. The developer asserts responsibility for the project + for the declared validity period, binding their X.509 certificate to the hash + before Vault signs it. + """ + print("--- Developer Pledge ---") + + vault_addr = os.getenv('VAULT_ADDR') + vault_token = os.getenv('VAULT_TOKEN') + vault_cacert = os.getenv('VAULT_CACERT') + + if not vault_addr or not vault_token: + print("[FATAL] VAULT_ADDR and VAULT_TOKEN must be set.") + sys.exit(1) + + verify_tls = vault_cacert if vault_cacert else False + if not vault_cacert: + print("\033[93m[WARNING] Vault TLS verification is disabled.\033[0m") + + full_config = load_project_config(full_config=True) + project = full_config.get('project', {}) + settings = full_config.get('compiler_settings', {}) + + vault_key = settings.get('vault_key_name', 'cic-my-sign-key') + vault_cert_path = settings.get('vault_cert_path') or os.getenv('VAULT_CERT_PATH') + owner_name = project.get('owner', '') + owner_email = settings.get('owner_email') or next( + (c['value'] for c in project.get('contacts', []) if c.get('type') == 'email'), '' + ) + validity_days = int(settings.get('validity_days', 365)) + + if not vault_cert_path: + print("[FATAL] vault_cert_path must be set in project.yaml (compiler_settings) or VAULT_CERT_PATH env.") + sys.exit(1) + + # 1. Fetch developer certificate from Vault KV + print(f" Fetching certificate from Vault ({vault_cert_path})...") + cert_pem = _vault_get_cert(vault_addr, vault_token, vault_cert_path, verify_tls) + print(" \033[92m✓ Certificate obtained.\033[0m") + + # 2. Build validity and createdBy blocks + now = datetime.datetime.now(datetime.timezone.utc) + valid_from = now.strftime('%Y-%m-%d') + valid_until = (now + datetime.timedelta(days=validity_days)).strftime('%Y-%m-%d') + + validity = {'from': valid_from, 'until': valid_until} + created_by = { + 'name': owner_name, + 'email': owner_email, + 'certificate': cert_pem, + } + + # 3. Hash the pledge payload — certificate is inside the payload before signing + pledge_payload = {'createdBy': created_by, 'validity': validity} + content_hash = get_sha256_b64(to_canonical_json(pledge_payload)) + print(f" Pledge hash: {content_hash[:24]}...") + + # 4. Sign via Vault Transit + print(f" Signing with Vault key '{vault_key}'...") + signature = _vault_sign(content_hash, vault_addr, vault_token, vault_key, verify_tls) + print(" \033[92m✓ Pledge signed.\033[0m") + + # 5. Write commitment.yaml + commitment = { + 'kind': 'DeveloperCommitment', + 'created': now.isoformat(), + 'validity': validity, + 'createdBy': created_by, + 'pledge': { + 'content_hash': content_hash, + 'sign': signature, + }, + } + write_yaml('commitment.yaml', commitment) + print(f"\n \033[92m✓ commitment.yaml created.\033[0m") + print(f" Developer : {owner_name} <{owner_email}>") + print(f" Valid from: {valid_from}") + print(f" Valid until: {valid_until}") + print(f"\n \033[93mNext step: commit commitment.yaml, then run 'make release'.\033[0m") + + def run_release(): - """Runs the full release process: validation, checksum, signing.""" + """Builds a signed PrimitiveRelease bundle artifact into release/.""" print("--- Running Schema Release ---") - # First, run all prerequisite checks release_version, component_name = validate_release_prerequisites() vault_addr = os.getenv('VAULT_ADDR') vault_token = os.getenv('VAULT_TOKEN') - vault_cacert = os.getenv('VAULT_CACERT') # For production TLS verification + vault_cacert = os.getenv('VAULT_CACERT') if not vault_addr or not vault_token: print("[FATAL] VAULT_ADDR and VAULT_TOKEN must be set for release.") sys.exit(1) - # Set TLS verification for Vault connection - if vault_cacert: - verify_tls = vault_cacert - print(f"[INFO] Using CA cert for Vault TLS verification: {vault_cacert}") - else: - verify_tls = False + verify_tls = vault_cacert if vault_cacert else False + if not vault_cacert: print("\033[93m[WARNING] Vault TLS verification is disabled. Do not use in production.\033[0m") - # 1. Prepare project.yaml with version and timestamp - full_project_config = load_project_config(full_config=True) - if 'release' in full_project_config: - del full_project_config['release'] + # 1. Load and verify developer commitment (must exist and be within validity window) + print("--- Verifying Developer Commitment ---") + commitment = _load_valid_commitment() + validity = commitment['validity'] + created_by = commitment['createdBy'] + + # 2. Fetch release issuer certificate from Vault + full_config = load_project_config(full_config=True) + settings = full_config.get('compiler_settings', {}) + project = full_config.get('project', {}) + vault_key = settings.get('vault_key_name', 'cic-my-sign-key') + vault_cert_path = settings.get('vault_cert_path') or os.getenv('VAULT_CERT_PATH') + if not vault_cert_path: + print("[FATAL] vault_cert_path must be set in project.yaml or VAULT_CERT_PATH env.") + sys.exit(1) + print(f"--- Fetching release issuer certificate ({vault_cert_path}) ---") + release_cert_pem = _vault_get_cert(vault_addr, vault_token, vault_cert_path, verify_tls) + print(" \033[92m✓ Release certificate obtained.\033[0m") + release_owner_name = project.get('owner', '') + release_owner_email = settings.get('owner_email') or next( + (c['value'] for c in project.get('contacts', []) if c.get('type') == 'email'), '' + ) + release_created_by = { + 'name': release_owner_name, + 'email': release_owner_email, + 'certificate': release_cert_pem, + } + + # 3. Validate everything before building the artifact + run_validation() + run_primitive_validation() + run_domain_compatibility_check() - release_block = { - "version": release_version, - "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat() + # 4. Collect schema files: atomic + aggregate, no examples, no index + source_dir = CONFIG.get('source_dir', 'schemas') + all_files = glob.glob(os.path.join(source_dir, '**', '*.yaml'), recursive=True) + schema_files = sorted([ + f for f in all_files + if os.path.basename(f) != 'index.yaml' + and not f.endswith('.gitkeep') + and '/examples/' not in f.replace(os.sep, '/') + and os.path.getsize(f) > 0 + ]) + + print(f"--- Building release bundle ({len(schema_files)} schemas) ---") + + # 5. Build specs[] — meta_hash over raw file bytes, version placeholder injected in spec content + specs = [] + release_version_str = f"v{release_version}" + for schema_file in schema_files: + with open(schema_file, 'rb') as fh: + raw = fh.read() + meta_hash = get_sha256_b64(raw) + spec_data = _inject_version(yaml.safe_load(raw), release_version_str) + specs.append({ + 'id': os.path.splitext(os.path.basename(schema_file))[0], + 'source_path': schema_file.replace(os.sep, '/'), + 'meta_hash': meta_hash, + 'spec': spec_data, + }) + print(f" - {schema_file}: {meta_hash[:16]}...") + + # 6. Build hash: {createdBy (developer), release.createdBy (issuer), specs, validity} + # Both certificates are cryptographically bound before Vault signs. + # Future: this will become a Merkle tree root over CI artifact layers. + hash_payload = { + 'createdBy': created_by, + 'releasedBy': release_created_by, + 'specs': specs, + 'validity': validity, + } + build_hash = get_sha256_b64(to_canonical_json(hash_payload)) + print(f" - Build hash: {build_hash[:16]}...") + + # 7. Sign via Vault Transit (one call for the entire bundle) + print(f" - Signing with Vault key '{vault_key}'...") + signature = _vault_sign(build_hash, vault_addr, vault_token, vault_key, verify_tls) + print(" \033[92m✓ Vault signature obtained.\033[0m") + + # 8. Assemble and write the bundle artifact + project_name = full_config.get('project', {}).get('name', 'XXprimitivesXX') + bundle = { + 'kind': 'PrimitiveRelease', + 'version': release_version, + 'timestamp': datetime.datetime.now(datetime.timezone.utc).isoformat(), + 'validity': validity, + 'createdBy': created_by, + 'specs': specs, + 'release': { + 'createdBy': release_created_by, + 'build_hash': build_hash, + 'sign': signature, + }, } - full_project_config['release'] = release_block - write_yaml('project.yaml', full_project_config) - print(" - Updated project.yaml with version and timestamp.") - - # 2. Stage all changes and get the repository tree hash (source_hash) - print(" - Staging all changes to calculate repository state...") - run_git_command(['git', 'add', '.']) - tree_id = run_git_command(['git', 'write-tree']) - print(f" - Staged content tree ID: {tree_id[:12]}...") - source_hash = get_reproducible_repo_hash(tree_id) - print(f" - Calculated source hash: {source_hash[:12]}...") - - # 3. build_hash = source_hash egyelőre — tényleges build env nincs még - build_hash = source_hash - print(f" - Build hash (= source hash until real build env): {build_hash[:12]}...") - - # 4. Write release block to project.yaml (release.sh adds sign + cert on top) - release_block['repository_tree_hash'] = source_hash - release_block['build_hash'] = build_hash - full_project_config['release'] = release_block - write_yaml('project.yaml', full_project_config) - print(" - \033[92m✓ project.yaml finalized with source_hash + build_hash.\033[0m") - print(f" - \033[93mACTION REQUIRED: git tag {component_name}@v{release_version}\033[0m") + + os.makedirs('release', exist_ok=True) + artifact_path = os.path.join('release', f"{project_name}-v{release_version}.yaml") + write_yaml(artifact_path, bundle) + print(f"\n \033[92m✓ Release artifact: {artifact_path}\033[0m") + print(f" \033[93mACTION REQUIRED: git add {artifact_path} && git commit -m 'release: {release_version}' && git tag {component_name}@v{release_version}\033[0m") def run_primitive_validation(): @@ -399,20 +667,197 @@ def run_domain_compatibility_check(): print(f"\nAll {len(domain_files)} DomainComposition files are compatible.") +def run_verify_release(artifact_path, strict=False): + """Verifies a PrimitiveRelease bundle: schema validation, build_hash, and meta_hash checks. + + strict=True: meta_hash mismatches against local source files are a hard failure. + strict=False (default): meta_hash mismatches are reported as warnings only. + """ + mode_label = " [STRICT]" if strict else "" + print(f"--- Verifying release artifact: {artifact_path}{mode_label} ---") + + try: + bundle = load_yaml(artifact_path) + except Exception as e: + print(f"\033[91m✗ ERROR: Cannot load artifact: {e}\033[0m") + sys.exit(1) + + # Validate against release.schema.yaml if it exists + release_schema_path = 'release.schema.yaml' + if os.path.isfile(release_schema_path): + try: + from jsonschema import validate as jvalidate, ValidationError as JValidationError + release_schema = load_yaml(release_schema_path) + jvalidate(instance=bundle, schema=release_schema) + print(f" \033[92m✓ Schema valid (release.schema.yaml)\033[0m") + except JValidationError as e: + print(f"\033[91m✗ Schema validation failed: {e.message}\033[0m") + sys.exit(1) + else: + print(f" \033[93m⚠ release.schema.yaml not found — skipping schema validation\033[0m") + + if bundle.get('kind') != 'PrimitiveRelease': + print(f"\033[91m✗ ERROR: Expected kind=PrimitiveRelease, got: {bundle.get('kind')}\033[0m") + sys.exit(1) + print(f" kind: PrimitiveRelease") + print(f" version: {bundle.get('version')}") + print(f" timestamp: {bundle.get('timestamp')}") + + specs = bundle.get('specs', []) + print(f" specs[]: {len(specs)} entries") + + validity = bundle.get('validity') + created_by = bundle.get('createdBy') + release_block = bundle.get('release', {}) + release_created_by = release_block.get('createdBy', {}) + + # Verify developer commitment fields + if not created_by or not created_by.get('certificate'): + print(f" \033[91m✗ createdBy.certificate missing — bundle lacks developer commitment\033[0m") + sys.exit(1) + print(f" createdBy: {created_by.get('name')} <{created_by.get('email')}>") + + # Verify release issuer fields + if not release_created_by or not release_created_by.get('certificate'): + print(f" \033[91m✗ release.createdBy.certificate missing — release issuer not bound\033[0m") + sys.exit(1) + print(f" releasedBy: {release_created_by.get('name')} <{release_created_by.get('email')}>") + + if validity: + today = datetime.datetime.now(datetime.timezone.utc).date() + valid_from = datetime.date.fromisoformat(validity.get('from', '1970-01-01')) + valid_until = datetime.date.fromisoformat(validity.get('until', '1970-01-01')) + if valid_from <= today <= valid_until: + print(f" \033[92m✓ Maintenance commitment valid: {valid_from} → {valid_until}\033[0m") + else: + print(f" \033[93m⚠ Maintenance commitment expired: {valid_from} → {valid_until} (today: {today})\033[0m") + else: + print(f" \033[93m⚠ No validity block found in bundle\033[0m") + + recorded_hash = release_block.get('build_hash', '') + + # build_hash covers {createdBy (developer), releasedBy (issuer), specs, validity} + hash_payload = { + 'createdBy': created_by, + 'releasedBy': release_created_by, + 'specs': specs, + 'validity': validity, + } + recomputed_hash = get_sha256_b64(to_canonical_json(hash_payload)) + + if recomputed_hash == recorded_hash: + print(f"\n \033[92m✓ build_hash verified: {recomputed_hash[:24]}...\033[0m") + else: + print(f"\n \033[91m✗ build_hash MISMATCH\033[0m") + print(f" recorded: {recorded_hash}") + print(f" recomputed: {recomputed_hash}") + sys.exit(1) + + # Optional: verify meta_hash against local source files if they exist + mismatches = [] + for entry in specs: + src = entry.get('source_path', '') + recorded_meta = entry.get('meta_hash', '') + if src and os.path.isfile(src): + with open(src, 'rb') as fh: + actual_meta = get_sha256_b64(fh.read()) + if actual_meta != recorded_meta: + mismatches.append(f"{src}: recorded={recorded_meta[:12]}... actual={actual_meta[:12]}...") + + local_checked = sum(1 for e in specs if os.path.isfile(e.get('source_path', ''))) + + if mismatches: + label = "\033[91m✗\033[0m" if strict else "\033[93m⚠\033[0m" + severity = "ERROR" if strict else "WARNING" + print(f"\n {label} meta_hash {severity}: source files differ from bundle ({len(mismatches)}/{local_checked}):") + for m in mismatches: + print(f" {m}") + if strict: + print(f" \033[91m✗ Strict mode: working tree must match the release bundle exactly.\033[0m") + sys.exit(1) + else: + if local_checked: + print(f" \033[92m✓ meta_hash verified for {local_checked} local source files\033[0m") + + # Signature verification against release issuer certificate — mandatory + print(f"\n--- Signature Verification ---") + cert_pem = release_created_by.get('certificate', '') + release_sign = release_block.get('sign', '') + + if not cert_pem: + print(f" \033[91m✗ release.createdBy.certificate missing — cannot verify signature\033[0m") + sys.exit(1) + if not release_sign: + print(f" \033[91m✗ release.sign missing — bundle is unsigned\033[0m") + sys.exit(1) + if not recorded_hash: + print(f" \033[91m✗ release.build_hash missing — cannot verify signature\033[0m") + sys.exit(1) + + ok, reason = _verify_cert_signature(cert_pem, release_sign, recorded_hash) + if ok: + print(f" \033[92m✓ Release signature verified (ECDSA, certificate public key)\033[0m") + else: + print(f" \033[91m✗ Release signature FAILED: {reason}\033[0m") + sys.exit(1) + + # Optional pledge signature verification if commitment.yaml is present + if os.path.isfile('commitment.yaml'): + try: + commitment = load_yaml('commitment.yaml') + pledge = commitment.get('pledge', {}) + pledge_hash = pledge.get('content_hash', '') + pledge_sign = pledge.get('sign', '') + pledge_cert = commitment.get('createdBy', {}).get('certificate', '') + if pledge_cert and pledge_sign and pledge_hash: + ok, reason = _verify_cert_signature(pledge_cert, pledge_sign, pledge_hash) + if ok: + print(f" \033[92m✓ Pledge signature verified (commitment.yaml)\033[0m") + else: + print(f" \033[91m✗ Pledge signature FAILED: {reason}\033[0m") + sys.exit(1) + except Exception as e: + print(f" \033[93m⚠ Could not verify pledge signature: {e}\033[0m") + else: + print(f" \033[93m⚠ commitment.yaml not found — pledge signature not verified\033[0m") + + print(f" \033[93m⚠ CA chain verification not configured (no trust root bundle)\033[0m") + print(f"\n \033[92m✓ Artifact integrity OK\033[0m") + + def main(): """Main entrypoint for the script.""" if len(sys.argv) < 2: - print("Usage: python tools/compiler.py [validate|release]") + print("Usage: python tools/compiler.py [validate|pledge|release|verify-release ]") sys.exit(1) command = sys.argv[1] - if command == 'validate': + if command in ('--help', '-h', 'help'): + print("Usage: python tools/compiler.py ") + print("") + print("Commands:") + print(" validate Validate all schemas offline (no Vault required).") + print(" pledge Generate signed developer commitment → commitment.yaml.") + print(" release Build signed PrimitiveRelease bundle (requires Vault + commitment.yaml).") + print(" verify-release [--strict] Verify a PrimitiveRelease bundle.") + print(" Default: meta_hash mismatches are warnings.") + print(" --strict: meta_hash mismatch is a hard failure.") + print(" help Show this help message.") + elif command == 'validate': run_validation() run_primitive_validation() run_domain_compatibility_check() + elif command == 'pledge': + run_pledge() elif command == 'release': run_release() + elif command == 'verify-release': + if len(sys.argv) < 3: + print("Usage: python tools/compiler.py verify-release [--strict]") + sys.exit(1) + strict = '--strict' in sys.argv[3:] + run_verify_release(sys.argv[2], strict=strict) else: print(f"Unknown command: {command}") sys.exit(1) diff --git a/tools/vault-rootCA-sign-agent.sh b/tools/vault-rootCA-sign-agent.sh new file mode 100755 index 0000000..19966e2 --- /dev/null +++ b/tools/vault-rootCA-sign-agent.sh @@ -0,0 +1,247 @@ +#!/bin/bash +# vault-runner.sh - Temporary Vault signing server launcher +# +# Copyright (c) 2025, Gábor Zoltán Sinkó +# Licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License +# See https://creativecommons.org/licenses/by-nc-sa/4.0/ + +# FIXME: This script is a temporary, local-only solution to simulate the +# central signing environment for development purposes. It launches a temporary, +# in-memory Vault instance and exposes a private key for signing. +# +# !!! WARNING !!! +# This script is NOT for production use. It handles sensitive key material +# in a local, non-hardened environment. It should only be activated +# temporarily during the release process on a trusted developer machine. +# The long-term goal is to replace this with a proper, centralized signing API +# and a secure build environment. + +set -e + +KEYFILE="" +ROOT_CA_FILE="" +KEY_NAME="cic-my-sign-key" +CIC_DIR="" +CIC_CERTS=() + +show_help() { + echo "Usage: $0 [OPTIONS]" + echo " -k, --keyfile Encrypted private key file" + echo " -c, --crtfile Public certificate file (crt format)" + echo " --root-ca-file Public Root CA certificate file (crt format)" + echo " -n, --name Vault key name (default: cic-my-sign-key)" + echo " --cic-dir Load all CA PEM files from EEC directory tree" + echo " (skips out/, .key, .csr files)" + echo " --cic-cert Load a single CA certificate into KV (repeatable)" + echo " -s, --stop Stop the running Vault server" + echo " -h, --help Display this help message" + exit 0 +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -k|--keyfile) + KEYFILE="$2" + shift 2 + ;; + -c|--crtfile) + CRTFILE="$2" + shift 2 + ;; + --root-ca-file) + ROOT_CA_FILE="$2" + shift 2 + ;; + -n|--name) + KEY_NAME="$2" + shift 2 + ;; + --cic-dir) + CIC_DIR="$2" + shift 2 + ;; + --cic-cert) + CIC_CERTS+=("$2") + shift 2 + ;; + -s|--stop|stop) + STOP=1 + shift + ;; + -h|--help) + show_help + ;; + *) + echo "[!] Unknown option: $1" + show_help + ;; + esac +done + +if [[ "$STOP" == "1" ]]; then + if [[ -f $XDG_RUNTIME_DIR/vault_CA/vault.pid ]]; then + kill $(cat $XDG_RUNTIME_DIR/vault_CA/vault.pid) && echo "[*] Vault stopped." && rm -f $XDG_RUNTIME_DIR/vault_CA/vault.pid + else + echo "[!] vault.pid file not found – is Vault running?" + fi + exit 0 +fi + + +if [[ -f $XDG_RUNTIME_DIR/vault_CA/vault.pid ]]; then + echo "[!] vault.pid file found – is Vault running? First stop the vault" + exit 0 +fi + +TMPDIR=$(mktemp -d) +VAULT_PORT=18202 +TOKEN_FILE="$XDG_RUNTIME_DIR/vault_CA/sign-token" +SERVER_CA_FILE="$XDG_RUNTIME_DIR/vault_CA/server.crt" # New file for server CA cert +VAULT_KEY="$TMPDIR/vault-key.pem" +VAULT_CERT="$TMPDIR/vault-cert.pem" +PIDFILE="vault.pid" +export VAULT_API_ADDR="https://127.0.0.1:$VAULT_PORT" +export VAULT_ADDR="https://127.0.0.1:$VAULT_PORT" +mkdir -p $XDG_RUNTIME_DIR/vault_CA + +if [[ -z "$KEYFILE" || -z "$CRTFILE" || -z "$ROOT_CA_FILE" ]]; then + echo "[!] Key file, certificate file, and Root CA file must all be specified." + show_help +fi + +read -s -p "Enter private key password (hidden input): " KEYPASS +echo + +# Create SAN config for self-signed cert +cat > "$TMPDIR/san.cnf" < "$TMPDIR/vault-config.hcl" < "$TMPDIR/vault.log" 2>&1 & +echo $! > "$XDG_RUNTIME_DIR/vault_CA/$PIDFILE" +VAULT_PID=$(cat "$XDG_RUNTIME_DIR/vault_CA/$PIDFILE") + +sleep 2 + +echo "[*] Initializing and unsealing Vault..." +key=$(vault operator init -key-shares=5 -key-threshold=3 -format=json) +for k in $(echo "$key" | jq -r '.unseal_keys_b64[0:3][]'); do + vault operator unseal "$k" +done +export VAULT_TOKEN=$(echo "$key" | jq -r '.root_token') + +sleep 2 + +vault secrets enable transit + +# Import private key into Vault via pipe +echo "[*] Importing private key into Vault..." +vault transit import transit/keys/$KEY_NAME @<(openssl pkey -in "$KEYFILE" -passin pass:"$KEYPASS" \ + | openssl pkcs8 -topk8 -nocrypt -outform der \ + | openssl base64 -A) type=ecdsa-p256 + +vault secrets enable -path=$KEY_NAME -version=2 kv +echo "[*] Storing public certificate in Vault..." +cat "$CRTFILE" | vault kv put -mount=$KEY_NAME crt bar=- + +echo "[*] Storing Root CA certificate in Vault..." +cat "$ROOT_CA_FILE" | vault kv put -mount=$KEY_NAME CICRootCA bar=- + +# Load CIC CA certificates from directory +if [[ -n "$CIC_DIR" ]]; then + if [[ ! -d "$CIC_DIR" ]]; then + echo "[!] --cic-dir '$CIC_DIR' does not exist or is not a directory." + exit 1 + fi + echo "[*] Loading CIC CA certificates from: $CIC_DIR" + while IFS= read -r -d '' cert_file; do + if ! grep -q "BEGIN CERTIFICATE" "$cert_file" 2>/dev/null; then + continue + fi + cert_key=$(basename "$cert_file") + cert_key="${cert_key%.*}" + echo "[*] Loading: $cert_file -> KV key: $cert_key" + cat "$cert_file" | vault kv put -mount=$KEY_NAME "$cert_key" bar=- + done < <(find "$CIC_DIR" -maxdepth 2 -name "*.pem" ! -path "*/out/*" -print0) +fi + +# Load individually specified CIC certificates +for cert_file in "${CIC_CERTS[@]}"; do + if [[ ! -f "$cert_file" ]]; then + echo "[!] --cic-cert '$cert_file' does not exist, skipping." + continue + fi + if ! grep -q "BEGIN CERTIFICATE" "$cert_file" 2>/dev/null; then + echo "[!] '$cert_file' does not appear to be a certificate, skipping." + continue + fi + cert_key=$(basename "$cert_file") + cert_key="${cert_key%.*}" + echo "[*] Loading: $cert_file -> KV key: $cert_key" + cat "$cert_file" | vault kv put -mount=$KEY_NAME "$cert_key" bar=- +done + +# Create sign policy +cat > "$TMPDIR/sign-policy.hcl" < "$TOKEN_FILE" + +echo "[*] Signing token created: $TOKEN_FILE" +echo "[*] Vault server CA certificate saved to: $SERVER_CA_FILE" +echo "[*] VAULT_ADDR: $VAULT_ADDR" +echo "[*] Vault PID: $VAULT_PID" +echo "[*] Stop Vault: $0 --stop" + +#trap "rm -rf $TMPDIR" EXIT + +# --- +# This script is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. +# You may use, adapt, and share it for non-commercial purposes with attribution, +# and you must distribute your modifications under the same license. +# +# License details: https://creativecommons.org/licenses/by-nc-sa/4.0/