From ed5b7b7114d986c4204aa3d11c6289559b182fc7 Mon Sep 17 00:00:00 2001 From: ialexeze Date: Tue, 18 Aug 2026 19:21:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(lifecycle):=20lifecycle:=20block=20?= =?UTF-8?q?=E2=80=94=20maturity,=20deprecation,=20compatibility,=20and=20k?= =?UTF-8?q?omposer=20acceptance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a top-level lifecycle: block to every Katalog and Komposer file: - lifecycle.maturity: alpha | beta | stable | deprecated — advisory signal; ork validate warns for pre-stable imports - lifecycle.deprecation: message, migratedTo, timeline — surfaced at push, validate, inspect, pull, and runtime startup; deprecated Katalogs always block ork run unless imported via a Komposer with lifecycle.accept.patterns - lifecycle.compatibility: kubernetes/orkestra semver ranges; ork validate rejects patterns outside the declared range - lifecycle.accept.patterns (Komposer-only): acknowledges deprecated or pre-stable imports by name; optional version scoping - policy.lifecycle.minMaturity: platform floor; imports below the declared maturity level are errors at validate time metadata.deprecation removed; lifecycle.deprecation is the canonical location. Katalog-level accept removed entirely — acceptance belongs on the Komposer. Registry guide steps renumbered: hooks→09, typed-komposer→10, ork-action→11, deprecation→12; new steps 13–16 cover deprecation-accept, lifecycle-maturity, lifecycle-compatibility, and komposer-accept. Schema evolution docs updated with a third layer (Gateway API / serve.fields.values). lifecycle: concepts page and registry guide corrected and expanded. --- CHANGELOG.md | 133 ++++++ charts/orkestra/Chart.yaml | 30 +- cmd/cli/helper.go | 21 +- cmd/cli/play_chain.go | 2 +- cmd/cli/validate.go | 6 +- .../conversion/03-serve-translation.md | 139 +++++++ documentation/concepts/conversion/index.md | 51 ++- documentation/concepts/lifecycle/index.md | 144 ++++--- documentation/faqs/07-registry.md | 56 +++ documentation/guides/registry/09-policy.md | 2 +- .../guides/registry/10-deprecation.md | 122 ------ documentation/guides/registry/10-lifecycle.md | 244 +++++++++++ documentation/guides/registry/index.md | 4 +- .../02-katalog/00-metadata/deprecation.md | 183 --------- .../schema/02-katalog/00-metadata/index.md | 15 - .../registry-guide/09-deprecation/README.md | 66 --- .../Dockerfile | 0 .../Makefile | 0 .../README.md | 0 .../api/v1alpha1/database_types.go | 0 .../cleanup.sh | 0 .../cr.yaml | 0 .../crd.yaml | 0 .../e2e.yaml | 0 .../go.mod.txt | 0 .../go.sum.txt | 0 .../hooks/database_hooks.go | 0 .../katalog.yaml | 0 .../simulate.yaml | 0 .../values.yaml | 0 .../README.md | 0 .../cleanup.sh | 0 .../cr-cache.yaml | 0 .../cr-database.yaml | 0 .../cr-webapp.yaml | 0 .../komposer.yaml | 0 .../simulate.yaml | 0 .../.github/workflows/publish-typed.yml | 0 .../.github/workflows/publish.yml | 0 .../README.md | 0 .../database-operator/Dockerfile | 0 .../database-operator/Makefile | 0 .../database-operator/README.md | 0 .../api/v1alpha1/database_types.go | 0 .../database-operator/cleanup.sh | 0 .../database-operator/cr.yaml | 0 .../database-operator/crd.yaml | 0 .../database-operator/e2e.yaml | 0 .../database-operator/go.mod.txt | 0 .../database-operator/go.sum.txt | 0 .../database-operator/hooks/database_hooks.go | 0 .../database-operator/katalog.yaml | 0 .../database-operator/simulate.yaml | 0 .../database-operator/values.yaml | 0 .../webapp-operator/README.md | 0 .../webapp-operator/cleanup.sh | 0 .../webapp-operator/cr-with-ingress.yaml | 0 .../webapp-operator/cr.yaml | 0 .../webapp-operator/crd.yaml | 0 .../webapp-operator/e2e.yaml | 0 .../webapp-operator/katalog.yaml | 0 .../webapp-operator/simulate-ingress.yaml | 0 .../webapp-operator/simulate.yaml | 0 .../registry-guide/12-deprecation/README.md | 58 +++ .../crd-v1.yaml | 0 .../katalog.yaml | 9 +- .../13-deprecation-accept/README.md | 82 ++++ .../13-deprecation-accept/crd-v1.yaml | 48 +++ .../13-deprecation-accept/katalog.yaml | 40 ++ .../13-deprecation-accept/komposer.yaml | 19 + .../14-lifecycle-maturity/README.md | 77 ++++ .../14-lifecycle-maturity/crd.yaml | 48 +++ .../14-lifecycle-maturity/katalog-alpha.yaml | 32 ++ .../14-lifecycle-maturity/katalog-beta.yaml | 32 ++ .../14-lifecycle-maturity/katalog-stable.yaml | 32 ++ .../15-lifecycle-compatibility/README.md | 73 ++++ .../15-lifecycle-compatibility/crd.yaml | 48 +++ .../15-lifecycle-compatibility/katalog.yaml | 37 ++ .../16-komposer-accept/README.md | 49 +++ .../16-komposer-accept/crd-cache.yaml | 38 ++ .../16-komposer-accept/crd.yaml | 48 +++ .../16-komposer-accept/katalog-cache.yaml | 33 ++ .../16-komposer-accept/katalog-webapp.yaml | 39 ++ .../16-komposer-accept/komposer.yaml | 23 ++ examples/registry-guide/README.md | 13 +- examples/use-cases/crd-conversion/README.md | 3 +- pkg/gateway/api/apply.go | 2 +- pkg/gateway/api/apply_target.go | 2 +- pkg/gateway/api/helper.go | 2 +- pkg/intent/target/helper.go | 10 +- pkg/intent/target/mux.go | 4 +- pkg/intent/target/resolve.go | 1 - pkg/katalog/deprecation_policy.go | 46 +-- pkg/katalog/deprecation_policy_test.go | 74 +--- pkg/katalog/parser.go | 2 + .../invalid/bad-deprecation-no-message.yaml | 3 +- .../bad-lifecycle-accept-on-katalog.yaml | 14 + .../bad-lifecycle-compat-kubernetes.yaml | 13 + .../bad-lifecycle-compat-orkestra.yaml | 13 + .../bad-lifecycle-maturity-unknown.yaml | 12 + .../invalid/bad-policy-min-maturity.yaml | 13 + .../validate/valid/deprecation-accepted.yaml | 5 +- .../valid/deprecation-eol-accepted.yaml | 6 +- .../validate/valid/deprecation-timeline.yaml | 1 + .../validate/valid/lifecycle-alpha.yaml | 12 + .../valid/lifecycle-deprecated-accepted.yaml | 18 + .../lifecycle-deprecated-no-block-warns.yaml | 12 + .../validate/valid/lifecycle-stable.yaml | 15 + .../valid/policy-min-maturity-beta.yaml | 9 + pkg/katalog/type.go | 35 +- pkg/katalog/validate.go | 11 +- pkg/katalog/validate_deprecation.go | 64 --- pkg/katalog/validate_deprecation_test.go | 125 ------ pkg/katalog/validate_lifecycle.go | 215 ++++++++++ pkg/katalog/validate_lifecycle_test.go | 384 ++++++++++++++++++ pkg/merger/file.go | 8 + pkg/merger/merger.go | 19 + pkg/merger/registry.go | 18 +- pkg/note/helper.go | 8 + pkg/note/semver.go | 93 +---- pkg/registry/meta_test.go | 3 +- pkg/registry/pattern.go | 28 +- pkg/registry/simulate/helper.go | 2 +- pkg/registry/testdata/katalog.yaml | 1 + .../kordinator/dependency_kordinator.go | 2 +- pkg/runtime/reconciler/generic.go | 1 - pkg/runtime/reconciler/generic_target.go | 2 +- pkg/runtime/reconciler/run_surface_cleanup.go | 2 +- pkg/types/katalog.go | 130 ++++-- pkg/types/methods.go | 1 - pkg/types/types.go | 2 +- pkg/types/types_operatorbox.go | 81 ++-- pkg/utils/semver.go | 93 +++++ pkg/utils/semver_test.go | 130 ++++++ 134 files changed, 2854 insertions(+), 992 deletions(-) create mode 100644 documentation/concepts/conversion/03-serve-translation.md delete mode 100644 documentation/guides/registry/10-deprecation.md create mode 100644 documentation/guides/registry/10-lifecycle.md delete mode 100644 documentation/reference/schema/02-katalog/00-metadata/deprecation.md delete mode 100644 examples/registry-guide/09-deprecation/README.md rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/Dockerfile (100%) rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/Makefile (100%) rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/README.md (100%) rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/api/v1alpha1/database_types.go (100%) rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/cleanup.sh (100%) rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/cr.yaml (100%) rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/crd.yaml (100%) rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/e2e.yaml (100%) rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/go.mod.txt (100%) rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/go.sum.txt (100%) rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/hooks/database_hooks.go (100%) rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/katalog.yaml (100%) rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/simulate.yaml (100%) rename examples/registry-guide/{10-hooks-katalog => 09-hooks-katalog}/values.yaml (100%) rename examples/registry-guide/{11-typed-komposer => 10-typed-komposer}/README.md (100%) rename examples/registry-guide/{11-typed-komposer => 10-typed-komposer}/cleanup.sh (100%) rename examples/registry-guide/{11-typed-komposer => 10-typed-komposer}/cr-cache.yaml (100%) rename examples/registry-guide/{11-typed-komposer => 10-typed-komposer}/cr-database.yaml (100%) rename examples/registry-guide/{11-typed-komposer => 10-typed-komposer}/cr-webapp.yaml (100%) rename examples/registry-guide/{11-typed-komposer => 10-typed-komposer}/komposer.yaml (100%) rename examples/registry-guide/{11-typed-komposer => 10-typed-komposer}/simulate.yaml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/.github/workflows/publish-typed.yml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/.github/workflows/publish.yml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/README.md (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/Dockerfile (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/Makefile (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/README.md (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/api/v1alpha1/database_types.go (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/cleanup.sh (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/cr.yaml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/crd.yaml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/e2e.yaml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/go.mod.txt (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/go.sum.txt (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/hooks/database_hooks.go (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/katalog.yaml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/simulate.yaml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/database-operator/values.yaml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/webapp-operator/README.md (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/webapp-operator/cleanup.sh (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/webapp-operator/cr-with-ingress.yaml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/webapp-operator/cr.yaml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/webapp-operator/crd.yaml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/webapp-operator/e2e.yaml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/webapp-operator/katalog.yaml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/webapp-operator/simulate-ingress.yaml (100%) rename examples/registry-guide/{12-ork-action => 11-ork-action}/webapp-operator/simulate.yaml (100%) create mode 100644 examples/registry-guide/12-deprecation/README.md rename examples/registry-guide/{09-deprecation => 12-deprecation}/crd-v1.yaml (100%) rename examples/registry-guide/{09-deprecation => 12-deprecation}/katalog.yaml (89%) create mode 100644 examples/registry-guide/13-deprecation-accept/README.md create mode 100644 examples/registry-guide/13-deprecation-accept/crd-v1.yaml create mode 100644 examples/registry-guide/13-deprecation-accept/katalog.yaml create mode 100644 examples/registry-guide/13-deprecation-accept/komposer.yaml create mode 100644 examples/registry-guide/14-lifecycle-maturity/README.md create mode 100644 examples/registry-guide/14-lifecycle-maturity/crd.yaml create mode 100644 examples/registry-guide/14-lifecycle-maturity/katalog-alpha.yaml create mode 100644 examples/registry-guide/14-lifecycle-maturity/katalog-beta.yaml create mode 100644 examples/registry-guide/14-lifecycle-maturity/katalog-stable.yaml create mode 100644 examples/registry-guide/15-lifecycle-compatibility/README.md create mode 100644 examples/registry-guide/15-lifecycle-compatibility/crd.yaml create mode 100644 examples/registry-guide/15-lifecycle-compatibility/katalog.yaml create mode 100644 examples/registry-guide/16-komposer-accept/README.md create mode 100644 examples/registry-guide/16-komposer-accept/crd-cache.yaml create mode 100644 examples/registry-guide/16-komposer-accept/crd.yaml create mode 100644 examples/registry-guide/16-komposer-accept/katalog-cache.yaml create mode 100644 examples/registry-guide/16-komposer-accept/katalog-webapp.yaml create mode 100644 examples/registry-guide/16-komposer-accept/komposer.yaml create mode 100644 pkg/katalog/testdata/validate/invalid/bad-lifecycle-accept-on-katalog.yaml create mode 100644 pkg/katalog/testdata/validate/invalid/bad-lifecycle-compat-kubernetes.yaml create mode 100644 pkg/katalog/testdata/validate/invalid/bad-lifecycle-compat-orkestra.yaml create mode 100644 pkg/katalog/testdata/validate/invalid/bad-lifecycle-maturity-unknown.yaml create mode 100644 pkg/katalog/testdata/validate/invalid/bad-policy-min-maturity.yaml create mode 100644 pkg/katalog/testdata/validate/valid/lifecycle-alpha.yaml create mode 100644 pkg/katalog/testdata/validate/valid/lifecycle-deprecated-accepted.yaml create mode 100644 pkg/katalog/testdata/validate/valid/lifecycle-deprecated-no-block-warns.yaml create mode 100644 pkg/katalog/testdata/validate/valid/lifecycle-stable.yaml create mode 100644 pkg/katalog/testdata/validate/valid/policy-min-maturity-beta.yaml delete mode 100644 pkg/katalog/validate_deprecation.go delete mode 100644 pkg/katalog/validate_deprecation_test.go create mode 100644 pkg/katalog/validate_lifecycle.go create mode 100644 pkg/katalog/validate_lifecycle_test.go create mode 100644 pkg/utils/semver.go create mode 100644 pkg/utils/semver_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a16ebe397..42ea91582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,136 @@ +## v0.7.16 — Per-Target OperatorBox and MuxReconciler + +### Per-target operatorBox + +Each `serve.target.entries` entry can now declare its own `operatorBox` — resources, lifecycle hooks, and preReconcile gates (`enqueueGate` / `reconcileGate`). The reconciler selects the active box from `serve-alias` / `serve-target` annotations on the CR at reconcile time. + +```yaml +serve: + target: + entries: + v2-enabled: + operatorBox: + enqueueGate: "{{ isBusinessHours }}" + reconciler: + hooks: true + v2-ctor: + operatorBox: + reconciler: + default: false # use a dedicated constructor from ReconcilerRegistry +``` + +`reconciler.default: false` wires the target's constructor from `ReconcilerRegistry` at load time. A missing registry entry is a load-time error — the Runtime refuses to start rather than falling back silently. + +Surface switches are detected via `orkestra.orkspace.io/last-surface` and cleaned up with a label-selector sweep (`SweepOwnedNamespaced` / `SweepOwnedClusterScoped`) rather than template expansion, which is immune to spec fields being cleared before cleanup runs. `keepPreviousSurface: true` skips the sweep when set. + +`EffectiveOwnerKey` stamps and checks ownership as `.` for target-mode CRs, allowing per-surface resource isolation. + +--- + +### MuxReconciler and `pkg/intent/target/` + +`MuxReconciler` holds one `domain.Reconciler` per registered target. At reconcile time the kordinator reads the target annotation and dispatches to the matching reconciler, falling back to the CRD-level reconciler when no target-specific one is registered. The kordinator sees one reconciler; the routing is internal. + +Target resolution and CR construction move into a dedicated `pkg/intent/target/` package, separating the intent layer from the gateway API package. `target.go` and its tests follow. + +--- + +### `ork serve apply --override` + +Routing conflict detection (`409`) is still enforced when a target switch is attempted without an explicit override. Pass `--override` (or `?override=true` on the API) to route to the new target and trigger surface cleanup of the old one. + +--- + +### Fixture: `03-hooks-targets` + +`pkg/kubeclient/fixture/03-hooks-targets` — three targets on one CRD proving each dispatch path end-to-end: + +- `v2-enabled` — hooks with an `enqueueGate` (business hours), `featureEnabled: true` +- `v2-disabled` — same hook binary, `featureEnabled: false`, no gate +- `v2-ctor` — `reconciler.default: false`, dedicated constructor + +--- + +### Documentation + +`documentation/concepts/reusability/` — new section covering Reusability and Composition in Orkestra. + +--- + +### `lifecycle:` block — maturity, deprecation, compatibility, and platform policy + +A new top-level `lifecycle:` block on every Katalog and Komposer file replaces the old `metadata.deprecation:` approach with a first-class lifecycle model. + +```yaml +lifecycle: + maturity: beta # alpha | beta | stable | deprecated + + deprecation: + message: "Replaced by task-runner" + migratedTo: task-runner:v1.0.0 + timeline: + from: "2026-01-01" + to: "2027-01-01" + + compatibility: + orkestra: ">= 0.7.0" + kubernetes: ">= 1.28" +``` + +**Maturity** — four levels: `alpha`, `beta`, `stable`, `deprecated`. The presence of a `deprecation:` block is the primary signal; `maturity: deprecated` without a block emits a warning rather than an error. `maturity: deprecated` with a block is always valid. + +**Deprecation** — a deprecated Katalog always blocks startup when run directly. Consumers acknowledge it via `lifecycle.accept.patterns` on their Komposer, not by a field on the Katalog itself. + +**Compatibility** — declares minimum `orkestra` and `kubernetes` semver constraints. `ork validate` rejects patterns that declare versions below the installed runtime. + +**Kind boundary** — `lifecycle.accept.patterns` belongs on a Komposer. Declaring `lifecycle.accept` on a Katalog is a validation error. + +--- + +### Komposer-level lifecycle acceptance — `lifecycle.accept.patterns` + +Komposers accept deprecated Katalogs at the point of composition: + +```yaml +lifecycle: + accept: + patterns: + - name: webapp-operator + version: ">= 1.0.0, < 2.0.0" # optional semver range + - name: cache-operator +``` + +`version:` scopes acceptance to a semver range. Acceptance without a range applies to any version of that pattern. + +--- + +### Platform policy — `policy.lifecycle.minMaturity` + +Operators can declare a minimum maturity floor for all imported patterns: + +```yaml +policy: + lifecycle: + minMaturity: beta # alpha | beta | stable; deprecated is rejected +``` + +`ork validate` rejects any Katalog whose maturity is below the declared floor. `minMaturity: deprecated` is itself a validation error — the policy is a quality floor, not a filter. + +`policy:` is structured as `policy..*` so security, registry, and user-defined policy categories can grow alongside `lifecycle:` without flattening. + +--- + +### Registry guide examples 13–16 + +Four new self-contained steps extend the registry guide: + +- **13-deprecation-accept** — accept a deprecated Katalog via Komposer `lifecycle.accept.patterns`; scoped version acceptance +- **14-lifecycle-maturity** — maturity progression from `alpha` through `stable`; `ork inspect` output at each stage +- **15-lifecycle-compatibility** — declaring `orkestra` and `kubernetes` version constraints; validation rejection behaviour +- **16-komposer-accept** — `lifecycle.accept.patterns` on a Komposer composing both a deprecated and an alpha Katalog; scoped `version:` range + +--- + ## v0.7.15 — Gateway Webhook Intake + Artifact Signing ### Artifact signing — Cosign keyless, `publish:` block, local testing diff --git a/charts/orkestra/Chart.yaml b/charts/orkestra/Chart.yaml index 3c61fca53..71e3b75ea 100644 --- a/charts/orkestra/Chart.yaml +++ b/charts/orkestra/Chart.yaml @@ -6,8 +6,8 @@ description: >- namespace protection with admission webhooks, and observe everything in the Control Center. Runtime and Gateway deploy independently. type: application -version: 1.7.10 -appVersion: "0.7.10" +version: 1.7.16 +appVersion: "0.7.16" keywords: - orkestra @@ -65,16 +65,24 @@ annotations: url: https://raw.githubusercontent.com/orkspace/orkestra/main/documentation/assets/controlcenter/pv-watcher-workers.png artifacthub.io/changes: | - kind: added - description: "ork e2e: --report-file flag writes results as GFM markdown tables (passed + failed with error column)" + description: "Per-target operatorBox: each serve.target.entries entry can declare its own operatorBox with hooks, gates, and reconciler config" - kind: added - description: "ork e2e: spec.onFailure — diagnostic kubectl and shell commands printed when any expectation fails" + description: "MuxReconciler: dispatches to the correct reconciler per target at reconcile time; kordinator sees one reconciler" - kind: added - description: "ork e2e: per-expectation onFailure — diagnostics printed immediately when that specific checkpoint fails" + description: "pkg/intent/target/: target resolution and CR construction move into a dedicated package separate from gateway API" - kind: added - description: "ork e2e: terminal output split — passed cases first, failed cases with indented error detail" - - kind: fixed - description: "forEach silently no-op on NetworkPolicy, ResourceQuota, LimitRange, ClusterRole, ClusterRoleBinding" - - kind: fixed - description: "NetworkPolicy, ResourceQuota, LimitRange, ClusterRole, ClusterRoleBinding not tracked as children in CR detail" + description: "ork serve apply --override: force-route to a new target and trigger surface cleanup of the old one" + - kind: added + description: "Fixture 03-hooks-targets: three targets on one CRD proving hooks, gate, and constructor dispatch paths" + - kind: added + description: "documentation/concepts/reusability/: new Reusability and Composition section" - kind: fixed - description: "isFileMotif: bare relative motif paths not recognised as file paths" + description: "reconciler.default: false — constructor now wired from ReconcilerRegistry at load time; missing entry is a load-time error" + - kind: added + description: "lifecycle: block — maturity (alpha/beta/stable/deprecated), deprecation, compatibility, and platform policy on every Katalog" + - kind: added + description: "lifecycle.accept.patterns on Komposer: accept deprecated Katalogs at composition time with optional semver version range" + - kind: added + description: "policy.minMaturity: platform-level maturity floor; ork validate rejects patterns below the declared floor" + - kind: added + description: "Registry guide examples 13–16: deprecation-accept, lifecycle-maturity, lifecycle-compatibility, komposer-accept" diff --git a/cmd/cli/helper.go b/cmd/cli/helper.go index cc72aaca4..263cb93ec 100644 --- a/cmd/cli/helper.go +++ b/cmd/cli/helper.go @@ -726,6 +726,10 @@ func printTypedOperatorHint(err *katalog.TypedOperatorError, command string) { // katalog. Reads timeline state from the KatalogDeprecation methods. // Prints nothing if the block is nil or today is before timeline.from. func printKatalogDeprecation(d *orktypes.KatalogDeprecation) { + printKatalogDeprecationWithHint(d, "") +} + +func printKatalogDeprecationWithHint(d *orktypes.KatalogDeprecation, hint string) { if d == nil { return } @@ -734,17 +738,7 @@ func printKatalogDeprecation(d *orktypes.KatalogDeprecation) { if state == "none" { return } - printDeprecationBlock(state, d.Message, d.MigratedTo, d.TimelineTo(), d.DaysUntilEOL(today)) - - // Stale accept flag warnings — shown after the block so they are not missed. - if d.Accept != nil { - if d.Accept.Eol && !d.Accept.BeforeEol { - fmt.Printf(" %s accept.eol is set without accept.beforeEol — eol: true alone is not sufficient\n", yellow("⚠")) - } - if d.Accept.BeforeEol && !d.Accept.Eol && state == "eol" { - fmt.Printf(" %s accept.beforeEol is set but the EOL date has passed — add accept.eol: true or ork run will refuse to start\n", yellow("⚠")) - } - } + printDeprecationBlock(state, d.Message, d.MigratedTo, d.TimelineTo(), hint, d.DaysUntilEOL(today)) } // printPatternDeprecation prints the deprecation notice for a registry pattern @@ -767,7 +761,7 @@ func printPatternDeprecation(dep *registry.PatternDeprecated) { } // printDeprecationBlock renders the deprecation block for a given state. -func printDeprecationBlock(state, message, migrateTo, eolDate string, daysLeft int) { +func printDeprecationBlock(state, message, migrateTo, eolDate, hint string, daysLeft int) { switch state { case "eol": fmt.Printf("\n%s END OF LIFE\n", red("✗")) @@ -788,6 +782,9 @@ func printDeprecationBlock(state, message, migrateTo, eolDate string, daysLeft i if migrateTo != "" { fmt.Printf(" Migrate to: %s\n", bold(migrateTo)) } + if hint != "" { + fmt.Printf(" %s\n", hint) + } fmt.Println() } diff --git a/cmd/cli/play_chain.go b/cmd/cli/play_chain.go index aa6dd8595..45a6596bb 100644 --- a/cmd/cli/play_chain.go +++ b/cmd/cli/play_chain.go @@ -11,10 +11,10 @@ import ( "strings" "github.com/orkspace/orkestra/pkg/gateway/api" + orktarget "github.com/orkspace/orkestra/pkg/intent/target" "github.com/orkspace/orkestra/pkg/katalog" "github.com/orkspace/orkestra/pkg/merger" "github.com/orkspace/orkestra/pkg/registry/simulate" - orktarget "github.com/orkspace/orkestra/pkg/intent/target" orktmpl "github.com/orkspace/orkestra/pkg/resources/template" orktypes "github.com/orkspace/orkestra/pkg/types" "gopkg.in/yaml.v3" diff --git a/cmd/cli/validate.go b/cmd/cli/validate.go index 8ba860476..88db7f79b 100644 --- a/cmd/cli/validate.go +++ b/cmd/cli/validate.go @@ -153,7 +153,11 @@ Examples: builtIn := 0 custom := 0 - printKatalogDeprecation(k.Deprecation()) + var deprecationHint string + if konfig.IsKomposerKind(docKind) { + deprecationHint = "To acknowledge this import, add it to lifecycle.accept.patterns in your Komposer." + } + printKatalogDeprecationWithHint(k.Deprecation(), deprecationHint) // Print each CRD entry with enrichment info for _, entry := range sortedEntries { diff --git a/documentation/concepts/conversion/03-serve-translation.md b/documentation/concepts/conversion/03-serve-translation.md new file mode 100644 index 000000000..63bf6ff7a --- /dev/null +++ b/documentation/concepts/conversion/03-serve-translation.md @@ -0,0 +1,139 @@ +# serve.fields.values + +`serve.fields.values` runs at the Gateway before the CR is written. Callers submit a simplified intent; the Katalog fans the fields out to the CRD's internal schema. The CRD never sees the caller's vocabulary — the translation is declared once in the serve block and runs transparently on every apply. + +Use it when the caller should not be coupled to the CRD schema. The CRD is an implementation detail; the intent is the contract. + +--- + +## How it works + +Declare `serve.fields` in the Katalog. Each field entry maps one intent field to one or more CRD paths via `values:`. The expressions run against `.value` — the raw value the caller submitted. + +```yaml +serve: + fields: + schedule: + values: + schedule.minute: '{{ cronMinute .value }}' + schedule.hour: '{{ cronHour .value }}' + schedule.dayOfMonth: '{{ cronDom .value }}' + schedule.month: '{{ cronMonth .value }}' + schedule.dayOfWeek: '{{ cronDow .value }}' +``` + +The caller submits: + +```yaml +target: cronjob-tutorial +name: daily-backup +schedule: "0 2 * * 1-5" +image: "gcr.io/google-containers/busybox:latest" +``` + +What reaches the API server: + +```yaml +spec: + schedule: + minute: "0" + hour: "2" + dayOfMonth: "*" + month: "*" + dayOfWeek: "1-5" + image: gcr.io/google-containers/busybox:latest +``` + +The structured schedule is reconstructed from the cron string entirely within the serve layer. Nothing downstream — not the CRD schema, not the reconciler, not etcd — ever sees the flat string. + +--- + +## The pipeline order + +```text +Intent submitted (ork serve apply) + ↓ +serve.fields.values ← flat intent → CRD-shaped spec fields (at the Gateway) + ↓ +validation ← intent gate fires on the raw request fields + ↓ +CR written to API server ← structured spec only; caller's vocabulary gone + ↓ +normalize / mutation / reconcile ← see the CRD shape throughout +``` + +`serve.fields.values` runs before the CR reaches the API server. Validation rules on `request.*` fields fire against the caller's raw input — before the fanout — so error messages speak the caller's vocabulary, not the CRD's. + +```yaml +validation: + rules: + - field: request.schedule + operator: exists + message: "schedule is required — use a cron expression (e.g. \"*/5 * * * *\")" + action: deny +``` + +--- + +## One field to many CRD paths + +A single intent field can fan out to any number of CRD paths. Each key under `values:` is a dot-notation path into `spec`: + +```yaml +serve: + fields: + schedule: + values: + schedule.minute: '{{ cronMinute .value }}' + schedule.hour: '{{ cronHour .value }}' + schedule.dayOfMonth: '{{ cronDom .value }}' + schedule.month: '{{ cronMonth .value }}' + schedule.dayOfWeek: '{{ cronDow .value }}' +``` + +Five CRD fields from one intent field. The template functions (`cronMinute`, `cronHour`, `cronDom`, `cronMonth`, `cronDow`) each extract one component from the cron string. + +--- + +## Comparing the three approaches + +| | `normalize:` | `conversion.paths:` | `serve.fields.values` | +|---|---|---|---| +| **Translation point** | Reconciler | API server (`/convert`) | Gateway (before CR is written) | +| **CRD versions** | One | Two or more | One | +| **Caller submits via** | `kubectl apply` | `kubectl apply` | `ork serve apply` | +| **Caller sees CRD schema** | Yes | Yes | No | +| **Gateway required** | No | Yes | Yes | + +`serve.fields.values` is the only approach where the CRD schema is entirely hidden from callers. The other two require the caller to know the CRD's field names, even if the format is flexible. + +--- + +## Try it + +```bash +ork init --pack use-cases/crd-conversion/with-serve-translation +cd with-serve-translation +``` + +Test the field fanout locally without a cluster: + +```bash +ork serve play -i intent.yaml -t dev +``` + +Prints the built CR with `spec.schedule` as the structured object. Try an invalid cron string: + +```bash +ork serve play -i intent-invalid.yaml -t dev +``` + +The intent gate fires on `request.schedule` and returns the error in the caller's vocabulary. + +--- + +## Where to go next + +- **[Schema Evolution](./index.md)** — pick-one table +- **[Self-service and target mode](../self-service/index.md)** — how `ork serve apply` and the Gateway delivery layer work +- **[serve.fields schema](../../reference/schema/02-katalog/22-serve-field-translation.md)** — full `serve.fields.values` reference and all field config options diff --git a/documentation/concepts/conversion/index.md b/documentation/concepts/conversion/index.md index 3c01cdf28..745362561 100644 --- a/documentation/concepts/conversion/index.md +++ b/documentation/concepts/conversion/index.md @@ -2,36 +2,53 @@ Schema evolution is the problem of changing what a CR looks like over time — without breaking the operators, clients, and stored objects that depend on the current shape. -Orkestra provides two approaches. The choice comes down to one question: does the Kubernetes API server need to know there are two versions? +Orkestra provides three approaches, each anchored to a different API layer. The choice is really a question of where in the stack the translation should live. --- -## Two approaches +## Three approaches, three layers -**[`normalize:`](./01-normalize.md)** — the API server never sees two versions. The Katalog normalises input format variation before reconcile runs. One CRD version. No webhook. No TLS. +**[Kubernetes API — `conversion.paths:`](./02-conversion-paths.md)** -**[`conversion.paths:`](./02-conversion-paths.md)** — the API server stores objects at one version and serves them at another. Orkestra Gateway handles `/convert`. Multi-version CRD, bidirectional, lossless. +The API server is the translation point. Orkestra Gateway handles `/convert`; the API server stores objects at one version and serves them at another. Multi-version CRD, bidirectional, lossless. + +Use when external clients target a specific API version, or when live objects at v1 must remain readable as v1. --- -## Pick one +**[Runtime API — `normalize:`](./01-normalize.md)** + +The reconciler is the translation point. The runtime normalises input format variation before reconcile runs. One CRD version. No webhook. No TLS. + +Use when you control who creates the CRs and want to tolerate input shape variation without versioning overhead. + +--- -| | `normalize:` | `conversion.paths:` | -|---|---|---| -| **CRD versions** | One | Two or more | -| **Kubernetes sees** | One schema | Multi-version CRD spec | -| **Conversion webhook** | No | Yes — Orkestra Gateway's `/convert` | -| **TLS required** | No | Yes — auto-generated | -| **Objects in etcd** | One format | One storage version, served in any declared version | -| **Best for** | Internal operators, tolerating input shape variation | Public APIs, external clients targeting a specific version, migrating live objects | +**[Gateway API — `serve.fields.values`](./03-serve-translation.md)** + +The Gateway is the translation point. Callers submit a simplified intent through the Gateway API; `serve.fields.values` fans the fields out to the CRD's internal schema before the CR is written. One CRD version. No webhook. The CRD schema is an implementation detail — callers never see it. + +Use when callers submit intents through the Gateway and should not be coupled to the CRD schema. + +--- -If you control who creates the CRs and want to iterate quickly: `normalize:`. +## At a glance -If external clients target a specific API version, or you have live objects at v1 that **must** remain readable as v1: `conversion.paths:`. +| | Kubernetes API | Runtime API | Gateway API | +|---|---|---|---| +| **Mechanism** | `conversion.paths:` | `normalize:` | `serve.fields.values` | +| **Translation point** | API server (`/convert`) | Reconciler | Gateway (before CR is written) | +| **CRD versions** | Two or more | One | One | +| **Caller sees CRD schema** | Yes | Yes | No | +| **Conversion webhook** | Yes — Orkestra Gateway's `/convert` | No | No | +| **TLS required** | Yes — auto-generated | No | No | +| **Gateway required** | Yes | No | Yes | +| **Objects in etcd** | One storage version, served in any declared version | One format | One format | --- ## Where to go next -- **[normalize:](./01-normalize.md)** — input tolerance without versioning overhead -- **[conversion.paths:](./02-conversion-paths.md)** — multi-version API with Orkestra as the conversion webhook +- **[Kubernetes API — conversion.paths:](./02-conversion-paths.md)** — multi-version CRD with Orkestra as the conversion webhook +- **[Runtime API — normalize:](./01-normalize.md)** — input tolerance without versioning overhead +- **[Gateway API — serve.fields.values](./03-serve-translation.md)** — Gateway-layer translation; callers never see the CRD schema diff --git a/documentation/concepts/lifecycle/index.md b/documentation/concepts/lifecycle/index.md index 018898794..60697c369 100644 --- a/documentation/concepts/lifecycle/index.md +++ b/documentation/concepts/lifecycle/index.md @@ -4,9 +4,7 @@ OLM exists because operators were binaries. When the unit of distribution is a b Orkestra removes the problem at the root. The operator is not a binary — it is a pattern. Patterns are data. Data's lifecycle is not an external concern to manage. It is a built-in property of the artifact. -**Lifecycle follows production.** The same model that bakes tests into the artifact at publish time ([simulate](../simulate/) gates + [e2e](../e2e/) gates, proof in OCI annotations) is the model that governs upgrade, deprecation, and deletion. There is no separate lifecycle system to install. You write patterns, and the lifecycle comes with them. - -This document covers the lifecycle of one pattern — from first write to deprecation. The whitepaper covers the binary-vs-data argument in depth: [Declarative Operators Whitepaper](/publications/01-declarative-operators-whitepaper). +**Lifecycle follows production.** The same model that bakes tests into the artifact at publish time is the model that governs maturity, upgrade, compatibility, deprecation, and deletion. There is no separate lifecycle system to install. You write patterns, and the lifecycle comes with them. --- @@ -15,13 +13,13 @@ This document covers the lifecycle of one pattern — from first write to deprec | Stage | How it works | |-------|-------------| | **Create** | A Katalog is YAML — no build pipeline, no image, no binary | -| **Validate** | `ork validate` — offline, schema and security posture | +| **Validate** | `ork validate` — offline, schema, security posture, maturity and compatibility checks | | **Test** | `ork simulate` (sub-second, no cluster) + `ork e2e` (real cluster) | | **Distribute** | `ork push` — gates run, proof baked into OCI annotations, artifact ships | -| **Inspect** | `ork inspect` — proof is visible before pulling, before importing | +| **Inspect** | `ork inspect` — proof visible before pulling, before importing | | **Consume** | `ork pull` — artifact arrives with its proof | | **Upgrade** | Version bump in the Komposer — same push pipeline, same gates, proof baked into the new artifact; never automatic | -| **Deprecate** | `metadata.deprecation` — author sees it at `ork push`; consumers at `ork inspect`, `ork pull`, `ork validate`; `ork run`/`ork gate` enforce `accept` | +| **Deprecate** | `lifecycle.deprecation` — author sees it at `ork push`; consumers at `ork inspect`, `ork pull`, `ork validate`; `ork run` blocks unless acknowledged via Komposer `lifecycle.accept.patterns` | | **Delete** | `security.deletionProtection` — webhook blocks `kubectl delete` on CRs, CRDs, and Orkestra infrastructure | --- @@ -39,27 +37,27 @@ spec: crds: database: apiTypes: - ... + ... app: apiTypes: - ... + ... dependsOn: - database: healthy # app does not reconcile until database is healthy + database: healthy ``` -Complex Katalogs can import Motifs — reusable resource blueprints for security posture, probes, retry policy, and RBAC. Motifs are themselves versioned OCI artifacts. +Complex Katalogs can import Motifs — reusable fragments for security posture, probes, retry policy, and RBAC. Motifs are themselves versioned OCI artifacts. --- ## Validation and testing -`ork validate` runs entirely offline. It checks schema, security posture, and structural correctness. No cluster required. +`ork validate` runs entirely offline. It checks schema, security posture, structural correctness, and — when `lifecycle:` is declared — maturity signals and compatibility ranges. `ork simulate` runs the real reconciler against an in-memory cluster. It asserts which resources are created in which reconcile cycle. Sub-second, no Docker, runs in CI. `ork e2e` spins up a kind cluster, deploys the operator, and verifies behavior against real scheduling and webhooks. This is the outer gate before publishing. -Both are declarative — they are YAML files describing expectations, not test scripts. That matters for the lifecycle: the tests ship with the pattern, they are reviewable, and they cannot be silently skipped. +Both are declarative — YAML files describing expectations, not test scripts. The tests ship with the pattern, are reviewable, and cannot be silently skipped. --- @@ -69,7 +67,7 @@ Both are declarative — they are YAML files describing expectations, not test s ork push database:v1.0.0 ./database/ ``` -`ork push` runs both gates automatically. If either fails, the push is blocked. When they pass, the results are baked into OCI annotations attached to the artifact: +`ork push` runs both gates automatically. If either fails, the push is blocked. When they pass, results are baked into OCI annotations on the artifact: | Annotation | What it records | |-----------|-----------------| @@ -78,11 +76,14 @@ ork push database:v1.0.0 ./database/ | `io.orkestra.e2e.status` | Verified against a real cluster | | `io.orkestra.e2e.assertions` | Total expectations | | `io.orkestra.katalog.typed` | Whether a custom runtime is required | -| `io.orkestra.deprecated` | Lifecycle status | -| `io.orkestra.deprecated.message` | Deprecation message | -| `io.orkestra.katalog.deprecated.migrated_to` | Migration target | -| `io.orkestra.katalog.deprecated.timeline_from` | Deprecation window open date | -| `io.orkestra.katalog.deprecated.timeline_to` | End-of-life date | +| `io.orkestra.lifecycle.maturity` | Maturity level declared by the author | +| `io.orkestra.lifecycle.deprecated` | Whether the pattern is deprecated | +| `io.orkestra.lifecycle.deprecated.message` | Deprecation message | +| `io.orkestra.lifecycle.deprecated.migrated_to` | Migration target | +| `io.orkestra.lifecycle.deprecated.timeline_from` | Deprecation window open date | +| `io.orkestra.lifecycle.deprecated.timeline_to` | End-of-life date | +| `io.orkestra.lifecycle.compat.kubernetes` | Kubernetes version range | +| `io.orkestra.lifecycle.compat.orkestra` | Orkestra version range | The artifact is self-describing. A consumer reads its proof before pulling. @@ -94,7 +95,7 @@ The artifact is self-describing. A consumer reads its proof before pulling. ork inspect database:v1.0.0 ``` -`ork inspect` shows the simulation status, e2e status, assertion counts, deprecation status, and the list of files inside the artifact — before pulling, before importing. +`ork inspect` shows simulation status, e2e status, assertion counts, maturity level, compatibility ranges, and deprecation state — before pulling, before importing. ```bash ork pull database:v1.0.0 @@ -106,21 +107,15 @@ ork pull database:v1.0.0 ## Upgrade -Patterns are versioned with OCI tags. `database:v1.0.0` and `database:v1.1.0` are distinct artifacts with distinct proofs — not binary releases that need a separate controller to manage, but plain OCI artifacts that carry everything they verified at publish time. - -### The upgrade flow +Patterns are versioned with OCI tags. `database:v1.0.0` and `database:v1.1.0` are distinct artifacts with distinct proofs — not binary releases that need a separate controller to manage. ```bash ork push database:v1.1.0 ./database/ ``` -The same push pipeline runs on `v1.1.0` as it did on `v1.0.0`. `ork simulate` runs the new pattern against an in-memory cluster and asserts exactly which resources are created in which reconcile cycle. `ork e2e` verifies it against a real cluster. Both must pass before the artifact is written. If either fails, the push is blocked — there is no "push and hope" path. - -The new artifact is self-describing. `ork inspect database:v1.1.0` shows simulation assertion count, e2e status, and any deprecation state before anything is pulled or imported. - -### Upgrade is an explicit decision +The same push pipeline runs on every version. The new artifact is self-describing. `ork inspect database:v1.1.0` shows its proof before anything is pulled or imported. -The Komposer references the new version: +The Komposer references the new version explicitly: ```yaml imports: @@ -128,26 +123,61 @@ imports: - url: database:v1.1.0 ``` -Until you change that reference, nothing upgrades. There is no autosync, no watch loop, no version resolver that silently bumps the version when a newer tag appears. The decision to run `v1.1.0` is a line of YAML in a file that goes through code review. A PR adding that line carries a link to the proof that `v1.1.0` was verified — the OCI annotations are visible in `ork inspect` output and are part of the artifact record. +Until you change that reference, nothing upgrades. There is no autosync, no watch loop, no version resolver that silently bumps the version when a newer tag appears. The decision to run `v1.1.0` is a line of YAML in a file that goes through code review. + +--- + +## The `lifecycle:` block + +`lifecycle:` is a top-level field on a Katalog, at the same level as `metadata`, `spec`, and `gateway`. It is the policy layer for the artifact — it changes what tooling does, not what the operator does at runtime. -See [Foundations: Configuration is Deliberate](../../foundations/03-no-autosync.md) for the full argument. +```yaml +lifecycle: + maturity: stable + deprecation: + message: "Use database-v2. Improved connection pooling and status reporting." + migratedTo: "oci://ghcr.io/myorg/database-v2:v1.0.0" + timeline: + from: "2026-09-01" + to: "2027-03-01" + compatibility: + kubernetes: ">=1.31" + orkestra: ">=0.7.14" +``` + +All sub-fields are optional. Maturity and compatibility are advisory — read by tooling at validate time. Deprecation is enforced: `ork run` and the Gateway refuse to start a deprecated Katalog unless a Komposer has explicitly acknowledged it. + +--- + +## Maturity + +`lifecycle.maturity` signals the stability level of the pattern. It affects how `ork validate` warns and how the pattern appears in registry listings. + +| Value | Meaning | `ork validate` behaviour | +|-------|---------|--------------------------| +| `alpha` | Experimental, breaking changes expected | Warning: not recommended for production | +| `beta` | Stabilising, API mostly settled | Warning: lower severity | +| `stable` | Production-ready, semantic versioning applies | No warning | +| `deprecated` | Replaced or abandoned | Warning if `lifecycle.deprecation` is not also set (block is the primary signal) | + +When `maturity` is omitted, tooling treats the pattern as `stable` if it has a version ≥ 1.0.0, otherwise `beta`. --- ## Deprecation ```yaml -metadata: - name: database +lifecycle: + maturity: deprecated deprecation: - message: "Use v2.0.0 — improved connection pooling and status reporting" - migratedTo: database:v2.0.0 + message: "Use database-v2. Improved connection pooling and status reporting." + migratedTo: "oci://ghcr.io/myorg/database-v2:v1.0.0" timeline: from: "2026-09-01" # deprecation window opens — warning with countdown to: "2027-03-01" # end of life ``` -The deprecation is part of the artifact. It is surfaced at every touchpoint — and the author sees it first: `ork push` shows the exact warning consumers will see, immediately after the katalog is validated and before the artifact is uploaded. After that, `ork validate`, `ork inspect`, and `ork pull` all surface the same state-aware warning. The state is computed from today vs the timeline: +Deprecation is part of the artifact and surfaced at every touchpoint. The author sees it first: `ork push` shows the exact warning consumers will see before the artifact is uploaded. After that, `ork validate`, `ork inspect`, and `ork pull` all surface the same state-aware warning: | Condition | Shown as | |-----------|----------| @@ -155,28 +185,50 @@ The deprecation is part of the artifact. It is surfaced at every touchpoint — | `from ≤ today < to` | ⚠ Deprecated · N days until EOL | | `today ≥ to` | ✗ END OF LIFE | -The pattern remains in the registry for compatibility — it is not deleted — but it is clearly marked at every touchpoint. `ork validate` enforces that `message` is present and that `from` is strictly before `to`. +The pattern remains in the registry — it is not deleted — but is clearly marked at every touchpoint. -**Runtime enforcement** — `ork run` and `ork gate` refuse to start a deprecated or EOL Katalog unless the operator has explicitly acknowledged it in the file: +**Runtime enforcement** — `ork run` refuses to start a deprecated or EOL Katalog when run directly. To run a deprecated Katalog, import it into a Komposer and acknowledge it there: ```yaml -deprecation: +lifecycle: accept: - beforeEol: true # allows startup during the deprecation warning window - eol: true # additionally required after the end-of-life date + patterns: + - name: database + author: myorg ``` -This makes the decision to run a deprecated pattern a visible, reviewable record in the Katalog itself — not a flag in a deploy script. `eol: true` without `beforeEol: true` is not accepted; both must be set to run past the EOL date. +The acceptance lives on the Komposer — the consumer making a deliberate, reviewable decision — not on the Katalog itself. -The separation is deliberate: `ork validate` is a pre-flight tool and shows warnings without blocking. The enforcement gate lives at runtime startup — after validation passes, before the operator begins reconciling. A PR adding `eol: true` is a traceable, reviewable decision rather than a buried CLI flag. +--- + +## Compatibility -See the [deprecation schema reference](../../reference/schema/02-katalog/00-metadata/deprecation.md) for the full field list, enforcement table, and OCI annotation mapping. +`lifecycle.compatibility` declares which Kubernetes and Orkestra versions this pattern is verified to work with. Both fields accept a semver range. + +```yaml +lifecycle: + compatibility: + kubernetes: ">=1.31" + orkestra: ">=0.7.14" +``` + +`ork validate --cluster` checks the running cluster's server version against the declared range and fails if incompatible. `ork run` and `ork serve apply` also check at apply time — a CR apply against an incompatible cluster fails with a clear message rather than a runtime error. + +Without `--cluster`, the compatibility check is skipped and a note is printed. + +Accepted range syntax follows semver conventions: + +```text +>=1.31 # at least 1.31 +>=1.28, <1.33 # between 1.28 and 1.33 exclusive +^1.31 # >=1.31, <2.0 +``` --- ## Deletion protection -Deletion protection attaches a label to every managed CR and CRD, and registers a validating webhook that blocks any `kubectl delete` on labeled resources. When enabled, it protects both your CRs and Orkestra's own infrastructure — the Deployment, Service, webhook configurations, and supporting resources all carry the same label and are blocked by the same webhook. +Deletion protection attaches a label to every managed CR and CRD, and registers a validating webhook that blocks any `kubectl delete` on labeled resources. When enabled, it protects both your CRs and Orkestra's own infrastructure. ```yaml security: @@ -192,6 +244,4 @@ Per-CRD overrides let you opt individual CRDs out of CR-level or CRD-level prote OLM solved the right problem for its era. When operators were binaries, lifecycle management had to be external — and OLM built exactly the system that required: its own controllers, its own CRDs, its own installation lifecycle. The overhead was the necessary cost of the binary constraint. -Orkestra makes the question moot. The operator is not a binary — it is a Katalog. There are no new CRDs, no new controllers, no lifecycle stack to operate. [Your CRD is enough](/blog/your-crd-is-enough). The lifecycle — versioning, testing, distribution, deprecation, deletion — is expressed in the same artifact language as the operator itself. - -The proof is in the annotation. Every `ork push` attaches what was verified, to what level, and when. Every `ork inspect` surfaces it. The lifecycle is not a process running somewhere. It is a record traveling with the artifact. +Orkestra makes the question moot. The operator is a Katalog. Maturity, compatibility, deprecation, and deletion protection are fields in that Katalog. There are no new CRDs, no new controllers, no lifecycle stack to operate. The lifecycle is not a process running somewhere else. It is a record traveling with the artifact — from first push to final EOL. diff --git a/documentation/faqs/07-registry.md b/documentation/faqs/07-registry.md index 78d04571e..2612c680d 100644 --- a/documentation/faqs/07-registry.md +++ b/documentation/faqs/07-registry.md @@ -163,9 +163,65 @@ valid signature from one of the listed identities is present. --- +## How do I mark a pattern as deprecated? + +Set `lifecycle.maturity: deprecated` and fill in `lifecycle.deprecation`: + +```yaml +lifecycle: + maturity: deprecated + deprecation: + migratedTo: ghcr.io/myorg/katalogs/webapp@v2.0.0 + message: "Migrate to v2.0.0 before 2027-01-01." + timeline: + from: "2026-01-01" + to: "2027-01-01" +``` + +Then push normally — `ork push` creates a new artifact with the deprecation metadata baked in. The previous version is not modified. + +→ [Lifecycle guide](guides/registry/10-lifecycle.md) + +--- + +## What does maturity: alpha mean for consumers? + +`ork validate` prints a non-fatal warning when a Katalog or an imported pattern carries `maturity: alpha` or `maturity: beta`. The warning does not block validation — it is informational. + +To acknowledge the warning on a Komposer, list the pattern in `lifecycle.accept.patterns`: + +```yaml +lifecycle: + accept: + patterns: + - name: cache-operator + author: myorg +``` + +--- + +## How do I suppress lifecycle warnings in ork validate? + +Warnings for deprecated or pre-stable imports are suppressed by declaring acceptance on the Komposer — not by a flag, and not on the Katalog itself. + +```yaml +lifecycle: + accept: + patterns: + - name: webapp-operator + author: myorg +``` + +Each `patterns` entry covers all lifecycle concerns for that import — deprecated, alpha, beta. + +→ [Lifecycle — Accept](guides/registry/10-lifecycle.md#accept--komposer-level) + +--- + ## Further reading - **[Orkestra Registry](../orkestra-registry/index.md)** — full publishing and pulling reference - **[simulate gates](../orkestra-registry/05-simulate.md)** — how simulate quality signals work - **[e2e gates](../orkestra-registry/04-e2e.md)** — how e2e quality signals work - **[Artifact Signing](../security/10-artifact-signing.md)** — keyless signing, CI setup, local testing +- **[Lifecycle](guides/registry/10-lifecycle.md)** — maturity, deprecation, compatibility, and acceptance diff --git a/documentation/guides/registry/09-policy.md b/documentation/guides/registry/09-policy.md index 1e820d287..eacf09f49 100644 --- a/documentation/guides/registry/09-policy.md +++ b/documentation/guides/registry/09-policy.md @@ -134,4 +134,4 @@ cd 04-katalog-platform # Follow the steps in the README ``` -→ Next: [Deprecation](10-deprecation.md) +→ Next: [Lifecycle](10-lifecycle.md) diff --git a/documentation/guides/registry/10-deprecation.md b/documentation/guides/registry/10-deprecation.md deleted file mode 100644 index ab0509a1d..000000000 --- a/documentation/guides/registry/10-deprecation.md +++ /dev/null @@ -1,122 +0,0 @@ -# Deprecation - -Patterns in the registry are immutable at a version — `v1.0.0` cannot be modified after it is pushed. When a pattern is superseded, you deprecate the old version rather than delete it. Consumers on the old version see a warning and a migration path. - ---- - -## Marking a pattern as deprecated - -Add `deprecated: true` and a `deprecationMessage` to `metadata`: - -```yaml -# katalog.yaml — web-service v1.0.0 (deprecated) -apiVersion: orkestra.orkspace.io/v1 -kind: Katalog -metadata: - name: web-service - version: v1.0.0 - deprecation: - migrateTo: oci://ghcr.io/myorg/katalog/stateless-app:v1.0.0 - message: > - web-service is superseded by stateless-app:v1.0.0, which adds - multi-port support and structured health check configuration. - See: ork inspect stateless-app:v1.0.0 -``` - -Push the deprecation annotation: - -```bash -ork push web-service:v1.0.0 ./deprecated/ -``` - -This creates a new artifact at `v1.0.0` with the deprecation metadata. The previous artifact is not modified — `ork push` is always a new upload, never an in-place mutation. - ---- - -## What consumers see - -Consumers who inspect the deprecated version see the warning immediately: - -```bash -ork inspect web-service:v1.0.0 -``` - -```text -⚠ This pattern is deprecated. - Migrate to: ghcr.io/myorg/katalogs/webapp@v2.0.0 - Note: v1.0.0 is end-of-life. Migrate to v2.0.0: add spec.healthPath to your CRs -web-service:v1.0.0 - Kind: Katalog - -To import: - imports: - - motif: oci://ghcr.io/myorg/motifs/web-service:v1.0.0 ← ⚠ deprecated -``` - -Komposers that import a deprecated Katalog will surface the warning at `ork validate` and `ork simulate` time — before any CR is applied. - ---- - -## The deprecation lifecycle - -```text -v1.0.0 ← active - │ - │ v1.1.0 ships (backwards-compatible) - ▼ -v1.0.0 ← ⚠ deprecated ("use v1.1.0") -v1.1.0 ← active - │ - │ migration complete — all consumers on v1.1.0 - ▼ -v1.0.0 ← retired (removed from index, not deleted) -v1.1.0 ← active -``` - -Retirement removes the pattern from `ork patterns` output but does not delete the OCI artifact. Consumers that pinned a digest can still pull. Consumers that use version tags get a "not found in index" message and should migrate. - ---- - -## Deprecating an entire pattern - -When a pattern is replaced by something conceptually different — not just a new version — mark the whole pattern as deprecated with a redirect: - -```yaml -metadata: - name: web-service - version: v1.0.0 - deprecation: - migrateTo: oci://ghcr.io/myorg/motifs/stateless-app:v1.0.0 - message: > - web-service is superseded by stateless-app:v1.0.0, which adds - multi-port support and structured health check configuration. - See: ork inspect stateless-app:v1.0.0 -``` - ---- - -## Best practices - -**Write the migration path in the deprecation message.** "Use v1.1.0" is not a migration path. "Add `probeProfile: standard` and `probePath: /health` to your `with:` block — both match v1.0.0 defaults" is. - -**Give consumers time.** Publish the new version and deprecate the old one simultaneously — don't delete `v1.0.0` when `v1.1.0` ships. Platform teams may have change-freeze windows. The registry is the mechanism that lets them upgrade on their own schedule. - -**Check for active importers before retiring.** `ork patterns` shows which patterns are imported by Komposers in the official registry. For your internal registry, check Komposers in your platform repository before retiring a pattern. - -**Version the deprecation message itself.** If the migration path changes (e.g., `v1.2.0` ships and is even better than `v1.1.0`), push an updated deprecation message to `v1.0.0`: - -```bash -# Update the deprecation message and re-push -ork push web-service:v1.0.0 ./deprecated/ -``` - ---- - -## Try it - -```bash -ork init --pack registry-guide -cd 09-deprecation - -# Follow the steps in the README -``` diff --git a/documentation/guides/registry/10-lifecycle.md b/documentation/guides/registry/10-lifecycle.md new file mode 100644 index 000000000..58128364a --- /dev/null +++ b/documentation/guides/registry/10-lifecycle.md @@ -0,0 +1,244 @@ +# Lifecycle + +The `lifecycle:` block is a policy annotation on any Katalog or Komposer. `lifecycle.maturity` is advisory — tooling (`ork validate`, `ork inspect`, the registry index) surfaces it as a signal. The `deprecation:` block within `lifecycle:` is enforced: `ork validate` warns, and `ork run` blocks startup if a deprecated pattern has not been accepted. The Gateway enforces the same gate on apply. + +--- + +## Maturity + +`lifecycle.maturity` signals how stable a pattern's API surface is: + +| Value | Meaning | +|-------|---------| +| `alpha` | Experimental — breaking changes expected between versions | +| `beta` | Stabilising — API is mostly settled, minor breakage possible | +| `stable` | Production-ready — semantic versioning applies | +| `deprecated` | Replaced — requires `lifecycle.deprecation` | + +```yaml +lifecycle: + maturity: alpha +``` + +`ork validate` prints a non-fatal warning for `alpha` and `beta`. `stable` and an absent `maturity` produce no output. `deprecated` without a `lifecycle.deprecation` block is an error. + +--- + +## Deprecation + +Patterns in the registry are immutable at a version — `v1.0.0` cannot be modified after it is pushed. When a pattern is superseded, you deprecate the old version rather than delete it. Consumers on the old version see a warning and a migration path. + +### Marking a pattern as deprecated + +The `lifecycle.deprecation` block is the primary signal — `maturity: deprecated` is optional when the block is present. Setting both is explicit but redundant: + +```yaml +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: webapp-operator + version: v1.0.0 + +lifecycle: + maturity: deprecated + deprecation: + migratedTo: ghcr.io/myorg/katalogs/webapp@v2.0.0 + message: "v1.0.0 is end-of-life. Migrate to v2.0.0: add spec.healthPath to your CRs." + timeline: + from: "2025-01-01" + to: "2026-06-01" +``` + +Push the deprecation: + +```bash +ork push webapp-operator:v1.0.0 ./deprecated/ +``` + +`ork push` is always a new upload — the previous artifact is not modified. + +### What consumers see + +```bash +ork inspect webapp-operator:v1.0.0 +``` + +```text +⚠ This pattern is deprecated. + Migrate to: ghcr.io/myorg/katalogs/webapp@v2.0.0 + Note: v1.0.0 is end-of-life. Migrate to v2.0.0: add spec.healthPath to your CRs + EOL: 2026-06-01 +``` + +Komposers that import a deprecated Katalog surface the warning at `ork validate` and `ork simulate` time — before any CR is applied. + +### The state machine + +```text +v1.0.0 ← active + │ + │ v2.0.0 ships (migration target) + ▼ +v1.0.0 ← ⚠ deprecated ("migrate to v2.0.0") +v2.0.0 ← active + │ + │ migration complete — all consumers on v2.0.0 + ▼ +v1.0.0 ← retired (removed from index, not deleted) +v2.0.0 ← active +``` + +Retirement removes the pattern from `ork patterns` output but does not delete the OCI artifact. Consumers that pinned a digest can still pull. Consumers that use version tags get a "not found in index" message. + +### The timeline block + +`timeline.from` and `timeline.to` are advisory — they document the deprecation window so consumers can plan. `ork validate` surfaces them as part of the deprecation warning. + +```yaml +lifecycle: + deprecation: + timeline: + from: "2025-01-01" # when deprecation was announced + to: "2026-06-01" # end-of-life: pattern will be retired after this date +``` + +### Best practices + +**Write the migration path in the message.** "Use v2.0.0" is not a migration path. "Add `spec.healthPath: /health` to your CRs — it was implicit in v1.0.0 but is required in v2.0.0" is. + +**Give consumers time.** Publish the new version and deprecate the old one simultaneously — don't retire `v1.0.0` the day `v2.0.0` ships. Platform teams may have change-freeze windows. + +**Check for active importers before retiring.** `ork patterns` shows which patterns are imported by Komposers in the official registry. For internal registries, check Komposers in your platform repository before retiring. + +--- + +## Compatibility + +`lifecycle.compatibility` declares the Kubernetes and Orkestra versions the pattern has been verified against. Both fields accept [Masterminds/semver](https://github.com/Masterminds/semver) range syntax — the same library Helm uses. + +```yaml +lifecycle: + maturity: stable + compatibility: + kubernetes: ">=1.28" # verified against Kubernetes 1.28 and above + orkestra: ">=0.7.0" # verified against Orkestra 0.7.0 and above +``` + +`ork validate` checks that the range string is valid semver syntax. At apply time, the runtime compares: + +- `compatibility.kubernetes` against the live cluster server version +- `compatibility.orkestra` against the version baked into the `ork` binary + +A mismatch produces an error before any reconcile loop starts. + +Common range forms: + +```text +>=1.28 at least 1.28 +>=1.28, <1.33 between 1.28 and 1.33 exclusive +^1.28 >=1.28, <2.0 +~1.28 >=1.28, <1.29 +``` + +--- + +## Accept — Komposer level + +A Komposer that imports Katalogs with lifecycle concerns (deprecated, alpha, or beta) uses `lifecycle.accept.patterns` to acknowledge each import by name: + +```yaml +lifecycle: + accept: + patterns: + - name: webapp-operator # accepts all lifecycle concerns for this import + author: myorg # optional — disambiguates if the name is not unique + - name: cache-operator + author: myorg +``` + +Each entry covers the full lifecycle state of that Katalog — deprecated, pre-stable maturity, or both. Without an entry, `ork validate` warns for every unacknowledged import. + +`lifecycle.accept.patterns` is only valid on Komposers. Declaring it on a Katalog is an error. + +### Scoping an acceptance to a specific version + +`version` is an optional semver range that scopes the acceptance to a specific imported version. When set, `ork validate` warns if the imported version no longer matches — keeping the Komposer tidy as imports graduate: + +```yaml +lifecycle: + accept: + patterns: + - name: webapp-operator + author: myorg + version: "=1.0.0" # only accepts this specific deprecated version +``` + +Without `version`, the entry accepts any version of that pattern (current default behaviour). + +### The author field + +`author` is optional. When set, an entry only matches imports whose `metadata.author` equals that value. Use it when two registries publish a Katalog with the same name: + +```yaml +patterns: + - name: webapp-operator + author: myorg # only the myorg version, not a same-named pattern from another publisher +``` + +When `author` is omitted, any publisher's pattern with that name is accepted. + +--- + +## Platform policy + +`policy:` is a platform-tier top-level block distinct from `lifecycle:`. Where `lifecycle:` is about what a pattern signals, `policy:` is about what a Komposer allows. + +```yaml +policy: + lifecycle: + minMaturity: beta # alpha imports are errors, not warnings +``` + +`policy.lifecycle.minMaturity` sets a floor: any import whose maturity falls below the declared level is an error at `ork validate` time instead of a warning. Valid floors are `alpha`, `beta`, and `stable`. (`deprecated` is not a valid floor — deprecated imports require explicit `lifecycle.accept.patterns` regardless of any floor.) + +`policy:` is structured as `policy..*` so other policy categories — security, registry, user-defined — can grow alongside `lifecycle:` without flattening into one block. + +--- + +## Full schema + +```yaml +lifecycle: + maturity: alpha | beta | stable | deprecated # optional; deprecation block alone implies deprecated + deprecation: # presence implies deprecated; block alone is sufficient + migratedTo: # where to migrate + message: "" # required + timeline: + from: "YYYY-MM-DD" # when deprecation was announced + to: "YYYY-MM-DD" # EOL date + compatibility: # optional + kubernetes: "" + orkestra: "" + accept: # Komposer only + patterns: + - name: + author: # optional — disambiguates when name is not unique + version: "" # optional — scope acceptance to a specific version + +policy: # optional; Komposer-level platform enforcement + lifecycle: + minMaturity: alpha | beta | stable # imports below this floor are errors (not warnings) +``` + +--- + +## Try it + +```bash +ork init --pack registry-guide +cd 09-deprecation # mark a pattern deprecated and migrate consumers +cd 13-deprecation-accept # katalog-level and komposer-level acceptance +cd 14-lifecycle-maturity # alpha, beta, stable in one directory +cd 15-lifecycle-compatibility # kubernetes and orkestra ranges +cd 16-komposer-accept # komposer accepting deprecated + alpha imports +``` diff --git a/documentation/guides/registry/index.md b/documentation/guides/registry/index.md index 91e2f8d83..9efdd7ca5 100644 --- a/documentation/guides/registry/index.md +++ b/documentation/guides/registry/index.md @@ -38,7 +38,7 @@ The quality signals are annotations baked into the OCI artifact at push time. `o | [Upgrading Patterns](07-upgrade.md) | Motif upgrade, katalog follows, version isolation, rollback | | [API Evolution](08-api-evolution.md) | CRD field restructuring — with webhooks and without | | [Policy](09-policy.md) | Platform admission, allowedRegistries, supply chain control | -| [Deprecation](10-deprecation.md) | Pattern lifecycle: deprecate, warn consumers, retire | +| [Lifecycle](10-lifecycle.md) | Maturity, deprecation, compatibility, and acceptance at katalog and komposer level | --- @@ -77,4 +77,4 @@ Set these before any `ork push` or `ork pull` command. `docker login ` - [Upgrading Patterns](07-upgrade.md) — motif upgrade, katalog follows, version isolation, rollback - [API Evolution](08-api-evolution.md) — CRD field restructuring with and without webhooks - [Policy](09-policy.md) — platform admission, allowedRegistries, supply chain control -- [Deprecation](10-deprecation.md) — pattern lifecycle: deprecate, warn consumers, retire +- [Lifecycle](10-lifecycle.md) — maturity, deprecation, compatibility, and acceptance at katalog and komposer level diff --git a/documentation/reference/schema/02-katalog/00-metadata/deprecation.md b/documentation/reference/schema/02-katalog/00-metadata/deprecation.md deleted file mode 100644 index f2e2e1577..000000000 --- a/documentation/reference/schema/02-katalog/00-metadata/deprecation.md +++ /dev/null @@ -1,183 +0,0 @@ -# metadata.deprecation - -The `deprecation` block marks a Katalog as deprecated and optionally schedules its end of life. The deprecation state is surfaced at every touchpoint: - -- **`ork push`** — shown to the author immediately after the katalog is validated, before the artifact is uploaded. The author sees exactly what consumers will see. -- **`ork validate`** — shown during pre-flight checks, without blocking. -- **`ork inspect`** — shown before pulling, annotating each version in `--versions` and the full detail view. -- **`ork pull`** — shown after the artifact is cached or extracted. - -The `accept` sub-block is the operator's explicit acknowledgement that a deprecated or EOL pattern is intentionally kept running — without it, `ork run` and `ork gate` refuse to start. - -```yaml -metadata: - name: database-operator - deprecation: - message: "Use postgres-operator:v2.0.0 — improved connection pooling and status reporting" - migratedTo: postgres-operator:v2.0.0 - timeline: - from: "2026-09-01" - to: "2027-03-01" - accept: - beforeEol: true # required to run during the deprecation warning window - eol: true # required to run after the end-of-life date -``` - ---- - -## Fields - -### `message` (required) - - -Human-readable explanation of the deprecation. Shown in every warning and in `ork inspect` output. Required when the `deprecation` block is present — `ork validate` fails if it is missing. - -### `migratedTo` - -The replacement pattern in `:` format. Shown alongside the deprecation message to give consumers a clear migration target. - -### `timeline` - -An optional schedule for the deprecation window. Both sub-fields are `YYYY-MM-DD` strings. Omitting `timeline` entirely declares the Katalog deprecated without a scheduled EOL. - -#### `timeline.from` - -The date the deprecation window opens. Before this date, the Katalog is declared deprecated but no timeline warning is shown. On and after this date, `ork inspect` and `ork pull` display the deprecation warning with the number of days until EOL. - -#### `timeline.to` - -The end-of-life date. On and after this date, the artifact is shown as **END OF LIFE** (red ✗). The artifact remains in the registry and remains pullable — this is a display state, not a deletion. - -`from` must be strictly before `to`. `ork validate` rejects equal or reversed dates. - -### `accept` - -Explicit operator acknowledgement that a deprecated or EOL Katalog is intentionally kept running. Without the appropriate `accept` field set, `ork run` and `ork gate` refuse to start. - -#### `accept.beforeEol` - -Set to `true` to allow `ork run` and `ork gate` to start while the pattern is in the deprecation warning window (state `"warning"`). A deprecation warning is still printed on every start. - -#### `accept.eol` - -Set to `true` (alongside `beforeEol: true`) to allow `ork run` and `ork gate` to start after the pattern has passed its end-of-life date (state `"eol"`). `eol: true` alone is not sufficient — `beforeEol` must also be true. - -Setting `accept.eol: true` is a strong signal in code review that a team has consciously decided to run a dead pattern. It should be accompanied by a tracked migration plan. - ---- - -## Display states - -The display state is computed by comparing today's date against the timeline: - -| Condition | State | Display | -|-----------|-------|---------| -| No `timeline` declared | `warning` | ⚠ Deprecated — `message` | -| `today < from` | `warning` | ⚠ Deprecated — `message` | -| `from ≤ today < to` | `warning` | ⚠ Deprecated — `message` · N days until EOL (YYYY-MM-DD) | -| `today ≥ to` | `eol` | ✗ END OF LIFE — `message` | - -`ork inspect --versions` uses the same logic to annotate each version row. - ---- - -## Enforcement at runtime startup - -`ork run` and `ork gate` check the deprecation policy after validation. The rules: - -| State | `accept` required | Behaviour | -|-------|-------------------|-----------| -| `none` (before `from`, or no block) | — | No gate | -| `warning` (deprecated, before EOL) | `beforeEol: true` | Blocked without it; warning still printed when set | -| `eol` (past `to`) | `beforeEol: true` + `eol: true` | Blocked without both; `eol` alone is not sufficient | - -The error message always shows the deprecation message, the migration target if set, and the exact YAML to add. - -## Validation rules - -`ork validate` enforces: - -- `message` is non-empty when `deprecation` is declared -- `timeline.from` and `timeline.to`, when present, must be valid `YYYY-MM-DD` dates -- `timeline.from` must be strictly before `timeline.to` - -`ork validate` does **not** enforce `accept` — it is a pre-flight tool and must not block. Enforcement is at runtime startup only. - ---- - -## OCI annotations - -`ork push` serialises the deprecation block to OCI manifest annotations. Consumers read these via `ork inspect` without pulling the artifact. - -| Annotation | Value | -|-----------|-------| -| `io.orkestra.deprecated` | `"true"` | -| `io.orkestra.deprecated.message` | The deprecation message | -| `io.orkestra.katalog.deprecated.migrated_to` | `migratedTo` value | -| `io.orkestra.katalog.deprecated.timeline_from` | `timeline.from` value | -| `io.orkestra.katalog.deprecated.timeline_to` | `timeline.to` value | - ---- - -## Example: immediate deprecation - -```yaml -metadata: - name: legacy-worker - deprecation: - message: "Replaced by task-runner which uses the Jobs API" - migratedTo: task-runner:v1.0.0 -``` - -## Example: scheduled EOL - -```yaml -metadata: - name: legacy-worker - deprecation: - message: "Replaced by task-runner which uses the Jobs API" - migratedTo: task-runner:v1.0.0 - timeline: - from: "2026-09-01" # warning with countdown starts - to: "2027-03-01" # shown as END OF LIFE after this date -``` - -## Example: accepted deprecation (running during warning window) - -```yaml -metadata: - name: legacy-worker - deprecation: - message: "Replaced by task-runner which uses the Jobs API" - migratedTo: task-runner:v1.0.0 - timeline: - from: "2026-09-01" - to: "2027-03-01" - accept: - beforeEol: true # migration in progress — team is aware -``` - -## Example: accepted EOL (running past end-of-life) - -```yaml -metadata: - name: legacy-worker - deprecation: - message: "Replaced by task-runner which uses the Jobs API" - migratedTo: task-runner:v1.0.0 - timeline: - from: "2026-09-01" - to: "2027-03-01" - accept: - beforeEol: true # required alongside eol - eol: true # explicit acknowledgement — migration tracking required -``` - ---- - -## See also - -- [metadata](./index.md) — full metadata block overview -- [ork validate](../../cli/06-validate.md) — offline validation including deprecation checks -- [ork inspect](../../cli/11-inspect.md) — displays deprecation state before pulling -- [Lifecycle](../../../concepts/lifecycle/index.md) — where deprecation fits in the pattern lifecycle diff --git a/documentation/reference/schema/02-katalog/00-metadata/index.md b/documentation/reference/schema/02-katalog/00-metadata/index.md index 984e3949a..314dc66c1 100644 --- a/documentation/reference/schema/02-katalog/00-metadata/index.md +++ b/documentation/reference/schema/02-katalog/00-metadata/index.md @@ -19,15 +19,6 @@ metadata: - stateful - aws createdBy: operator # optional — affects Control Center UI - deprecation: # optional — see deprecation.md - message: Use postgres-operator instead. - migratedTo: postgres-operator:2.0.0 - timeline: - from: "2026-09-01" - to: "2027-03-01" - accept: - beforeEol: true - eol: true ``` --- @@ -68,11 +59,6 @@ SPDX license identifier (e.g. `Apache-2.0`, `MIT`). Displayed in registry listin Keywords for categorising the Katalog in the Orkestra Registry. Aid discovery via `ork patterns --tag ` and indexing in Artifact Hub. Have no effect on runtime behaviour. -### `deprecation` - -Marks this Katalog as deprecated. Surfaced at `ork push`, `ork validate`, `ork inspect`, and `ork pull`. The `accept` sub-block controls runtime startup enforcement. - -See [deprecation.md](./deprecation.md) for the full field list, display states, enforcement rules, and examples. ## projects @@ -83,6 +69,5 @@ Internal field injected by `ork-doctor` at generation time. Holds developer-side ## See also - [01-top-level.md](../01-top-level.md) — full Katalog wire format -- [deprecation.md](./deprecation.md) — deprecation timeline, accept gates, OCI annotations - [ork inspect](../../../cli/11-inspect.md) — displays metadata fields - [ork push](../../../cli/09-push.md) — uses `name` and `version` as the artifact tag diff --git a/examples/registry-guide/09-deprecation/README.md b/examples/registry-guide/09-deprecation/README.md deleted file mode 100644 index 246642cf4..000000000 --- a/examples/registry-guide/09-deprecation/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# 09 — Deprecation - -Mark a pattern as deprecated, surface warnings to consumers, and publish a -replacement that consumers can migrate to. - -## Files - -| File | Purpose | -|------|---------| -| `katalog.yaml` | v1.0.0 with `deprecation:` block set | -| `crd-v1.yaml` | WebApp v1 CRD (copied from 02-katalog-api) | - ---- - -> **Before you start:** If `ORK_REGISTRY` is not set, export it now (see [01-motifs](../01-motifs/README.md#push-to-the-registry)). Replace `myorg` with your actual registry path in [katalog.yaml](katalog.yaml) and throughout this example. - ---- - -## The deprecation block - -Add `deprecation:` under `metadata:` to mark a pattern end-of-life: - -```yaml -metadata: - deprecation: - migratedTo: ghcr.io/myorg/katalogs/webapp-operator:v2.0.0 - message: "v1.0.0 is end-of-life. Migrate to v2.0.0: add spec.healthPath." -``` - -This is written as OCI annotations when the pattern is pushed. Consumers see it in -three places: - -- `ork inspect` — yellow warning block before the name/version line -- `ork patterns` — deprecated patterns prefixed with ⚠ -- `ork validate` — warning on stderr when a Komposer pulls a deprecated pattern - -## Publish the deprecation - -```bash -export ORK_REGISTRY=ghcr.io/myorg/katalogs -ork push . -ork patterns # deprecated patterns are prefixed with ⚠ -``` - -Re-pushing the same version tag with the deprecation annotation is enough — -consumers pulling `@v1.0.0` will see the warning immediately without any -changes to their Komposer. - -## What consumers see - -```bash -ork validate -# webapp-operator This pattern ⚠ is deprecated. -# Migrate to: ghcr.io/myorg/katalogs/webapp@v2.0.0 -# Message: v1.0.0 is end-of-life. Migrate to v2.0.0: add spec.healthPath to your CRs. -``` - -## Migration path - -1. Consumers update their Komposer to reference `@v2.0.0` -2. Add `spec.healthPath` to any CRs that need a non-default probe path -3. Run `ork validate` — the deprecation warning disappears - -## Next step - -→ [10-hooks-katalog/README.md](../10-hooks-katalog/README.md) — typed Go operator: hooks, generate registry, build, publish diff --git a/examples/registry-guide/10-hooks-katalog/Dockerfile b/examples/registry-guide/09-hooks-katalog/Dockerfile similarity index 100% rename from examples/registry-guide/10-hooks-katalog/Dockerfile rename to examples/registry-guide/09-hooks-katalog/Dockerfile diff --git a/examples/registry-guide/10-hooks-katalog/Makefile b/examples/registry-guide/09-hooks-katalog/Makefile similarity index 100% rename from examples/registry-guide/10-hooks-katalog/Makefile rename to examples/registry-guide/09-hooks-katalog/Makefile diff --git a/examples/registry-guide/10-hooks-katalog/README.md b/examples/registry-guide/09-hooks-katalog/README.md similarity index 100% rename from examples/registry-guide/10-hooks-katalog/README.md rename to examples/registry-guide/09-hooks-katalog/README.md diff --git a/examples/registry-guide/10-hooks-katalog/api/v1alpha1/database_types.go b/examples/registry-guide/09-hooks-katalog/api/v1alpha1/database_types.go similarity index 100% rename from examples/registry-guide/10-hooks-katalog/api/v1alpha1/database_types.go rename to examples/registry-guide/09-hooks-katalog/api/v1alpha1/database_types.go diff --git a/examples/registry-guide/10-hooks-katalog/cleanup.sh b/examples/registry-guide/09-hooks-katalog/cleanup.sh similarity index 100% rename from examples/registry-guide/10-hooks-katalog/cleanup.sh rename to examples/registry-guide/09-hooks-katalog/cleanup.sh diff --git a/examples/registry-guide/10-hooks-katalog/cr.yaml b/examples/registry-guide/09-hooks-katalog/cr.yaml similarity index 100% rename from examples/registry-guide/10-hooks-katalog/cr.yaml rename to examples/registry-guide/09-hooks-katalog/cr.yaml diff --git a/examples/registry-guide/10-hooks-katalog/crd.yaml b/examples/registry-guide/09-hooks-katalog/crd.yaml similarity index 100% rename from examples/registry-guide/10-hooks-katalog/crd.yaml rename to examples/registry-guide/09-hooks-katalog/crd.yaml diff --git a/examples/registry-guide/10-hooks-katalog/e2e.yaml b/examples/registry-guide/09-hooks-katalog/e2e.yaml similarity index 100% rename from examples/registry-guide/10-hooks-katalog/e2e.yaml rename to examples/registry-guide/09-hooks-katalog/e2e.yaml diff --git a/examples/registry-guide/10-hooks-katalog/go.mod.txt b/examples/registry-guide/09-hooks-katalog/go.mod.txt similarity index 100% rename from examples/registry-guide/10-hooks-katalog/go.mod.txt rename to examples/registry-guide/09-hooks-katalog/go.mod.txt diff --git a/examples/registry-guide/10-hooks-katalog/go.sum.txt b/examples/registry-guide/09-hooks-katalog/go.sum.txt similarity index 100% rename from examples/registry-guide/10-hooks-katalog/go.sum.txt rename to examples/registry-guide/09-hooks-katalog/go.sum.txt diff --git a/examples/registry-guide/10-hooks-katalog/hooks/database_hooks.go b/examples/registry-guide/09-hooks-katalog/hooks/database_hooks.go similarity index 100% rename from examples/registry-guide/10-hooks-katalog/hooks/database_hooks.go rename to examples/registry-guide/09-hooks-katalog/hooks/database_hooks.go diff --git a/examples/registry-guide/10-hooks-katalog/katalog.yaml b/examples/registry-guide/09-hooks-katalog/katalog.yaml similarity index 100% rename from examples/registry-guide/10-hooks-katalog/katalog.yaml rename to examples/registry-guide/09-hooks-katalog/katalog.yaml diff --git a/examples/registry-guide/10-hooks-katalog/simulate.yaml b/examples/registry-guide/09-hooks-katalog/simulate.yaml similarity index 100% rename from examples/registry-guide/10-hooks-katalog/simulate.yaml rename to examples/registry-guide/09-hooks-katalog/simulate.yaml diff --git a/examples/registry-guide/10-hooks-katalog/values.yaml b/examples/registry-guide/09-hooks-katalog/values.yaml similarity index 100% rename from examples/registry-guide/10-hooks-katalog/values.yaml rename to examples/registry-guide/09-hooks-katalog/values.yaml diff --git a/examples/registry-guide/11-typed-komposer/README.md b/examples/registry-guide/10-typed-komposer/README.md similarity index 100% rename from examples/registry-guide/11-typed-komposer/README.md rename to examples/registry-guide/10-typed-komposer/README.md diff --git a/examples/registry-guide/11-typed-komposer/cleanup.sh b/examples/registry-guide/10-typed-komposer/cleanup.sh similarity index 100% rename from examples/registry-guide/11-typed-komposer/cleanup.sh rename to examples/registry-guide/10-typed-komposer/cleanup.sh diff --git a/examples/registry-guide/11-typed-komposer/cr-cache.yaml b/examples/registry-guide/10-typed-komposer/cr-cache.yaml similarity index 100% rename from examples/registry-guide/11-typed-komposer/cr-cache.yaml rename to examples/registry-guide/10-typed-komposer/cr-cache.yaml diff --git a/examples/registry-guide/11-typed-komposer/cr-database.yaml b/examples/registry-guide/10-typed-komposer/cr-database.yaml similarity index 100% rename from examples/registry-guide/11-typed-komposer/cr-database.yaml rename to examples/registry-guide/10-typed-komposer/cr-database.yaml diff --git a/examples/registry-guide/11-typed-komposer/cr-webapp.yaml b/examples/registry-guide/10-typed-komposer/cr-webapp.yaml similarity index 100% rename from examples/registry-guide/11-typed-komposer/cr-webapp.yaml rename to examples/registry-guide/10-typed-komposer/cr-webapp.yaml diff --git a/examples/registry-guide/11-typed-komposer/komposer.yaml b/examples/registry-guide/10-typed-komposer/komposer.yaml similarity index 100% rename from examples/registry-guide/11-typed-komposer/komposer.yaml rename to examples/registry-guide/10-typed-komposer/komposer.yaml diff --git a/examples/registry-guide/11-typed-komposer/simulate.yaml b/examples/registry-guide/10-typed-komposer/simulate.yaml similarity index 100% rename from examples/registry-guide/11-typed-komposer/simulate.yaml rename to examples/registry-guide/10-typed-komposer/simulate.yaml diff --git a/examples/registry-guide/12-ork-action/.github/workflows/publish-typed.yml b/examples/registry-guide/11-ork-action/.github/workflows/publish-typed.yml similarity index 100% rename from examples/registry-guide/12-ork-action/.github/workflows/publish-typed.yml rename to examples/registry-guide/11-ork-action/.github/workflows/publish-typed.yml diff --git a/examples/registry-guide/12-ork-action/.github/workflows/publish.yml b/examples/registry-guide/11-ork-action/.github/workflows/publish.yml similarity index 100% rename from examples/registry-guide/12-ork-action/.github/workflows/publish.yml rename to examples/registry-guide/11-ork-action/.github/workflows/publish.yml diff --git a/examples/registry-guide/12-ork-action/README.md b/examples/registry-guide/11-ork-action/README.md similarity index 100% rename from examples/registry-guide/12-ork-action/README.md rename to examples/registry-guide/11-ork-action/README.md diff --git a/examples/registry-guide/12-ork-action/database-operator/Dockerfile b/examples/registry-guide/11-ork-action/database-operator/Dockerfile similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/Dockerfile rename to examples/registry-guide/11-ork-action/database-operator/Dockerfile diff --git a/examples/registry-guide/12-ork-action/database-operator/Makefile b/examples/registry-guide/11-ork-action/database-operator/Makefile similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/Makefile rename to examples/registry-guide/11-ork-action/database-operator/Makefile diff --git a/examples/registry-guide/12-ork-action/database-operator/README.md b/examples/registry-guide/11-ork-action/database-operator/README.md similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/README.md rename to examples/registry-guide/11-ork-action/database-operator/README.md diff --git a/examples/registry-guide/12-ork-action/database-operator/api/v1alpha1/database_types.go b/examples/registry-guide/11-ork-action/database-operator/api/v1alpha1/database_types.go similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/api/v1alpha1/database_types.go rename to examples/registry-guide/11-ork-action/database-operator/api/v1alpha1/database_types.go diff --git a/examples/registry-guide/12-ork-action/database-operator/cleanup.sh b/examples/registry-guide/11-ork-action/database-operator/cleanup.sh similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/cleanup.sh rename to examples/registry-guide/11-ork-action/database-operator/cleanup.sh diff --git a/examples/registry-guide/12-ork-action/database-operator/cr.yaml b/examples/registry-guide/11-ork-action/database-operator/cr.yaml similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/cr.yaml rename to examples/registry-guide/11-ork-action/database-operator/cr.yaml diff --git a/examples/registry-guide/12-ork-action/database-operator/crd.yaml b/examples/registry-guide/11-ork-action/database-operator/crd.yaml similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/crd.yaml rename to examples/registry-guide/11-ork-action/database-operator/crd.yaml diff --git a/examples/registry-guide/12-ork-action/database-operator/e2e.yaml b/examples/registry-guide/11-ork-action/database-operator/e2e.yaml similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/e2e.yaml rename to examples/registry-guide/11-ork-action/database-operator/e2e.yaml diff --git a/examples/registry-guide/12-ork-action/database-operator/go.mod.txt b/examples/registry-guide/11-ork-action/database-operator/go.mod.txt similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/go.mod.txt rename to examples/registry-guide/11-ork-action/database-operator/go.mod.txt diff --git a/examples/registry-guide/12-ork-action/database-operator/go.sum.txt b/examples/registry-guide/11-ork-action/database-operator/go.sum.txt similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/go.sum.txt rename to examples/registry-guide/11-ork-action/database-operator/go.sum.txt diff --git a/examples/registry-guide/12-ork-action/database-operator/hooks/database_hooks.go b/examples/registry-guide/11-ork-action/database-operator/hooks/database_hooks.go similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/hooks/database_hooks.go rename to examples/registry-guide/11-ork-action/database-operator/hooks/database_hooks.go diff --git a/examples/registry-guide/12-ork-action/database-operator/katalog.yaml b/examples/registry-guide/11-ork-action/database-operator/katalog.yaml similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/katalog.yaml rename to examples/registry-guide/11-ork-action/database-operator/katalog.yaml diff --git a/examples/registry-guide/12-ork-action/database-operator/simulate.yaml b/examples/registry-guide/11-ork-action/database-operator/simulate.yaml similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/simulate.yaml rename to examples/registry-guide/11-ork-action/database-operator/simulate.yaml diff --git a/examples/registry-guide/12-ork-action/database-operator/values.yaml b/examples/registry-guide/11-ork-action/database-operator/values.yaml similarity index 100% rename from examples/registry-guide/12-ork-action/database-operator/values.yaml rename to examples/registry-guide/11-ork-action/database-operator/values.yaml diff --git a/examples/registry-guide/12-ork-action/webapp-operator/README.md b/examples/registry-guide/11-ork-action/webapp-operator/README.md similarity index 100% rename from examples/registry-guide/12-ork-action/webapp-operator/README.md rename to examples/registry-guide/11-ork-action/webapp-operator/README.md diff --git a/examples/registry-guide/12-ork-action/webapp-operator/cleanup.sh b/examples/registry-guide/11-ork-action/webapp-operator/cleanup.sh similarity index 100% rename from examples/registry-guide/12-ork-action/webapp-operator/cleanup.sh rename to examples/registry-guide/11-ork-action/webapp-operator/cleanup.sh diff --git a/examples/registry-guide/12-ork-action/webapp-operator/cr-with-ingress.yaml b/examples/registry-guide/11-ork-action/webapp-operator/cr-with-ingress.yaml similarity index 100% rename from examples/registry-guide/12-ork-action/webapp-operator/cr-with-ingress.yaml rename to examples/registry-guide/11-ork-action/webapp-operator/cr-with-ingress.yaml diff --git a/examples/registry-guide/12-ork-action/webapp-operator/cr.yaml b/examples/registry-guide/11-ork-action/webapp-operator/cr.yaml similarity index 100% rename from examples/registry-guide/12-ork-action/webapp-operator/cr.yaml rename to examples/registry-guide/11-ork-action/webapp-operator/cr.yaml diff --git a/examples/registry-guide/12-ork-action/webapp-operator/crd.yaml b/examples/registry-guide/11-ork-action/webapp-operator/crd.yaml similarity index 100% rename from examples/registry-guide/12-ork-action/webapp-operator/crd.yaml rename to examples/registry-guide/11-ork-action/webapp-operator/crd.yaml diff --git a/examples/registry-guide/12-ork-action/webapp-operator/e2e.yaml b/examples/registry-guide/11-ork-action/webapp-operator/e2e.yaml similarity index 100% rename from examples/registry-guide/12-ork-action/webapp-operator/e2e.yaml rename to examples/registry-guide/11-ork-action/webapp-operator/e2e.yaml diff --git a/examples/registry-guide/12-ork-action/webapp-operator/katalog.yaml b/examples/registry-guide/11-ork-action/webapp-operator/katalog.yaml similarity index 100% rename from examples/registry-guide/12-ork-action/webapp-operator/katalog.yaml rename to examples/registry-guide/11-ork-action/webapp-operator/katalog.yaml diff --git a/examples/registry-guide/12-ork-action/webapp-operator/simulate-ingress.yaml b/examples/registry-guide/11-ork-action/webapp-operator/simulate-ingress.yaml similarity index 100% rename from examples/registry-guide/12-ork-action/webapp-operator/simulate-ingress.yaml rename to examples/registry-guide/11-ork-action/webapp-operator/simulate-ingress.yaml diff --git a/examples/registry-guide/12-ork-action/webapp-operator/simulate.yaml b/examples/registry-guide/11-ork-action/webapp-operator/simulate.yaml similarity index 100% rename from examples/registry-guide/12-ork-action/webapp-operator/simulate.yaml rename to examples/registry-guide/11-ork-action/webapp-operator/simulate.yaml diff --git a/examples/registry-guide/12-deprecation/README.md b/examples/registry-guide/12-deprecation/README.md new file mode 100644 index 000000000..14989232f --- /dev/null +++ b/examples/registry-guide/12-deprecation/README.md @@ -0,0 +1,58 @@ +# 09 — Deprecation + +Mark a pattern as deprecated, signal a migration path, and set a timeline. This is the producer side — the next step ([13-deprecation-accept](../13-deprecation-accept/README.md)) covers the consumer side. + +## Files + +| File | Purpose | +|------|---------| +| `katalog.yaml` | v1.0.0 with `lifecycle.deprecation:` block and timeline | +| `crd-v1.yaml` | WebApp v1 CRD | + +--- + +> **Before you start:** If `ORK_REGISTRY` is not set, export it now (see [01-motifs](../01-motifs/README.md#push-to-the-registry)). Replace `myorg` with your actual registry path. + +--- + +## The lifecycle block + +`lifecycle:` is a top-level field alongside `metadata:` and `spec:`. It carries policy for tooling — the runtime ignores it. + +```yaml +lifecycle: + maturity: deprecated + deprecation: + migratedTo: ghcr.io/myorg/katalogs/webapp@v2.0.0 + message: "v1.0.0 is end-of-life. Migrate to v2.0.0: add spec.healthPath to your CRs." + timeline: + from: "2025-01-01" # warn from this date + to: "2026-06-01" # EOL on this date +``` + +`ork validate` enforces that `maturity: deprecated` requires a `deprecation:` block. Without one, validation fails. + +## Publish the deprecation + +```bash +export ORK_REGISTRY=ghcr.io/myorg/katalogs +ork validate +ork push . +ork patterns # deprecated patterns are prefixed with ⚠ +``` + +## What consumers see + +```bash +ork inspect webapp-operator:v1.0.0 +# ⚠ This pattern is deprecated. +# Migrate to: ghcr.io/myorg/katalogs/webapp@v2.0.0 +# Message: v1.0.0 is end-of-life. Migrate to v2.0.0: add spec.healthPath. +# EOL: 2026-06-01 +``` + +`ork validate` on a Komposer that imports this pattern surfaces the same warning — before any CR is applied. + +## Next step + +→ [13-deprecation-accept](../13-deprecation-accept/README.md) — accept running a deprecated pattern as a consumer diff --git a/examples/registry-guide/09-deprecation/crd-v1.yaml b/examples/registry-guide/12-deprecation/crd-v1.yaml similarity index 100% rename from examples/registry-guide/09-deprecation/crd-v1.yaml rename to examples/registry-guide/12-deprecation/crd-v1.yaml diff --git a/examples/registry-guide/09-deprecation/katalog.yaml b/examples/registry-guide/12-deprecation/katalog.yaml similarity index 89% rename from examples/registry-guide/09-deprecation/katalog.yaml rename to examples/registry-guide/12-deprecation/katalog.yaml index 52e461b39..4d3454e57 100644 --- a/examples/registry-guide/09-deprecation/katalog.yaml +++ b/examples/registry-guide/12-deprecation/katalog.yaml @@ -13,11 +13,14 @@ metadata: - stateless - deprecated - # Deprecation block — surfaced by ork inspect, ork patterns, - # and ork validate when this pattern is pulled as a registry source. +lifecycle: + maturity: deprecated deprecation: migratedTo: ghcr.io/myorg/katalogs/webapp@v2.0.0 message: "v1.0.0 is end-of-life. Migrate to v2.0.0: add spec.healthPath to your CRs." + timeline: + from: "2025-01-01" + to: "2026-06-01" spec: crds: @@ -42,4 +45,4 @@ spec: - path: phase value: "Running" - path: ready - value: "{{ allReplicasReady .children.deployment }}" \ No newline at end of file + value: "{{ allReplicasReady .children.deployment }}" diff --git a/examples/registry-guide/13-deprecation-accept/README.md b/examples/registry-guide/13-deprecation-accept/README.md new file mode 100644 index 000000000..b79568f28 --- /dev/null +++ b/examples/registry-guide/13-deprecation-accept/README.md @@ -0,0 +1,82 @@ +# 13 — Accepting Deprecation + +Step 12 covered the producer: marking a pattern deprecated and setting a timeline. This step covers the consumer: what happens when you import a deprecated pattern, and how to explicitly acknowledge it. + +## Files + +| File | Purpose | +|------|---------| +| `katalog.yaml` | Deprecated Katalog — the upstream pattern as published | +| `komposer.yaml` | Komposer that imports it; acceptance lives here | +| `crd-v1.yaml` | WebApp v1 CRD | + +--- + +## The problem + +`katalog.yaml` is deprecated. When your Komposer imports it, `ork validate` surfaces: + +```text +✗ END OF LIFE + This pattern reached end of life on 2026-06-01. + v1.0.0 is end-of-life. Migrate to v2.0.0: add spec.healthPath to your CRs. + Migrate to: ghcr.io/myorg/katalogs/webapp@v2.0.0 + To acknowledge this import, add it to lifecycle.accept.patterns in your Komposer. +``` + +The pattern author declared the deprecation — they cannot accept it on your behalf. Acceptance is a consumer decision. + +--- + +## The fix — accept at the Komposer level + +Add the pattern name to `lifecycle.accept.patterns` on your [Komposer](./komposer.yaml): + +```yaml +lifecycle: + accept: + patterns: + - name: webapp-operator +``` + +That is the signal: you have read the deprecation, evaluated the migration path, and are choosing to continue using this version intentionally. `ork validate` passes and `ork run` starts. + +```bash +ork validate -f komposer.yaml +``` + +--- + +## Scoping acceptance to a version range + +If you are accepting a specific version window — not any future deprecated version of the same pattern — add a `version:` range: + +```yaml +lifecycle: + accept: + patterns: + - name: webapp-operator + version: ">= 1.0.0, < 2.0.0" +``` + +Acceptance only applies when the imported version falls within that range. A newer deprecated version outside the range blocks again, forcing a conscious re-evaluation. + +--- + +## Multiple deprecated imports + +List each pattern by name. `ork validate` surfaces all unacknowledged names in one message so you can add them in a single pass: + +```yaml +lifecycle: + accept: + patterns: + - name: webapp-operator + - name: old-data-store +``` + +--- + +## Next step + +→ [14-lifecycle-maturity](../14-lifecycle-maturity/README.md) — signal pattern stability with alpha, beta, and stable maturity levels diff --git a/examples/registry-guide/13-deprecation-accept/crd-v1.yaml b/examples/registry-guide/13-deprecation-accept/crd-v1.yaml new file mode 100644 index 000000000..6fb66cbfc --- /dev/null +++ b/examples/registry-guide/13-deprecation-accept/crd-v1.yaml @@ -0,0 +1,48 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: webapps.rkguide.demo +spec: + group: rkguide.demo + versions: + - name: v1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [image] + properties: + image: + type: string + description: Container image to run (e.g. ghcr.io/orkspace/orkestra-dev-server:0.7.5) + port: + type: integer + default: 8080 + description: Container port the application listens on + replicas: + type: integer + default: 1 + description: Number of replicas + host: + type: string + default: "" + description: Ingress hostname — when set, an Ingress is created + resourceProfile: + type: string + default: small + enum: [small, standard, burst] + description: CPU/memory resource profile + status: + type: object + x-kubernetes-preserve-unknown-fields: true + names: + kind: WebApp + plural: webapps + singular: webapp + scope: Namespaced \ No newline at end of file diff --git a/examples/registry-guide/13-deprecation-accept/katalog.yaml b/examples/registry-guide/13-deprecation-accept/katalog.yaml new file mode 100644 index 000000000..1a2983aee --- /dev/null +++ b/examples/registry-guide/13-deprecation-accept/katalog.yaml @@ -0,0 +1,40 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: webapp-operator + version: v1.0.0 + description: > + WebApp operator v1.0.0 — deprecated. Consumers must acknowledge this + in their Komposer before Orkestra will start. + author: myorg + license: Apache-2.0 + +lifecycle: + maturity: deprecated + deprecation: + migratedTo: ghcr.io/myorg/katalogs/webapp@v2.0.0 + message: "v1.0.0 is end-of-life. Migrate to v2.0.0: add spec.healthPath to your CRs." + timeline: + from: "2025-01-01" + to: "2026-06-01" + +spec: + crds: + webapp: + crdFile: ./crd-v1.yaml + + imports: + - motif: ../01-motifs/web-service/motif.yaml + with: + image: "{{ .spec.image }}" + port: "{{ .spec.port }}" + replicas: "{{ .spec.replicas }}" + + operatorBox: + reconciler: + workers: 2 + resync: 1m + status: + fields: + - path: phase + value: "Running" diff --git a/examples/registry-guide/13-deprecation-accept/komposer.yaml b/examples/registry-guide/13-deprecation-accept/komposer.yaml new file mode 100644 index 000000000..a4ceb9781 --- /dev/null +++ b/examples/registry-guide/13-deprecation-accept/komposer.yaml @@ -0,0 +1,19 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Komposer +metadata: + name: platform-komposer + version: v1.0.0 + description: > + Platform Komposer that imports a deprecated Katalog from a local file. + lifecycle.accept.patterns acknowledges the import so ork validate does not warn. + +# lifecycle: +# maturity: stable +# accept: +# patterns: +# - name: webapp-operator # accepts all lifecycle concerns for this import +# author: myorg # optional — disambiguates when name is not unique + +imports: + files: + - ./katalog.yaml diff --git a/examples/registry-guide/14-lifecycle-maturity/README.md b/examples/registry-guide/14-lifecycle-maturity/README.md new file mode 100644 index 000000000..1433d86b9 --- /dev/null +++ b/examples/registry-guide/14-lifecycle-maturity/README.md @@ -0,0 +1,77 @@ +# 14 — Lifecycle Maturity + +Signal how stable a pattern is using `lifecycle.maturity`. This tells consumers — and the registry — whether a pattern is experimental, stabilising, or production-ready. Maturity alone is advisory; the `deprecation:` block within `lifecycle:` is enforced at both validate time and runtime startup — a deprecated pattern without acceptance blocks `ork run`. + +## Files + +| File | Purpose | +|------|---------| +| `katalog-alpha.yaml` | v0.1.0 — `maturity: alpha`, experimental | +| `katalog-beta.yaml` | v0.5.0 — `maturity: beta`, stabilising | +| `katalog-stable.yaml` | v1.0.0 — `maturity: stable`, production-ready | +| `crd.yaml` | WebApp CRD | + +--- + +## The four maturity levels + +```yaml +lifecycle: + maturity: alpha # experimental — breaking changes expected +``` + +```yaml +lifecycle: + maturity: beta # stabilising — API mostly settled +``` + +```yaml +lifecycle: + maturity: stable # production-ready — semantic versioning applies +``` + +```yaml +lifecycle: + maturity: deprecated # replaced — requires lifecycle.deprecation (see 09-deprecation) +``` + +When `maturity:` is omitted, `ork validate` treats the pattern as `stable` if `metadata.version >= 1.0.0`, otherwise `beta`. + +--- + +## Validate all three + +```bash +# alpha — passes, prints a non-fatal warning +ork validate -f katalog-alpha.yaml + +# beta — passes, prints a lower-severity warning +ork validate -f katalog-beta.yaml + +# stable — passes silently +ork validate -f katalog-stable.yaml +``` + +Alpha and beta validations pass — warnings are informational. The registry surfaces them as badges on `ork patterns` and `ork inspect` so consumers can see stability at a glance without running validate themselves. + +--- + +## Graduating a pattern + +Push each version with its maturity signal as the pattern stabilises: + +```bash +# publish alpha +ork push ./katalog-alpha.yaml + +# iterate — when stable, publish with updated maturity +ork push ./katalog-stable.yaml +``` + +Consumers who pinned `@v0.1.0` continue to see the alpha signal. Consumers who upgrade to `@v1.0.0` see stable. The registry does not retroactively change annotations on published versions. + +--- + +## Next step + +→ [15-lifecycle-compatibility](../15-lifecycle-compatibility/README.md) — declare which Kubernetes and Orkestra versions a pattern has been verified against diff --git a/examples/registry-guide/14-lifecycle-maturity/crd.yaml b/examples/registry-guide/14-lifecycle-maturity/crd.yaml new file mode 100644 index 000000000..6fb66cbfc --- /dev/null +++ b/examples/registry-guide/14-lifecycle-maturity/crd.yaml @@ -0,0 +1,48 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: webapps.rkguide.demo +spec: + group: rkguide.demo + versions: + - name: v1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [image] + properties: + image: + type: string + description: Container image to run (e.g. ghcr.io/orkspace/orkestra-dev-server:0.7.5) + port: + type: integer + default: 8080 + description: Container port the application listens on + replicas: + type: integer + default: 1 + description: Number of replicas + host: + type: string + default: "" + description: Ingress hostname — when set, an Ingress is created + resourceProfile: + type: string + default: small + enum: [small, standard, burst] + description: CPU/memory resource profile + status: + type: object + x-kubernetes-preserve-unknown-fields: true + names: + kind: WebApp + plural: webapps + singular: webapp + scope: Namespaced \ No newline at end of file diff --git a/examples/registry-guide/14-lifecycle-maturity/katalog-alpha.yaml b/examples/registry-guide/14-lifecycle-maturity/katalog-alpha.yaml new file mode 100644 index 000000000..55cfe693d --- /dev/null +++ b/examples/registry-guide/14-lifecycle-maturity/katalog-alpha.yaml @@ -0,0 +1,32 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: webapp-operator + version: v0.1.0 + description: WebApp operator — early alpha, API may change between releases. + author: myorg + license: Apache-2.0 + +lifecycle: + maturity: alpha + +spec: + crds: + webapp: + crdFile: ./crd.yaml + + imports: + - motif: ../01-motifs/web-service/motif.yaml + with: + image: "{{ .spec.image }}" + port: "{{ .spec.port }}" + replicas: "{{ .spec.replicas }}" + + operatorBox: + reconciler: + workers: 1 + resync: 2m + status: + fields: + - path: phase + value: "Running" diff --git a/examples/registry-guide/14-lifecycle-maturity/katalog-beta.yaml b/examples/registry-guide/14-lifecycle-maturity/katalog-beta.yaml new file mode 100644 index 000000000..1d6bff3c2 --- /dev/null +++ b/examples/registry-guide/14-lifecycle-maturity/katalog-beta.yaml @@ -0,0 +1,32 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: webapp-operator + version: v0.5.0 + description: WebApp operator — beta, API is mostly settled but minor changes may still occur. + author: myorg + license: Apache-2.0 + +lifecycle: + maturity: beta + +spec: + crds: + webapp: + crdFile: ./crd.yaml + + imports: + - motif: ../01-motifs/web-service/motif.yaml + with: + image: "{{ .spec.image }}" + port: "{{ .spec.port }}" + replicas: "{{ .spec.replicas }}" + + operatorBox: + reconciler: + workers: 2 + resync: 1m + status: + fields: + - path: phase + value: "Running" diff --git a/examples/registry-guide/14-lifecycle-maturity/katalog-stable.yaml b/examples/registry-guide/14-lifecycle-maturity/katalog-stable.yaml new file mode 100644 index 000000000..4c8acbbb2 --- /dev/null +++ b/examples/registry-guide/14-lifecycle-maturity/katalog-stable.yaml @@ -0,0 +1,32 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: webapp-operator + version: v1.0.0 + description: WebApp operator — stable, semantic versioning applies and breaking changes are gated behind major versions. + author: myorg + license: Apache-2.0 + +lifecycle: + maturity: stable + +spec: + crds: + webapp: + crdFile: ./crd.yaml + + imports: + - motif: ../01-motifs/web-service/motif.yaml + with: + image: "{{ .spec.image }}" + port: "{{ .spec.port }}" + replicas: "{{ .spec.replicas }}" + + operatorBox: + reconciler: + workers: 2 + resync: 1m + status: + fields: + - path: phase + value: "Running" diff --git a/examples/registry-guide/15-lifecycle-compatibility/README.md b/examples/registry-guide/15-lifecycle-compatibility/README.md new file mode 100644 index 000000000..d0c1f22fa --- /dev/null +++ b/examples/registry-guide/15-lifecycle-compatibility/README.md @@ -0,0 +1,73 @@ +# 15 — Lifecycle Compatibility + +Declare which Kubernetes and Orkestra versions a pattern has been verified against. These constraints are advisory at `ork validate` time (syntax is checked) and enforced at apply time against the running cluster version and the baked-in Orkestra version. + +## Files + +| File | Purpose | +|------|---------| +| `katalog.yaml` | Stable Katalog with `lifecycle.compatibility` declaring version ranges | +| `crd.yaml` | WebApp CRD | + +--- + +## The compatibility block + +```yaml +lifecycle: + maturity: stable + compatibility: + kubernetes: ">=1.28" # verified against Kubernetes 1.28 and above + orkestra: ">=0.7.0" # verified against Orkestra 0.7.0 and above +``` + +Both fields accept [Masterminds/semver](https://github.com/Masterminds/semver) range syntax — the same library Helm uses: + +``` +>=1.28 at least 1.28 +>=1.28, <1.33 between 1.28 and 1.33 exclusive +^1.28 >=1.28, <2.0 +~1.28 >=1.28, <1.29 +``` + +--- + +## Validate — syntax check + +`ork validate` checks that the range string is valid semver syntax. It does not connect to a cluster or check the live version. + +```bash +ork validate +# ✓ webapp valid +``` + +An invalid range fails immediately: + +```yaml +compatibility: + kubernetes: "!!!invalid" +# ✗ lifecycle.compatibility.kubernetes: "!!!invalid" is not a valid semver range +``` + +--- + +## Apply-time enforcement + +When `ork run` or `ork serve apply` loads the Katalog, it compares: + +- `compatibility.kubernetes` against the live cluster server version +- `compatibility.orkestra` against the version baked into the `ork` binary at build time + +A mismatch produces a clear error before any reconcile loop starts: + +``` +✗ compatibility.kubernetes: cluster is 1.27.3 — pattern requires >=1.28 +``` + +No flag is needed for the Orkestra version check — the build version is always available locally. + +--- + +## Next step + +→ [16-komposer-accept](../16-komposer-accept/README.md) — a Komposer that acknowledges the lifecycle state of all its imported patterns diff --git a/examples/registry-guide/15-lifecycle-compatibility/crd.yaml b/examples/registry-guide/15-lifecycle-compatibility/crd.yaml new file mode 100644 index 000000000..6fb66cbfc --- /dev/null +++ b/examples/registry-guide/15-lifecycle-compatibility/crd.yaml @@ -0,0 +1,48 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: webapps.rkguide.demo +spec: + group: rkguide.demo + versions: + - name: v1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [image] + properties: + image: + type: string + description: Container image to run (e.g. ghcr.io/orkspace/orkestra-dev-server:0.7.5) + port: + type: integer + default: 8080 + description: Container port the application listens on + replicas: + type: integer + default: 1 + description: Number of replicas + host: + type: string + default: "" + description: Ingress hostname — when set, an Ingress is created + resourceProfile: + type: string + default: small + enum: [small, standard, burst] + description: CPU/memory resource profile + status: + type: object + x-kubernetes-preserve-unknown-fields: true + names: + kind: WebApp + plural: webapps + singular: webapp + scope: Namespaced \ No newline at end of file diff --git a/examples/registry-guide/15-lifecycle-compatibility/katalog.yaml b/examples/registry-guide/15-lifecycle-compatibility/katalog.yaml new file mode 100644 index 000000000..6b11a58a3 --- /dev/null +++ b/examples/registry-guide/15-lifecycle-compatibility/katalog.yaml @@ -0,0 +1,37 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: webapp-operator + version: v1.2.0 + description: > + WebApp operator v1.2.0 — stable, with compatibility constraints declaring the + verified Kubernetes and Orkestra version ranges. + author: myorg + license: Apache-2.0 + +lifecycle: + maturity: stable + compatibility: + kubernetes: ">=1.28" # verified against Kubernetes 1.28 and above + orkestra: ">=0.7.0" # verified against Orkestra 0.7.0 and above + +spec: + crds: + webapp: + crdFile: ./crd.yaml + + imports: + - motif: ../01-motifs/web-service/motif.yaml + with: + image: "{{ .spec.image }}" + port: "{{ .spec.port }}" + replicas: "{{ .spec.replicas }}" + + operatorBox: + reconciler: + workers: 2 + resync: 1m + status: + fields: + - path: phase + value: "Running" diff --git a/examples/registry-guide/16-komposer-accept/README.md b/examples/registry-guide/16-komposer-accept/README.md new file mode 100644 index 000000000..5f259b928 --- /dev/null +++ b/examples/registry-guide/16-komposer-accept/README.md @@ -0,0 +1,49 @@ +# 16 — Komposer Accept: Mixed Lifecycle Concerns + +Step 13 covered accepting a deprecated pattern. This step covers the case where your imports have different lifecycle concerns — one is deprecated, another is alpha. The mechanism is the same; the error messages differ. + +## Files + +| File | Purpose | +|------|---------| +| `katalog-webapp.yaml` | Deprecated Katalog — being replaced by v2.0.0 | +| `katalog-cache.yaml` | Alpha Katalog — API is settling | +| `komposer.yaml` | Komposer that imports both | +| `crd.yaml` | WebApp CRD | +| `crd-cache.yaml` | Cache CRD | + +--- + +## What you see without acceptance + +```text +✗ END OF LIFE + This pattern reached end of life on 2026-06-01. + v1.0.0 is end-of-life. Migrate to v2.0.0: add spec.healthPath to your CRs. + Migrate to: ghcr.io/myorg/katalogs/webapp@v2.0.0 + To acknowledge this import, add it to lifecycle.accept.patterns in your Komposer. + +⚠ MATURITY WARNING + cache-operator is alpha — experimental, breaking changes expected. + To acknowledge this import, add it to lifecycle.accept.patterns in your Komposer. +``` + +Two different concerns, one place to acknowledge them: + +```yaml +lifecycle: + accept: + patterns: + - name: webapp-operator + - name: cache-operator +``` + +```bash +ork validate -f komposer.yaml +``` + +--- + +## Next step + +→ Back to [Registry Guide index](../README.md) diff --git a/examples/registry-guide/16-komposer-accept/crd-cache.yaml b/examples/registry-guide/16-komposer-accept/crd-cache.yaml new file mode 100644 index 000000000..6a0509ff0 --- /dev/null +++ b/examples/registry-guide/16-komposer-accept/crd-cache.yaml @@ -0,0 +1,38 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: caches.rkguide.demo +spec: + group: rkguide.demo + versions: + - name: v1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [image] + properties: + image: + type: string + port: + type: integer + default: 6379 + replicas: + type: integer + default: 1 + status: + type: object + properties: + phase: + type: string + scope: Namespaced + names: + plural: caches + singular: cache + kind: Cache diff --git a/examples/registry-guide/16-komposer-accept/crd.yaml b/examples/registry-guide/16-komposer-accept/crd.yaml new file mode 100644 index 000000000..6fb66cbfc --- /dev/null +++ b/examples/registry-guide/16-komposer-accept/crd.yaml @@ -0,0 +1,48 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: webapps.rkguide.demo +spec: + group: rkguide.demo + versions: + - name: v1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [image] + properties: + image: + type: string + description: Container image to run (e.g. ghcr.io/orkspace/orkestra-dev-server:0.7.5) + port: + type: integer + default: 8080 + description: Container port the application listens on + replicas: + type: integer + default: 1 + description: Number of replicas + host: + type: string + default: "" + description: Ingress hostname — when set, an Ingress is created + resourceProfile: + type: string + default: small + enum: [small, standard, burst] + description: CPU/memory resource profile + status: + type: object + x-kubernetes-preserve-unknown-fields: true + names: + kind: WebApp + plural: webapps + singular: webapp + scope: Namespaced \ No newline at end of file diff --git a/examples/registry-guide/16-komposer-accept/katalog-cache.yaml b/examples/registry-guide/16-komposer-accept/katalog-cache.yaml new file mode 100644 index 000000000..01ce0bb55 --- /dev/null +++ b/examples/registry-guide/16-komposer-accept/katalog-cache.yaml @@ -0,0 +1,33 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: cache-operator + version: v0.2.0 + description: > + Cache operator v0.2.0 — alpha, API surface is settling. + author: myorg + license: Apache-2.0 + +lifecycle: + maturity: alpha + +spec: + crds: + cache: + crdFile: ./crd-cache.yaml + + imports: + - motif: ../01-motifs/web-service/motif.yaml + with: + image: "{{ .spec.image }}" + port: "{{ .spec.port }}" + replicas: "{{ .spec.replicas }}" + + operatorBox: + reconciler: + workers: 2 + resync: 1m + status: + fields: + - path: phase + value: "Running" diff --git a/examples/registry-guide/16-komposer-accept/katalog-webapp.yaml b/examples/registry-guide/16-komposer-accept/katalog-webapp.yaml new file mode 100644 index 000000000..be54ef4d1 --- /dev/null +++ b/examples/registry-guide/16-komposer-accept/katalog-webapp.yaml @@ -0,0 +1,39 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: webapp-operator + version: v1.0.0 + description: > + WebApp operator v1.0.0 — deprecated in favour of v2.0.0. + author: myorg + license: Apache-2.0 + +lifecycle: + maturity: deprecated + deprecation: + migratedTo: ghcr.io/myorg/katalogs/webapp@v2.0.0 + message: "Migrate to v2.0.0 before 2027-01-01." + timeline: + from: "2026-01-01" + to: "2027-01-01" + +spec: + crds: + webapp: + crdFile: ./crd.yaml + + imports: + - motif: ../01-motifs/web-service/motif.yaml + with: + image: "{{ .spec.image }}" + port: "{{ .spec.port }}" + replicas: "{{ .spec.replicas }}" + + operatorBox: + reconciler: + workers: 2 + resync: 1m + status: + fields: + - path: phase + value: "Running" diff --git a/examples/registry-guide/16-komposer-accept/komposer.yaml b/examples/registry-guide/16-komposer-accept/komposer.yaml new file mode 100644 index 000000000..0e5efa9bb --- /dev/null +++ b/examples/registry-guide/16-komposer-accept/komposer.yaml @@ -0,0 +1,23 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Komposer +metadata: + name: platform-komposer + version: v1.0.0 + description: > + Platform Komposer that imports a deprecated Katalog and an alpha Katalog. + lifecycle.accept.patterns acknowledges both so ork validate treats them as + intentional — not unreviewed warnings. + +lifecycle: + maturity: stable + accept: + patterns: + - name: webapp-operator # deprecated: v1.0.0 — migrating to v2.0.0 + author: myorg + - name: cache-operator # alpha: API is settling, acceptable for this platform + author: myorg + +imports: + files: + - ./katalog-webapp.yaml + - ./katalog-cache.yaml diff --git a/examples/registry-guide/README.md b/examples/registry-guide/README.md index 32bf2eb66..b9394b168 100644 --- a/examples/registry-guide/README.md +++ b/examples/registry-guide/README.md @@ -13,6 +13,7 @@ By the end of this guide you will have: - Composed multiple Katalogs into a single Komposer - Upgraded a live pattern and rolled it back - Deprecated a pattern and migrated consumers to a replacement +- Declared lifecycle maturity, compatibility ranges, and acceptance at both katalog and komposer level - Built a typed Go operator with hooks for complex business logic - Combined declarative and typed katalogs in one Komposer - Automated the full pipeline with GitHub Actions @@ -57,10 +58,14 @@ This provisions a single-node kind cluster named `ork-playground` and sets your | [06-pattern-zoo](./06-pattern-zoo/README.md) | Mix OCI registry patterns, local katalogs, and Helm sources | | [07-upgrade](./07-upgrade/README.md) | Publish v1.1.0, upgrade a live CR, verify zero downtime | | [08-bad-actor](./08-bad-actor/README.md) | Audit trail: trace who pushed what and when | -| [09-deprecation](./09-deprecation/README.md) | Mark a pattern deprecated, migrate consumers to the replacement | -| [10-hooks-katalog](./10-hooks-katalog/README.md) | Typed Go operator: hooks, generate registry, build, publish | -| [11-typed-komposer](./11-typed-komposer/README.md) | Combine declarative and typed katalogs in one Komposer | -| [12-ork-action](./12-ork-action/README.md) | GitHub Actions: automate validate → simulate → push | +| [09-hooks-katalog](./09-hooks-katalog/README.md) | Typed Go operator: hooks, generate registry, build, publish | +| [10-typed-komposer](./10-typed-komposer/README.md) | Combine declarative and typed katalogs in one Komposer | +| [11-ork-action](./11-ork-action/README.md) | GitHub Actions: automate validate → simulate → push | +| [12-deprecation](./12-deprecation/README.md) | Mark a pattern deprecated, migrate consumers to the replacement | +| [13-deprecation-accept](./13-deprecation-accept/README.md) | Accept a deprecated pattern at katalog level and at komposer level | +| [14-lifecycle-maturity](./14-lifecycle-maturity/README.md) | Signal stability with alpha / beta / stable / deprecated | +| [15-lifecycle-compatibility](./15-lifecycle-compatibility/README.md) | Declare verified Kubernetes and Orkestra version ranges | +| [16-komposer-accept](./16-komposer-accept/README.md) | Komposer that acknowledges multiple patterns with different lifecycle concerns | --- diff --git a/examples/use-cases/crd-conversion/README.md b/examples/use-cases/crd-conversion/README.md index afb42fbcf..e04b45c79 100644 --- a/examples/use-cases/crd-conversion/README.md +++ b/examples/use-cases/crd-conversion/README.md @@ -1,6 +1,6 @@ # CRD Conversion -Two approaches to the same problem: a CronJob operator that accepts multiple schedule formats. The Kubebuilder CronJob tutorial rewritten in YAML — with and without a conversion webhook. +Three approaches to the same problem: a CronJob operator that accepts multiple schedule formats. The Kubebuilder CronJob tutorial rewritten in YAML — each example isolates one translation strategy. | Example | What it teaches | |---------|-----------------| @@ -19,6 +19,7 @@ Every example ships with a runnable `e2e.yaml`. Run a single example end-to-end: ```bash cd with-webhooks && ork e2e cd without-webhooks && ork e2e +cd with-serve-translation && ork e2e ``` Or run both together: diff --git a/pkg/gateway/api/apply.go b/pkg/gateway/api/apply.go index 6ef4dde50..eff6ebfa4 100644 --- a/pkg/gateway/api/apply.go +++ b/pkg/gateway/api/apply.go @@ -26,12 +26,12 @@ import ( k8stypes "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" + orktarget "github.com/orkspace/orkestra/pkg/intent/target" "github.com/orkspace/orkestra/pkg/katalog" "github.com/orkspace/orkestra/pkg/konfig" "github.com/orkspace/orkestra/pkg/kubeclient" "github.com/orkspace/orkestra/pkg/labels" "github.com/orkspace/orkestra/pkg/logger" - orktarget "github.com/orkspace/orkestra/pkg/intent/target" orktmpl "github.com/orkspace/orkestra/pkg/resources/template" orktypes "github.com/orkspace/orkestra/pkg/types" ) diff --git a/pkg/gateway/api/apply_target.go b/pkg/gateway/api/apply_target.go index ff8efb734..8c8746a78 100644 --- a/pkg/gateway/api/apply_target.go +++ b/pkg/gateway/api/apply_target.go @@ -10,12 +10,12 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" k8stypes "k8s.io/apimachinery/pkg/types" + orktarget "github.com/orkspace/orkestra/pkg/intent/target" "github.com/orkspace/orkestra/pkg/katalog" "github.com/orkspace/orkestra/pkg/konfig" "github.com/orkspace/orkestra/pkg/kubeclient" "github.com/orkspace/orkestra/pkg/labels" "github.com/orkspace/orkestra/pkg/logger" - orktarget "github.com/orkspace/orkestra/pkg/intent/target" orktmpl "github.com/orkspace/orkestra/pkg/resources/template" orktypes "github.com/orkspace/orkestra/pkg/types" ) diff --git a/pkg/gateway/api/helper.go b/pkg/gateway/api/helper.go index 9e97f1d86..48e360389 100644 --- a/pkg/gateway/api/helper.go +++ b/pkg/gateway/api/helper.go @@ -5,9 +5,9 @@ import ( "net/http" "strings" + orktarget "github.com/orkspace/orkestra/pkg/intent/target" "github.com/orkspace/orkestra/pkg/kubeclient" "github.com/orkspace/orkestra/pkg/logger" - orktarget "github.com/orkspace/orkestra/pkg/intent/target" orktmpl "github.com/orkspace/orkestra/pkg/resources/template" orktypes "github.com/orkspace/orkestra/pkg/types" "github.com/orkspace/orkestra/pkg/utils" diff --git a/pkg/intent/target/helper.go b/pkg/intent/target/helper.go index c215b1efa..c904c51b9 100644 --- a/pkg/intent/target/helper.go +++ b/pkg/intent/target/helper.go @@ -3,13 +3,13 @@ package target import "github.com/orkspace/orkestra/pkg/utils" var ( - validateK8sName = utils.ValidKubernetesName - isNestedPath = utils.IsNestedPath - setNestedPath = utils.SetNestedPath + validateK8sName = utils.ValidKubernetesName + isNestedPath = utils.IsNestedPath + setNestedPath = utils.SetNestedPath ) // mapContains is a nil-safe map membership check. // Wraps utils.MapContains for local convenience. func mapContains[V any](m map[string]V, key string) bool { - return utils.MapContains(m, key) -} \ No newline at end of file + return utils.MapContains(m, key) +} diff --git a/pkg/intent/target/mux.go b/pkg/intent/target/mux.go index 72ac8edcd..69c5f1872 100644 --- a/pkg/intent/target/mux.go +++ b/pkg/intent/target/mux.go @@ -118,7 +118,9 @@ func (m *MuxReconciler) SetRollbackNotifiers(onTrigger, onClear func()) { } func (m *MuxReconciler) GetAutoMetrics() *autoscaler.AutoMetrics { - if exporter, ok := m.fallback.(interface{ GetAutoMetrics() *autoscaler.AutoMetrics }); ok { + if exporter, ok := m.fallback.(interface { + GetAutoMetrics() *autoscaler.AutoMetrics + }); ok { return exporter.GetAutoMetrics() } return nil diff --git a/pkg/intent/target/resolve.go b/pkg/intent/target/resolve.go index 61fa42f1b..06f9fac4d 100644 --- a/pkg/intent/target/resolve.go +++ b/pkg/intent/target/resolve.go @@ -24,4 +24,3 @@ func ResolveTargetFromAnnotations(annotations map[string]string) string { return "" } - diff --git a/pkg/katalog/deprecation_policy.go b/pkg/katalog/deprecation_policy.go index 697c64b6e..3a5706277 100644 --- a/pkg/katalog/deprecation_policy.go +++ b/pkg/katalog/deprecation_policy.go @@ -6,16 +6,17 @@ import ( ) // CheckDeprecationPolicy enforces runtime deprecation gates for ork run and -// ork gate. It is called after ValidateConfig — not inside it — because -// validation is a pre-flight tool and must not block; enforcement belongs to -// the runtime startup path. +// ork gate. It is called after ValidateConfig — not inside it. +// +// A deprecated Katalog always blocks when run directly. To run a deprecated +// Katalog, import it via a Komposer and add it to lifecycle.accept.patterns. // // Rules: -// - state "warning" (deprecated, before EOL): requires accept.beforeEol: true -// - state "eol" (past end-of-life date): requires accept.beforeEol: true AND accept.eol: true +// - state "warning" (deprecated, before EOL): blocks startup +// - state "eol" (past end-of-life date): blocks startup // - state "none": no-op func (k *Katalog) CheckDeprecationPolicy() error { - d := k.metadata.Deprecation + d := k.Deprecation() if d == nil { return nil } @@ -25,9 +26,6 @@ func (k *Katalog) CheckDeprecationPolicy() error { return nil case "warning": - if d.AcceptsBeforeEol() { - return nil - } msg := d.MigrationMessage() target := d.MigrationTarget() eolLine := "" @@ -40,42 +38,28 @@ func (k *Katalog) CheckDeprecationPolicy() error { } return fmt.Errorf( "%s This katalog is deprecated and cannot start without explicit acknowledgement.%s\n %s%s\n\n"+ - " To proceed, add to your katalog:\n\n"+ - " deprecation:\n"+ + " To proceed — in your Komposer:\n\n"+ + " lifecycle:\n"+ " accept:\n"+ - " beforeEol: true\n", + " patterns:\n"+ + " - name: \n", warningMark(), eolLine, msg, migrLine, ) case "eol": - if d.AcceptsEol() { - return nil - } msg := d.MigrationMessage() target := d.MigrationTarget() migrLine := "" if target != "" { migrLine = fmt.Sprintf("\n Migrate to: %s", target) } - if !d.AcceptsBeforeEol() { - return fmt.Errorf( - "%s This katalog is past its end-of-life date (%s) and cannot start.\n %s%s\n\n"+ - " To proceed, add to your katalog:\n\n"+ - " deprecation:\n"+ - " accept:\n"+ - " beforeEol: true\n"+ - " eol: true\n", - failureMark(), d.TimelineTo(), msg, migrLine, - ) - } return fmt.Errorf( "%s This katalog is past its end-of-life date (%s) and cannot start.\n %s%s\n\n"+ - " accept.beforeEol is set but accept.eol is required to run past EOL.\n\n"+ - " Add to your katalog:\n\n"+ - " deprecation:\n"+ + " To proceed — in your Komposer:\n\n"+ + " lifecycle:\n"+ " accept:\n"+ - " beforeEol: true\n"+ - " eol: true\n", + " patterns:\n"+ + " - name: \n", failureMark(), d.TimelineTo(), msg, migrLine, ) } diff --git a/pkg/katalog/deprecation_policy_test.go b/pkg/katalog/deprecation_policy_test.go index 291f3adee..1f5cf2ece 100644 --- a/pkg/katalog/deprecation_policy_test.go +++ b/pkg/katalog/deprecation_policy_test.go @@ -13,25 +13,17 @@ func TestCheckDeprecationPolicy_NilBlock(t *testing.T) { assert.NoError(t, k.CheckDeprecationPolicy()) } -func TestCheckDeprecationPolicy_NoTimeline_NoAccept(t *testing.T) { +func TestCheckDeprecationPolicy_NoTimeline_Deprecated(t *testing.T) { k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ Message: "use v2", }) err := k.CheckDeprecationPolicy() require.Error(t, err) - assert.Contains(t, err.Error(), "beforeEol: true") assert.Contains(t, err.Error(), "deprecated") + assert.Contains(t, err.Error(), "lifecycle:") } -func TestCheckDeprecationPolicy_NoTimeline_Accepted(t *testing.T) { - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - Message: "use v2", - Accept: &orktypes.DeprecationAccept{BeforeEol: true}, - }) - assert.NoError(t, k.CheckDeprecationPolicy()) -} - -func TestCheckDeprecationPolicy_WarningWindow_NoAccept(t *testing.T) { +func TestCheckDeprecationPolicy_WarningWindow(t *testing.T) { k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ Message: "use v2", Timeline: &orktypes.DeprecationTimeline{ @@ -41,19 +33,7 @@ func TestCheckDeprecationPolicy_WarningWindow_NoAccept(t *testing.T) { }) err := k.CheckDeprecationPolicy() require.Error(t, err) - assert.Contains(t, err.Error(), "beforeEol: true") -} - -func TestCheckDeprecationPolicy_WarningWindow_Accepted(t *testing.T) { - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - Message: "use v2", - Timeline: &orktypes.DeprecationTimeline{ - From: "2020-01-01", - To: "2099-01-01", - }, - Accept: &orktypes.DeprecationAccept{BeforeEol: true}, - }) - assert.NoError(t, k.CheckDeprecationPolicy()) + assert.Contains(t, err.Error(), "lifecycle:") } func TestCheckDeprecationPolicy_BeforeFrom_NoAcceptNeeded(t *testing.T) { @@ -67,7 +47,7 @@ func TestCheckDeprecationPolicy_BeforeFrom_NoAcceptNeeded(t *testing.T) { assert.NoError(t, k.CheckDeprecationPolicy()) } -func TestCheckDeprecationPolicy_EOL_NeitherAccepted(t *testing.T) { +func TestCheckDeprecationPolicy_EOL(t *testing.T) { k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ Message: "use v2", Timeline: &orktypes.DeprecationTimeline{ @@ -78,49 +58,7 @@ func TestCheckDeprecationPolicy_EOL_NeitherAccepted(t *testing.T) { err := k.CheckDeprecationPolicy() require.Error(t, err) assert.Contains(t, err.Error(), "end-of-life") - assert.Contains(t, err.Error(), "beforeEol: true") - assert.Contains(t, err.Error(), "eol: true") -} - -func TestCheckDeprecationPolicy_EOL_OnlyBeforeEol(t *testing.T) { - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - Message: "use v2", - Timeline: &orktypes.DeprecationTimeline{ - From: "2020-01-01", - To: "2020-06-01", - }, - Accept: &orktypes.DeprecationAccept{BeforeEol: true}, - }) - err := k.CheckDeprecationPolicy() - require.Error(t, err) - assert.Contains(t, err.Error(), "eol: true") -} - -func TestCheckDeprecationPolicy_EOL_BothAccepted(t *testing.T) { - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - Message: "use v2", - Timeline: &orktypes.DeprecationTimeline{ - From: "2020-01-01", - To: "2020-06-01", - }, - Accept: &orktypes.DeprecationAccept{BeforeEol: true, Eol: true}, - }) - assert.NoError(t, k.CheckDeprecationPolicy()) -} - -func TestCheckDeprecationPolicy_EOL_OnlyEolWithoutBeforeEol(t *testing.T) { - // eol: true alone is insufficient — beforeEol must also be true - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - Message: "use v2", - Timeline: &orktypes.DeprecationTimeline{ - From: "2020-01-01", - To: "2020-06-01", - }, - Accept: &orktypes.DeprecationAccept{Eol: true}, - }) - err := k.CheckDeprecationPolicy() - require.Error(t, err) - assert.Contains(t, err.Error(), "beforeEol: true") + assert.Contains(t, err.Error(), "lifecycle:") } func TestCheckDeprecationPolicy_MigrationTargetInError(t *testing.T) { diff --git a/pkg/katalog/parser.go b/pkg/katalog/parser.go index b641754fe..20212ac4f 100644 --- a/pkg/katalog/parser.go +++ b/pkg/katalog/parser.go @@ -99,6 +99,8 @@ func (k *Katalog) KomposeRuntimeKatalog( k.projectInfo = m.ToProjectInfo() k.enabledCRDs = m.Enabled() // Enabled CRDs for all operations k.metadata = m.APIMetadata().Metadata // Metadata for CLI and health endpoints + k.lifecycle = m.ToLifecycle() + k.policy = m.ToPolicy() k.APIVersion = m.APIMetadata().APIVersion k.Kind = m.APIMetadata().Kind k.konfig = kfg diff --git a/pkg/katalog/testdata/validate/invalid/bad-deprecation-no-message.yaml b/pkg/katalog/testdata/validate/invalid/bad-deprecation-no-message.yaml index be96be455..348d1e639 100644 --- a/pkg/katalog/testdata/validate/invalid/bad-deprecation-no-message.yaml +++ b/pkg/katalog/testdata/validate/invalid/bad-deprecation-no-message.yaml @@ -3,7 +3,8 @@ kind: Katalog metadata: name: legacy-worker version: 1.0.0 - description: Tests that metadata.deprecation requires a message field. + description: Tests that lifecycle.deprecation requires a message field. +lifecycle: deprecation: migratedTo: task-runner:v1.0.0 # message is required — this should fail validation diff --git a/pkg/katalog/testdata/validate/invalid/bad-lifecycle-accept-on-katalog.yaml b/pkg/katalog/testdata/validate/invalid/bad-lifecycle-accept-on-katalog.yaml new file mode 100644 index 000000000..28ad85db7 --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-lifecycle-accept-on-katalog.yaml @@ -0,0 +1,14 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-accept-on-katalog + version: 1.0.0 + description: Tests that lifecycle.accept is rejected on a Katalog (Komposer-only field). +lifecycle: + accept: + patterns: + - name: some-deprecated-import +spec: + crds: + widget: + crdFile: ../crd.yaml diff --git a/pkg/katalog/testdata/validate/invalid/bad-lifecycle-compat-kubernetes.yaml b/pkg/katalog/testdata/validate/invalid/bad-lifecycle-compat-kubernetes.yaml new file mode 100644 index 000000000..026474a72 --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-lifecycle-compat-kubernetes.yaml @@ -0,0 +1,13 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-compat-kubernetes + version: 1.0.0 + description: Tests that an invalid kubernetes semver range fails validation. +lifecycle: + compatibility: + kubernetes: "!!!invalid" +spec: + crds: + widget: + crdFile: ../crd.yaml diff --git a/pkg/katalog/testdata/validate/invalid/bad-lifecycle-compat-orkestra.yaml b/pkg/katalog/testdata/validate/invalid/bad-lifecycle-compat-orkestra.yaml new file mode 100644 index 000000000..5ab25956c --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-lifecycle-compat-orkestra.yaml @@ -0,0 +1,13 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-compat-orkestra + version: 1.0.0 + description: Tests that an invalid orkestra semver range fails validation. +lifecycle: + compatibility: + orkestra: "not-a-range" +spec: + crds: + widget: + crdFile: ../crd.yaml diff --git a/pkg/katalog/testdata/validate/invalid/bad-lifecycle-maturity-unknown.yaml b/pkg/katalog/testdata/validate/invalid/bad-lifecycle-maturity-unknown.yaml new file mode 100644 index 000000000..9b155f0e3 --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-lifecycle-maturity-unknown.yaml @@ -0,0 +1,12 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-maturity + version: 1.0.0 + description: Tests that an unknown maturity value fails validation. +lifecycle: + maturity: experimental +spec: + crds: + widget: + crdFile: ../crd.yaml diff --git a/pkg/katalog/testdata/validate/invalid/bad-policy-min-maturity.yaml b/pkg/katalog/testdata/validate/invalid/bad-policy-min-maturity.yaml new file mode 100644 index 000000000..0b5e29f1c --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-policy-min-maturity.yaml @@ -0,0 +1,13 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-policy-min-maturity + version: 1.0.0 + description: Tests that policy.minMaturity deprecated is rejected (deprecated is not a valid floor). +policy: + lifecycle: + minMaturity: deprecated +spec: + crds: + widget: + crdFile: ../crd.yaml diff --git a/pkg/katalog/testdata/validate/valid/deprecation-accepted.yaml b/pkg/katalog/testdata/validate/valid/deprecation-accepted.yaml index d9877bfcb..3f9dde9ce 100644 --- a/pkg/katalog/testdata/validate/valid/deprecation-accepted.yaml +++ b/pkg/katalog/testdata/validate/valid/deprecation-accepted.yaml @@ -3,15 +3,14 @@ kind: Katalog metadata: name: legacy-worker version: 1.0.0 - description: Tests accept.beforeEol allowing startup during the deprecation warning window. + description: "Tests deprecation with timeline — warning window, no accept needed at Katalog level." +lifecycle: deprecation: message: "Replaced by task-runner which uses the Jobs API" migratedTo: task-runner:v1.0.0 timeline: from: "2026-01-01" to: "2027-01-01" - accept: - beforeEol: true spec: crds: widget: diff --git a/pkg/katalog/testdata/validate/valid/deprecation-eol-accepted.yaml b/pkg/katalog/testdata/validate/valid/deprecation-eol-accepted.yaml index 3ac245483..2c1537344 100644 --- a/pkg/katalog/testdata/validate/valid/deprecation-eol-accepted.yaml +++ b/pkg/katalog/testdata/validate/valid/deprecation-eol-accepted.yaml @@ -3,16 +3,14 @@ kind: Katalog metadata: name: legacy-worker version: 1.0.0 - description: Tests accept.beforeEol + accept.eol allowing startup past the end-of-life date. + description: "Tests deprecation past end-of-life date — validates cleanly at schema level." +lifecycle: deprecation: message: "Replaced by task-runner which uses the Jobs API" migratedTo: task-runner:v1.0.0 timeline: from: "2020-01-01" to: "2021-01-01" - accept: - beforeEol: true - eol: true spec: crds: widget: diff --git a/pkg/katalog/testdata/validate/valid/deprecation-timeline.yaml b/pkg/katalog/testdata/validate/valid/deprecation-timeline.yaml index 754ff7037..8c38eadb4 100644 --- a/pkg/katalog/testdata/validate/valid/deprecation-timeline.yaml +++ b/pkg/katalog/testdata/validate/valid/deprecation-timeline.yaml @@ -4,6 +4,7 @@ metadata: name: legacy-worker version: 1.0.0 description: Tests deprecation timeline warning display (from/to dates, countdown). +lifecycle: deprecation: message: "Replaced by task-runner which uses the Jobs API" migratedTo: task-runner:v1.0.0 diff --git a/pkg/katalog/testdata/validate/valid/lifecycle-alpha.yaml b/pkg/katalog/testdata/validate/valid/lifecycle-alpha.yaml new file mode 100644 index 000000000..aa0ffe8e6 --- /dev/null +++ b/pkg/katalog/testdata/validate/valid/lifecycle-alpha.yaml @@ -0,0 +1,12 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: alpha-operator + version: 0.1.0 + description: Tests that alpha maturity passes validation (warns but does not error). +lifecycle: + maturity: alpha +spec: + crds: + widget: + crdFile: ../crd.yaml diff --git a/pkg/katalog/testdata/validate/valid/lifecycle-deprecated-accepted.yaml b/pkg/katalog/testdata/validate/valid/lifecycle-deprecated-accepted.yaml new file mode 100644 index 000000000..fca83cd4a --- /dev/null +++ b/pkg/katalog/testdata/validate/valid/lifecycle-deprecated-accepted.yaml @@ -0,0 +1,18 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: legacy-operator + version: 1.5.0 + description: Tests a deprecated Katalog with maturity and deprecation block. +lifecycle: + maturity: deprecated + deprecation: + message: "Replaced by new-operator. Migrate before 2027-01-01." + migratedTo: "new-operator:v1.0.0" + timeline: + from: "2026-01-01" + to: "2027-01-01" +spec: + crds: + widget: + crdFile: ../crd.yaml diff --git a/pkg/katalog/testdata/validate/valid/lifecycle-deprecated-no-block-warns.yaml b/pkg/katalog/testdata/validate/valid/lifecycle-deprecated-no-block-warns.yaml new file mode 100644 index 000000000..1d1a5bf76 --- /dev/null +++ b/pkg/katalog/testdata/validate/valid/lifecycle-deprecated-no-block-warns.yaml @@ -0,0 +1,12 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-deprecated-no-block + version: 1.0.0 + description: Tests that maturity deprecated without a deprecation block produces a warning (not an error). +lifecycle: + maturity: deprecated +spec: + crds: + widget: + crdFile: ../crd.yaml diff --git a/pkg/katalog/testdata/validate/valid/lifecycle-stable.yaml b/pkg/katalog/testdata/validate/valid/lifecycle-stable.yaml new file mode 100644 index 000000000..9d23114cc --- /dev/null +++ b/pkg/katalog/testdata/validate/valid/lifecycle-stable.yaml @@ -0,0 +1,15 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: stable-operator + version: 2.0.0 + description: Tests a full lifecycle block on a stable Katalog. +lifecycle: + maturity: stable + compatibility: + kubernetes: ">=1.28" + orkestra: ">=0.7.0" +spec: + crds: + widget: + crdFile: ../crd.yaml diff --git a/pkg/katalog/testdata/validate/valid/policy-min-maturity-beta.yaml b/pkg/katalog/testdata/validate/valid/policy-min-maturity-beta.yaml new file mode 100644 index 000000000..cb0301258 --- /dev/null +++ b/pkg/katalog/testdata/validate/valid/policy-min-maturity-beta.yaml @@ -0,0 +1,9 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Komposer +metadata: + name: platform-komposer + version: 1.0.0 + description: Tests that policy.minMaturity beta is valid. +policy: + lifecycle: + minMaturity: beta diff --git a/pkg/katalog/type.go b/pkg/katalog/type.go index 04b39b2c3..91ca6eabb 100644 --- a/pkg/katalog/type.go +++ b/pkg/katalog/type.go @@ -38,6 +38,8 @@ type Katalog struct { // Internal — enabledCRDs is enriched and validated; Spec.CRDs holds all (including disabled) metadata orktypes.KatalogMeta `yaml:"-" json:"-"` + lifecycle *orktypes.KatalogLifecycle `yaml:"-" json:"-"` + policy *orktypes.KatalogPolicy `yaml:"-" json:"-"` enabledCRDs map[string]orktypes.CRDEntry `yaml:"-" json:"-"` serveEnabledCRDs []*orktypes.CRDEntry `yaml:"-" json:"-"` withCRDFiles []string `yaml:"-" json:"-"` // CRD names that declared crdFile, captured before the field is cleared @@ -174,42 +176,55 @@ func (k *Katalog) Metadata() orktypes.KatalogMeta { return k.metadata } +// Lifecycle returns the lifecycle policy block, or nil if absent. +func (k *Katalog) Lifecycle() *orktypes.KatalogLifecycle { + return k.lifecycle +} + +// Policy returns the platform policy block, or nil if absent. +func (k *Katalog) Policy() *orktypes.KatalogPolicy { + return k.policy +} + // Deprecation returns the raw deprecation block, or nil if absent. func (k *Katalog) Deprecation() *orktypes.KatalogDeprecation { - return k.metadata.Deprecation + if k.lifecycle == nil { + return nil + } + return k.lifecycle.Deprecation } // IsDeprecated returns true if the Katalog is deprecated. func (k *Katalog) IsDeprecated() bool { - if k.metadata.Deprecation == nil { + if k.lifecycle == nil { return false } - return k.metadata.Deprecation.IsDeprecated() + return k.lifecycle.IsDeprecated() } // IsMigrated returns true if the Katalog is deprecated and has a migration target. func (k *Katalog) IsMigrated() bool { - if k.metadata.Deprecation == nil { + if k.lifecycle == nil || k.lifecycle.Deprecation == nil { return false } - return k.metadata.Deprecation.MigrationTarget() != "" + return k.lifecycle.Deprecation.MigrationTarget() != "" } // MigrationTarget returns the value of the MigratedTo field. -// If the deprecation block is nil or empty, it returns an empty string. +// If the lifecycle or deprecation block is nil, it returns an empty string. func (k *Katalog) MigrationTarget() string { - if k.metadata.Deprecation == nil { + if k.lifecycle == nil || k.lifecycle.Deprecation == nil { return "" } - return k.metadata.Deprecation.MigrationTarget() + return k.lifecycle.Deprecation.MigrationTarget() } // MigrationMessage returns the deprecation message. func (k *Katalog) MigrationMessage() string { - if k.metadata.Deprecation == nil { + if k.lifecycle == nil || k.lifecycle.Deprecation == nil { return "" } - return k.metadata.Deprecation.MigrationMessage() + return k.lifecycle.Deprecation.MigrationMessage() } // CRDEntry returns the enabled CRD entry for the given name. diff --git a/pkg/katalog/validate.go b/pkg/katalog/validate.go index 16e46c031..718ca16a4 100644 --- a/pkg/katalog/validate.go +++ b/pkg/katalog/validate.go @@ -14,9 +14,16 @@ func (k *Katalog) ValidateConfig(kfg *konfig.Konfig) (*Katalog, error) { } // ------------------------------------------------------------------------- - // 2. Deprecation timeline validation + // 2a. Lifecycle validation (deprecation, maturity, compatibility syntax) // ------------------------------------------------------------------------- - if err := k.validateDeprecation(); err != nil { + if err := k.validateLifecycle(); err != nil { + return nil, err + } + + // ------------------------------------------------------------------------- + // 2b. Policy block validation (minMaturity enum) + // ------------------------------------------------------------------------- + if err := k.validatePolicy(); err != nil { return nil, err } diff --git a/pkg/katalog/validate_deprecation.go b/pkg/katalog/validate_deprecation.go deleted file mode 100644 index ddb9433b1..000000000 --- a/pkg/katalog/validate_deprecation.go +++ /dev/null @@ -1,64 +0,0 @@ -package katalog - -import ( - "fmt" - "time" -) - -// validateDeprecation checks the metadata.deprecation block when present. -// -// Rules: -// 1. message is required when deprecation: is declared. -// 2. timeline.from and timeline.to must parse as YYYY-MM-DD when set. -// 3. timeline.from must be before timeline.to when both are set. -func (k *Katalog) validateDeprecation() error { - d := k.metadata.Deprecation - if d == nil { - return nil - } - - if d.Message == "" { - return fmt.Errorf( - "%s metadata.deprecation: message is required when deprecation is declared", - failureMark(), - ) - } - - const layout = "2006-01-02" - - var from, to time.Time - var hasFrom, hasTo bool - - if f := d.TimelineFrom(); f != "" { - t, err := time.Parse(layout, f) - if err != nil { - return fmt.Errorf( - "%s metadata.deprecation.timeline.from: %q is not a valid date (expected YYYY-MM-DD)", - failureMark(), f, - ) - } - from = t - hasFrom = true - } - - if s := d.TimelineTo(); s != "" { - t, err := time.Parse(layout, s) - if err != nil { - return fmt.Errorf( - "%s metadata.deprecation.timeline.to: %q is not a valid date (expected YYYY-MM-DD)", - failureMark(), s, - ) - } - to = t - hasTo = true - } - - if hasFrom && hasTo && !from.Before(to) { - return fmt.Errorf( - "%s metadata.deprecation.timeline: from (%s) must be before to (%s)", - failureMark(), d.TimelineFrom(), d.TimelineTo(), - ) - } - - return nil -} diff --git a/pkg/katalog/validate_deprecation_test.go b/pkg/katalog/validate_deprecation_test.go deleted file mode 100644 index 16532bd62..000000000 --- a/pkg/katalog/validate_deprecation_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package katalog - -import ( - "testing" - - orktypes "github.com/orkspace/orkestra/pkg/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func katalogWithDeprecation(d *orktypes.KatalogDeprecation) *Katalog { - return &Katalog{ - metadata: orktypes.KatalogMeta{ - Deprecation: d, - }, - } -} - -func TestValidateDeprecation_Nil(t *testing.T) { - k := katalogWithDeprecation(nil) - assert.NoError(t, k.validateDeprecation()) -} - -func TestValidateDeprecation_MessageOnly(t *testing.T) { - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - Message: "use v2", - }) - assert.NoError(t, k.validateDeprecation()) -} - -func TestValidateDeprecation_MissingMessage(t *testing.T) { - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - MigratedTo: "mypattern:v2", - }) - err := k.validateDeprecation() - require.Error(t, err) - assert.Contains(t, err.Error(), "message is required") -} - -func TestValidateDeprecation_ValidTimeline(t *testing.T) { - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - Message: "use v2", - Timeline: &orktypes.DeprecationTimeline{ - From: "2026-01-01", - To: "2027-01-01", - }, - }) - assert.NoError(t, k.validateDeprecation()) -} - -func TestValidateDeprecation_BadFromDate(t *testing.T) { - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - Message: "use v2", - Timeline: &orktypes.DeprecationTimeline{ - From: "not-a-date", - To: "2027-01-01", - }, - }) - err := k.validateDeprecation() - require.Error(t, err) - assert.Contains(t, err.Error(), "timeline.from") - assert.Contains(t, err.Error(), "YYYY-MM-DD") -} - -func TestValidateDeprecation_BadToDate(t *testing.T) { - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - Message: "use v2", - Timeline: &orktypes.DeprecationTimeline{ - From: "2026-01-01", - To: "01/01/2027", - }, - }) - err := k.validateDeprecation() - require.Error(t, err) - assert.Contains(t, err.Error(), "timeline.to") - assert.Contains(t, err.Error(), "YYYY-MM-DD") -} - -func TestValidateDeprecation_FromAfterTo(t *testing.T) { - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - Message: "use v2", - Timeline: &orktypes.DeprecationTimeline{ - From: "2027-06-01", - To: "2027-01-01", - }, - }) - err := k.validateDeprecation() - require.Error(t, err) - assert.Contains(t, err.Error(), "from") - assert.Contains(t, err.Error(), "before") - assert.Contains(t, err.Error(), "to") -} - -func TestValidateDeprecation_FromEqualTo(t *testing.T) { - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - Message: "use v2", - Timeline: &orktypes.DeprecationTimeline{ - From: "2027-01-01", - To: "2027-01-01", - }, - }) - err := k.validateDeprecation() - require.Error(t, err) - assert.Contains(t, err.Error(), "before") -} - -func TestValidateDeprecation_TimelineFromOnly(t *testing.T) { - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - Message: "use v2", - Timeline: &orktypes.DeprecationTimeline{ - From: "2026-01-01", - }, - }) - assert.NoError(t, k.validateDeprecation()) -} - -func TestValidateDeprecation_TimelineToOnly(t *testing.T) { - k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ - Message: "use v2", - Timeline: &orktypes.DeprecationTimeline{ - To: "2027-01-01", - }, - }) - assert.NoError(t, k.validateDeprecation()) -} diff --git a/pkg/katalog/validate_lifecycle.go b/pkg/katalog/validate_lifecycle.go new file mode 100644 index 000000000..9c3b1dff5 --- /dev/null +++ b/pkg/katalog/validate_lifecycle.go @@ -0,0 +1,215 @@ +package katalog + +import ( + "fmt" + "time" + + "github.com/orkspace/orkestra/pkg/konfig" + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/orkspace/orkestra/pkg/utils" +) + +// validateLifecycle checks the lifecycle: block when present. +// +// Rules: +// 1. maturity must be one of: alpha, beta, stable, deprecated. +// 2. maturity: alpha or beta adds a non-fatal warning. +// 3. maturity: deprecated without lifecycle.deprecation adds a non-fatal warning +// (the deprecation block is the primary signal; maturity is advisory). +// 4. lifecycle.deprecation present without maturity is valid (block implies deprecated). +// 5. compatibility.kubernetes and compatibility.orkestra must be valid semver ranges when set. +// 6. lifecycle.accept is only valid on a Komposer — error on a Katalog. +// 7. lifecycle.accept.patterns[].version must be a valid semver range when set. +func (k *Katalog) validateLifecycle() error { + lc := k.Lifecycle() + if lc == nil { + return nil + } + + if err := k.validateLifecycleMaturity(lc); err != nil { + return err + } + + if err := validateLifecycleCompatibility(lc); err != nil { + return err + } + + if err := k.validateLifecycleDeprecation(); err != nil { + return err + } + + if err := k.validateLifecycleAcceptBoundary(lc); err != nil { + return err + } + + if err := validateLifecycleAcceptPatterns(lc); err != nil { + return err + } + + return nil +} + +func (k *Katalog) validateLifecycleMaturity(lc *orktypes.KatalogLifecycle) error { + if lc.Maturity == "" { + return nil + } + switch lc.Maturity { + case orktypes.MaturityAlpha: + k.Warnings.AddWarning("lifecycle.maturity is alpha — this pattern is experimental and not recommended for production") + case orktypes.MaturityBeta: + k.Warnings.AddWarning("lifecycle.maturity is beta — this pattern is stabilising; API may still change") + case orktypes.MaturityStable: + // no warning + case orktypes.MaturityDeprecated: + if lc.Deprecation == nil { + // advisory — the deprecation block is the source of truth + k.Warnings.AddWarning("lifecycle.maturity is deprecated but lifecycle.deprecation is not set — add the deprecation block or remove the maturity") + } + default: + return fmt.Errorf( + "%s lifecycle.maturity: %q is not valid — must be one of: alpha, beta, stable, deprecated", + failureMark(), lc.Maturity, + ) + } + return nil +} + +func validateLifecycleCompatibility(lc *orktypes.KatalogLifecycle) error { + if lc.Compatibility == nil { + return nil + } + if k8s := lc.Compatibility.Kubernetes; k8s != "" { + if _, err := utils.SemverCheck("1.0.0", k8s); err != nil { + return fmt.Errorf( + "%s lifecycle.compatibility.kubernetes: %q is not a valid semver range", + failureMark(), k8s, + ) + } + } + if ork := lc.Compatibility.Orkestra; ork != "" { + if _, err := utils.SemverCheck("1.0.0", ork); err != nil { + return fmt.Errorf( + "%s lifecycle.compatibility.orkestra: %q is not a valid semver range", + failureMark(), ork, + ) + } + } + return nil +} + +// validateLifecycleDeprecation checks the lifecycle.deprecation block when present. +// +// Rules: +// 1. message is required when deprecation: is declared. +// 2. timeline.from and timeline.to must parse as YYYY-MM-DD when set. +// 3. timeline.from must be before timeline.to when both are set. +func (k *Katalog) validateLifecycleDeprecation() error { + d := k.Deprecation() + if d == nil { + return nil + } + + if d.Message == "" { + return fmt.Errorf( + "%s lifecycle.deprecation: message is required when deprecation is declared", + failureMark(), + ) + } + + const layout = "2006-01-02" + + var from, to time.Time + var hasFrom, hasTo bool + + if f := d.TimelineFrom(); f != "" { + t, err := time.Parse(layout, f) + if err != nil { + return fmt.Errorf( + "%s lifecycle.deprecation.timeline.from: %q is not a valid date (expected YYYY-MM-DD)", + failureMark(), f, + ) + } + from = t + hasFrom = true + } + + if s := d.TimelineTo(); s != "" { + t, err := time.Parse(layout, s) + if err != nil { + return fmt.Errorf( + "%s lifecycle.deprecation.timeline.to: %q is not a valid date (expected YYYY-MM-DD)", + failureMark(), s, + ) + } + to = t + hasTo = true + } + + if hasFrom && hasTo && !from.Before(to) { + return fmt.Errorf( + "%s lifecycle.deprecation.timeline: from (%s) must be before to (%s)", + failureMark(), d.TimelineFrom(), d.TimelineTo(), + ) + } + + return nil +} + +// validateLifecycleAcceptBoundary checks the lifecycle.accept block when present. +func (k *Katalog) validateLifecycleAcceptBoundary(lc *orktypes.KatalogLifecycle) error { + // lifecycle.accept is Komposer-only + if lc.Accept != nil && !konfig.IsKomposerKind(k.Kind) { + return fmt.Errorf( + "%s lifecycle.accept is only valid in a Komposer — move pattern acknowledgements to a komposer.yaml", + failureMark(), + ) + } + return nil +} + +// validateLifecycleAcceptPatterns validates the version field on each accept.patterns +// entry. When set, version must be a valid semver range. Stale-acceptance detection +// (warning when the imported version no longer matches) is deferred pending import resolution. +func validateLifecycleAcceptPatterns(lc *orktypes.KatalogLifecycle) error { + if lc.Accept == nil { + return nil + } + for _, e := range lc.Accept.Patterns { + if e.Version == "" { + continue + } + if _, err := utils.SemverCheck("1.0.0", e.Version); err != nil { + return fmt.Errorf( + "%s lifecycle.accept.patterns[%q].version: %q is not a valid semver range", + failureMark(), e.Name, e.Version, + ) + } + } + return nil +} + +// validatePolicy checks the policy: block when present. +// +// Rules: +// 1. policy.lifecycle.minMaturity must be one of: alpha, beta, stable. +// (deprecated is not a valid floor — deprecated imports require explicit accept regardless.) +func (k *Katalog) validatePolicy() error { + p := k.Policy() + if p == nil || p.Lifecycle == nil { + return nil + } + lc := p.Lifecycle + if lc.MinMaturity == "" { + return nil + } + switch lc.MinMaturity { + case orktypes.MaturityAlpha, orktypes.MaturityBeta, orktypes.MaturityStable: + // valid floors + default: + return fmt.Errorf( + "%s policy.lifecycle.minMaturity: %q is not valid — must be one of: alpha, beta, stable", + failureMark(), lc.MinMaturity, + ) + } + return nil +} diff --git a/pkg/katalog/validate_lifecycle_test.go b/pkg/katalog/validate_lifecycle_test.go new file mode 100644 index 000000000..aa99e5ae1 --- /dev/null +++ b/pkg/katalog/validate_lifecycle_test.go @@ -0,0 +1,384 @@ +package katalog + +import ( + "testing" + + "github.com/orkspace/orkestra/pkg/konfig" + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// helpers + +func katalogWithLifecycle(lc *orktypes.KatalogLifecycle) *Katalog { + return &Katalog{ + Kind: konfig.KatalogKind(), + lifecycle: lc, + } +} + +func komposerWithLifecycle(lc *orktypes.KatalogLifecycle) *Katalog { + return &Katalog{ + Kind: konfig.KomposerKind(), + lifecycle: lc, + } +} + +func katalogWithPolicy(p *orktypes.KatalogPolicy) *Katalog { + return &Katalog{ + Kind: konfig.KatalogKind(), + policy: p, + } +} + +// ── maturity ───────────────────────────────────────────────────────────────── + +func TestValidateLifecycle_NoLifecycle(t *testing.T) { + k := katalogWithLifecycle(nil) + assert.NoError(t, k.validateLifecycle()) +} + +func TestValidateLifecycle_StableMaturity(t *testing.T) { + k := katalogWithLifecycle(&orktypes.KatalogLifecycle{ + Maturity: orktypes.MaturityStable, + }) + assert.NoError(t, k.validateLifecycle()) + assert.False(t, k.Warnings.HasWarnings()) +} + +func TestValidateLifecycle_AlphaMaturityWarns(t *testing.T) { + k := katalogWithLifecycle(&orktypes.KatalogLifecycle{ + Maturity: orktypes.MaturityAlpha, + }) + assert.NoError(t, k.validateLifecycle()) + assert.True(t, k.Warnings.HasWarnings()) + assert.Contains(t, k.Warnings[0], "alpha") +} + +func TestValidateLifecycle_BetaMaturityWarns(t *testing.T) { + k := katalogWithLifecycle(&orktypes.KatalogLifecycle{ + Maturity: orktypes.MaturityBeta, + }) + assert.NoError(t, k.validateLifecycle()) + assert.True(t, k.Warnings.HasWarnings()) + assert.Contains(t, k.Warnings[0], "beta") +} + +// maturity: deprecated without a deprecation block is now a warning, not an error. +// The deprecation block is the primary signal. +func TestValidateLifecycle_DeprecatedWithoutBlockWarns(t *testing.T) { + k := katalogWithLifecycle(&orktypes.KatalogLifecycle{ + Maturity: orktypes.MaturityDeprecated, + }) + assert.NoError(t, k.validateLifecycle()) + assert.True(t, k.Warnings.HasWarnings()) + assert.Contains(t, k.Warnings[0], "lifecycle.deprecation is not set") +} + +// A deprecation block alone is sufficient — maturity: deprecated is not required. +func TestValidateLifecycle_DeprecationBlockWithoutMaturityIsValid(t *testing.T) { + k := katalogWithLifecycle(&orktypes.KatalogLifecycle{ + Deprecation: &orktypes.KatalogDeprecation{ + Message: "use v2", + }, + }) + assert.NoError(t, k.validateLifecycle()) + assert.False(t, k.Warnings.HasWarnings()) +} + +func TestValidateLifecycle_DeprecatedWithDeprecationBlock(t *testing.T) { + k := katalogWithLifecycle(&orktypes.KatalogLifecycle{ + Maturity: orktypes.MaturityDeprecated, + Deprecation: &orktypes.KatalogDeprecation{ + Message: "use v2", + }, + }) + assert.NoError(t, k.validateLifecycle()) + assert.False(t, k.Warnings.HasWarnings()) +} + +func TestValidateLifecycle_UnknownMaturity(t *testing.T) { + k := katalogWithLifecycle(&orktypes.KatalogLifecycle{ + Maturity: orktypes.LifecycleMaturity("experimental"), + }) + err := k.validateLifecycle() + require.Error(t, err) + assert.Contains(t, err.Error(), "not valid") + assert.Contains(t, err.Error(), "experimental") +} + +// ── compatibility ───────────────────────────────────────────────────────────── + +func TestValidateLifecycle_ValidCompatibility(t *testing.T) { + k := katalogWithLifecycle(&orktypes.KatalogLifecycle{ + Compatibility: &orktypes.LifecycleCompat{ + Kubernetes: ">=1.31", + Orkestra: ">=0.7.14", + }, + }) + assert.NoError(t, k.validateLifecycle()) +} + +func TestValidateLifecycle_InvalidKubernetesRange(t *testing.T) { + k := katalogWithLifecycle(&orktypes.KatalogLifecycle{ + Compatibility: &orktypes.LifecycleCompat{ + Kubernetes: "!!!invalid", + }, + }) + err := k.validateLifecycle() + require.Error(t, err) + assert.Contains(t, err.Error(), "lifecycle.compatibility.kubernetes") +} + +func TestValidateLifecycle_InvalidOrkestraRange(t *testing.T) { + k := katalogWithLifecycle(&orktypes.KatalogLifecycle{ + Compatibility: &orktypes.LifecycleCompat{ + Orkestra: "!!!invalid", + }, + }) + err := k.validateLifecycle() + require.Error(t, err) + assert.Contains(t, err.Error(), "lifecycle.compatibility.orkestra") +} + +// ── katalog Deprecation ──────────────────────────────────────────────────── + +func katalogWithDeprecation(d *orktypes.KatalogDeprecation) *Katalog { + if d == nil { + return &Katalog{} + } + return &Katalog{ + lifecycle: &orktypes.KatalogLifecycle{ + Deprecation: d, + }, + } +} + +func TestValidateLifecycleDeprecation_Nil(t *testing.T) { + k := katalogWithDeprecation(nil) + assert.NoError(t, k.validateLifecycleDeprecation()) +} + +func TestValidateLifecycleDeprecation_MessageOnly(t *testing.T) { + k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ + Message: "use v2", + }) + assert.NoError(t, k.validateLifecycleDeprecation()) +} + +func TestValidateLifecycleDeprecation_MissingMessage(t *testing.T) { + k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ + MigratedTo: "mypattern:v2", + }) + err := k.validateLifecycleDeprecation() + require.Error(t, err) + assert.Contains(t, err.Error(), "message is required") +} + +func TestValidateLifecycleDeprecation_ValidTimeline(t *testing.T) { + k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ + Message: "use v2", + Timeline: &orktypes.DeprecationTimeline{ + From: "2026-01-01", + To: "2027-01-01", + }, + }) + assert.NoError(t, k.validateLifecycleDeprecation()) +} + +func TestValidateLifecycleDeprecation_BadFromDate(t *testing.T) { + k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ + Message: "use v2", + Timeline: &orktypes.DeprecationTimeline{ + From: "not-a-date", + To: "2027-01-01", + }, + }) + err := k.validateLifecycleDeprecation() + require.Error(t, err) + assert.Contains(t, err.Error(), "timeline.from") + assert.Contains(t, err.Error(), "YYYY-MM-DD") +} + +func TestValidateLifecycleDeprecation_BadToDate(t *testing.T) { + k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ + Message: "use v2", + Timeline: &orktypes.DeprecationTimeline{ + From: "2026-01-01", + To: "01/01/2027", + }, + }) + err := k.validateLifecycleDeprecation() + require.Error(t, err) + assert.Contains(t, err.Error(), "timeline.to") + assert.Contains(t, err.Error(), "YYYY-MM-DD") +} + +func TestValidateLifecycleDeprecation_FromAfterTo(t *testing.T) { + k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ + Message: "use v2", + Timeline: &orktypes.DeprecationTimeline{ + From: "2027-06-01", + To: "2027-01-01", + }, + }) + err := k.validateLifecycleDeprecation() + require.Error(t, err) + assert.Contains(t, err.Error(), "from") + assert.Contains(t, err.Error(), "before") + assert.Contains(t, err.Error(), "to") +} + +func TestValidateLifecycleDeprecation_FromEqualTo(t *testing.T) { + k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ + Message: "use v2", + Timeline: &orktypes.DeprecationTimeline{ + From: "2027-01-01", + To: "2027-01-01", + }, + }) + err := k.validateLifecycleDeprecation() + require.Error(t, err) + assert.Contains(t, err.Error(), "before") +} + +func TestValidateLifecycleDeprecation_TimelineFromOnly(t *testing.T) { + k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ + Message: "use v2", + Timeline: &orktypes.DeprecationTimeline{ + From: "2026-01-01", + }, + }) + assert.NoError(t, k.validateLifecycleDeprecation()) +} + +func TestValidateLifecycleDeprecation_TimelineToOnly(t *testing.T) { + k := katalogWithDeprecation(&orktypes.KatalogDeprecation{ + Message: "use v2", + Timeline: &orktypes.DeprecationTimeline{ + To: "2027-01-01", + }, + }) + assert.NoError(t, k.validateLifecycleDeprecation()) +} + +// ── kind boundary — accept ──────────────────────────────────────────────────── + +func TestValidateLifecycle_AcceptOnKatalogIsError(t *testing.T) { + k := katalogWithLifecycle(&orktypes.KatalogLifecycle{ + Accept: &orktypes.KomposerAccept{ + Patterns: []orktypes.KomposerAcceptEntry{{Name: "some-pattern"}}, + }, + }) + err := k.validateLifecycle() + require.Error(t, err) + assert.Contains(t, err.Error(), "lifecycle.accept is only valid in a Komposer") +} + +func TestValidateLifecycle_AcceptOnKomposerIsValid(t *testing.T) { + k := komposerWithLifecycle(&orktypes.KatalogLifecycle{ + Accept: &orktypes.KomposerAccept{ + Patterns: []orktypes.KomposerAcceptEntry{ + {Name: "old-operator"}, + {Name: "alpha-import", Author: "myorg"}, + }, + }, + }) + assert.NoError(t, k.validateLifecycle()) +} + +// ── accept.patterns version field ──────────────────────────────────────────── + +func TestValidateLifecycle_AcceptPatternVersionValid(t *testing.T) { + k := komposerWithLifecycle(&orktypes.KatalogLifecycle{ + Accept: &orktypes.KomposerAccept{ + Patterns: []orktypes.KomposerAcceptEntry{ + {Name: "webapp-operator", Version: "=1.0.0"}, + {Name: "cache-operator", Version: ">=0.1.0, <1.0.0"}, + }, + }, + }) + assert.NoError(t, k.validateLifecycle()) +} + +func TestValidateLifecycle_AcceptPatternVersionInvalid(t *testing.T) { + k := komposerWithLifecycle(&orktypes.KatalogLifecycle{ + Accept: &orktypes.KomposerAccept{ + Patterns: []orktypes.KomposerAcceptEntry{ + {Name: "webapp-operator", Version: "!!!invalid"}, + }, + }, + }) + err := k.validateLifecycle() + require.Error(t, err) + assert.Contains(t, err.Error(), "lifecycle.accept.patterns[\"webapp-operator\"].version") +} + +func TestValidateLifecycle_AcceptPatternNoVersionIsValid(t *testing.T) { + k := komposerWithLifecycle(&orktypes.KatalogLifecycle{ + Accept: &orktypes.KomposerAccept{ + Patterns: []orktypes.KomposerAcceptEntry{ + {Name: "webapp-operator"}, // no version = accept all versions + }, + }, + }) + assert.NoError(t, k.validateLifecycle()) +} + +// ── KomposerAccept.Accepts helper ───────────────────────────────────────────── + +func TestKomposerAccept_Accepts(t *testing.T) { + a := &orktypes.KomposerAccept{ + Patterns: []orktypes.KomposerAcceptEntry{ + {Name: "old-operator"}, + {Name: "alpha-lib", Author: "myorg"}, + }, + } + + assert.True(t, a.Accepts("old-operator", "")) + assert.True(t, a.Accepts("old-operator", "anyorg")) // no author filter when entry has no author + assert.True(t, a.Accepts("alpha-lib", "myorg")) + assert.False(t, a.Accepts("alpha-lib", "otherorg")) // author mismatch + assert.False(t, a.Accepts("unknown-pattern", "")) + assert.False(t, (*orktypes.KomposerAccept)(nil).Accepts("any", "")) +} + +// ── policy ──────────────────────────────────────────────────────────────────── + +func TestValidatePolicy_NilPolicy(t *testing.T) { + k := katalogWithPolicy(nil) + assert.NoError(t, k.validatePolicy()) +} + +func TestValidatePolicy_ValidMinMaturity(t *testing.T) { + for _, m := range []orktypes.LifecycleMaturity{ + orktypes.MaturityAlpha, + orktypes.MaturityBeta, + orktypes.MaturityStable, + } { + k := katalogWithPolicy(&orktypes.KatalogPolicy{ + Lifecycle: &orktypes.KatalogLifecyclePolicy{MinMaturity: m}, + }) + assert.NoError(t, k.validatePolicy(), "expected no error for minMaturity: %s", m) + } +} + +func TestValidatePolicy_DeprecatedFloorIsError(t *testing.T) { + k := katalogWithPolicy(&orktypes.KatalogPolicy{ + Lifecycle: &orktypes.KatalogLifecyclePolicy{MinMaturity: orktypes.MaturityDeprecated}, + }) + err := k.validatePolicy() + require.Error(t, err) + assert.Contains(t, err.Error(), "policy.lifecycle.minMaturity") + assert.Contains(t, err.Error(), "deprecated") +} + +func TestValidatePolicy_UnknownFloorIsError(t *testing.T) { + k := katalogWithPolicy(&orktypes.KatalogPolicy{ + Lifecycle: &orktypes.KatalogLifecyclePolicy{MinMaturity: orktypes.LifecycleMaturity("production")}, + }) + err := k.validatePolicy() + require.Error(t, err) + assert.Contains(t, err.Error(), "policy.lifecycle.minMaturity") +} diff --git a/pkg/merger/file.go b/pkg/merger/file.go index f44e172df..5b5002ff6 100644 --- a/pkg/merger/file.go +++ b/pkg/merger/file.go @@ -191,6 +191,8 @@ func (m *Merger) loadKatalog(path string, doc *orktypes.KatalogFile) (map[string Metadata: doc.Metadata, } m.apiMetadata = apiMetadata + m.lifecycle = doc.Lifecycle + m.policy = doc.Policy m.security = doc.Security m.notification = doc.Notification m.providers = doc.Providers @@ -463,6 +465,12 @@ func (m *Merger) loadKomposer(path string, doc *orktypes.KatalogFile) (map[strin if doc.Publish != nil { m.publish = doc.Publish } + if doc.Lifecycle != nil { + m.lifecycle = doc.Lifecycle + } + if doc.Policy != nil { + m.policy = doc.Policy + } mergedProfiles, err := accProfiles.Merge(doc.Profiles, path) if err != nil { diff --git a/pkg/merger/merger.go b/pkg/merger/merger.go index d7d9ecdf6..a375ec093 100644 --- a/pkg/merger/merger.go +++ b/pkg/merger/merger.go @@ -66,6 +66,12 @@ type Merger struct { // notes holds the Katalog-level user-defined note registry. notes orktypes.NoteRegistry + // lifecycle holds the lifecycle policy of the final katalog. + lifecycle *orktypes.KatalogLifecycle + + // policy holds the platform policy block of the final katalog. + policy *orktypes.KatalogPolicy + // projects holds the merged projectInfo configuration of the final katalog projects map[string]interface{} @@ -409,6 +415,19 @@ func (m *Merger) ToNotes() orktypes.NoteRegistry { return m.notes } +// ToLifecycle returns the lifecycle policy of the merged result. +// Used by KomposeRuntimeKatalog to populate Katalog.lifecycle. +func (m *Merger) ToLifecycle() *orktypes.KatalogLifecycle { + m.mustBeMerged() + return m.lifecycle +} + +// ToPolicy returns the platform policy block of the merged result. +func (m *Merger) ToPolicy() *orktypes.KatalogPolicy { + m.mustBeMerged() + return m.policy +} + // ToProjectInfo returns merged project information of the merged result // This is used by KomposeRuntimeKatalog to populate Katalog.ProjectInfo. func (m *Merger) ToProjectInfo() interface{} { diff --git a/pkg/merger/registry.go b/pkg/merger/registry.go index f52509d26..f9fbbb4d5 100644 --- a/pkg/merger/registry.go +++ b/pkg/merger/registry.go @@ -105,15 +105,17 @@ func (m *Merger) loadRegistrySource(src orktypes.RegistrySource) (map[string]ork ) } - if dep := doc.Metadata.Deprecation; dep != nil { - msg := fmt.Sprintf("warning: registry pattern %q@%s is deprecated", cleanURL, version) - if dep.MigratedTo != "" { - msg += fmt.Sprintf(" — migrate to: %s", dep.MigratedTo) - } - if dep.Message != "" { - msg += fmt.Sprintf(" (%s)", dep.Message) + if lc := doc.Lifecycle; lc != nil { + if dep := lc.Deprecation; dep != nil { + msg := fmt.Sprintf("warning: registry pattern %q@%s is deprecated", cleanURL, version) + if dep.MigratedTo != "" { + msg += fmt.Sprintf(" — migrate to: %s", dep.MigratedTo) + } + if dep.Message != "" { + msg += fmt.Sprintf(" (%s)", dep.Message) + } + fmt.Fprintln(os.Stderr, msg) } - fmt.Fprintln(os.Stderr, msg) } registryRef := fmt.Sprintf("%s@%s", cleanURL, version) diff --git a/pkg/note/helper.go b/pkg/note/helper.go index 135b4c111..6688f7551 100644 --- a/pkg/note/helper.go +++ b/pkg/note/helper.go @@ -6,4 +6,12 @@ var ( expandCronMacro = utils.ExpandCronMacro validateCronField = utils.ValidateCronField isValidCronExpr = utils.IsValidCronExpr + + semverValid = utils.SemverValid + semverMajor = utils.SemverMajor + semverMinor = utils.SemverMinor + semverPatch = utils.SemverPatch + semverCompare = utils.SemverCompare + semverCheck = utils.SemverCheck + semverBump = utils.SemverBump ) diff --git a/pkg/note/semver.go b/pkg/note/semver.go index 3234e1094..17ba23e4d 100644 --- a/pkg/note/semver.go +++ b/pkg/note/semver.go @@ -2,10 +2,7 @@ package note import ( "fmt" - "strings" "text/template" - - "github.com/Masterminds/semver/v3" ) func semverNotes() template.FuncMap { @@ -20,112 +17,48 @@ func semverNotes() template.FuncMap { } } -// noteSemverMajor returns the major version component of a semver string. -// Returns "" for invalid input. -// -// {{ semverMajor "1.2.3" }} → "1" -// {{ semverMajor "v2.0.0" }} → "2" func noteSemverMajor(v string) string { - sv, err := semver.NewVersion(v) + n, err := semverMajor(v) if err != nil { return "" } - return fmt.Sprintf("%d", sv.Major()) + return fmt.Sprintf("%d", n) } -// noteSemverMinor returns the minor version component. -// -// {{ semverMinor "1.2.3" }} → "2" func noteSemverMinor(v string) string { - sv, err := semver.NewVersion(v) + n, err := semverMinor(v) if err != nil { return "" } - return fmt.Sprintf("%d", sv.Minor()) + return fmt.Sprintf("%d", n) } -// noteSemverPatch returns the patch version component. -// -// {{ semverPatch "1.2.3" }} → "3" func noteSemverPatch(v string) string { - sv, err := semver.NewVersion(v) + n, err := semverPatch(v) if err != nil { return "" } - return fmt.Sprintf("%d", sv.Patch()) + return fmt.Sprintf("%d", n) } -// noteSemverValid reports whether the string is a valid semver version. -// -// {{ semverValid "1.2.3" }} → true -// {{ semverValid "latest" }} → false func noteSemverValid(v string) bool { - _, err := semver.NewVersion(v) - return err == nil + return semverValid(v) } -// noteSemverCompare compares two semver strings. -// Returns -1 when a < b, 0 when a == b, 1 when a > b. -// Returns 0 for invalid input (safe zero value). -// -// {{ semverCompare "1.2.3" "1.3.0" }} → -1 -// {{ semverCompare "2.0.0" "1.9.9" }} → 1 func noteSemverCompare(a, b string) int { - sa, err := semver.NewVersion(a) - if err != nil { - return 0 - } - sb, err := semver.NewVersion(b) - if err != nil { - return 0 - } - return sa.Compare(sb) + n, _ := semverCompare(a, b) + return n } -// The issue is that semver.IncPatch() does not increment the patch number -// when there is a prerelease; it only drops the prerelease. -// To conform to industry standard (bump patch from 1.2.3-alpha to 1.2.4), -// we need to manually increment the component and discard any prerelease/metadata. func noteSemverBump(v, component string) string { - // Strip leading 'v' if present - strippedV := strings.TrimPrefix(v, "v") - sv, err := semver.NewVersion(strippedV) + result, err := semverBump(v, component) if err != nil { return v } - major, minor, patch := sv.Major(), sv.Minor(), sv.Patch() - switch strings.ToLower(component) { - case "major": - major++ - minor = 0 - patch = 0 - case "minor": - minor++ - patch = 0 - case "patch": - patch++ - default: - return v - } - // Create a new version without prerelease or metadata - newVer := semver.New(major, minor, patch, "", "") - return newVer.String() + return result } -// noteSemverConstraint reports whether version satisfies the constraint expression. -// Uses Masterminds semver constraint syntax: ">=1.0.0", "^2.0", "~1.2", "1.x". -// Returns false for invalid input (safe zero value). -// -// {{ semverConstraint "1.2.3" ">=1.0.0,<2.0.0" }} → true -// {{ semverConstraint "2.1.0" "^1.0" }} → false func noteSemverConstraint(v, constraint string) bool { - sv, err := semver.NewVersion(v) - if err != nil { - return false - } - c, err := semver.NewConstraint(constraint) - if err != nil { - return false - } - return c.Check(sv) + ok, _ := semverCheck(v, constraint) + return ok } diff --git a/pkg/registry/meta_test.go b/pkg/registry/meta_test.go index 806f98e95..809102e84 100644 --- a/pkg/registry/meta_test.go +++ b/pkg/registry/meta_test.go @@ -139,7 +139,7 @@ func TestLoadPatternMeta_Fixture(t *testing.T) { t.Errorf("Name = %q; want registry-pack-probe", meta.Name) } if meta.Deprecated == nil { - t.Fatal("Deprecated is nil — fixture has metadata.deprecation") + t.Fatal("Deprecated is nil — fixture has lifecycle.deprecation") } if meta.Deprecated.MigratedTo == "" { t.Errorf("MigratedTo is empty") @@ -159,6 +159,7 @@ metadata: name: old-redis version: v6 description: "Legacy Redis operator" +lifecycle: deprecation: migratedTo: ghcr.io/test/patterns/redis:v7 message: "Upgrade to v7 — adds TLS support" diff --git a/pkg/registry/pattern.go b/pkg/registry/pattern.go index cb50f4732..dd272b4c0 100644 --- a/pkg/registry/pattern.go +++ b/pkg/registry/pattern.go @@ -108,14 +108,14 @@ func LoadPatternMeta(dir string, spec *PatternSpec) (*PatternMeta, error) { var raw struct { Kind string `yaml:"kind"` Metadata struct { - Name string `yaml:"name"` - Version string `yaml:"version"` - Description string `yaml:"description"` - Author string `yaml:"author"` - License string `yaml:"license"` - Tags []string `yaml:"tags"` - Deprecation *orktypes.KatalogDeprecation `yaml:"deprecation"` + Name string `yaml:"name"` + Version string `yaml:"version"` + Description string `yaml:"description"` + Author string `yaml:"author"` + License string `yaml:"license"` + Tags []string `yaml:"tags"` } `yaml:"metadata"` + Lifecycle *orktypes.KatalogLifecycle `yaml:"lifecycle"` } if err := yaml.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("parsing %s: %w", spec.PrimaryFile, err) @@ -132,12 +132,14 @@ func LoadPatternMeta(dir string, spec *PatternSpec) (*PatternMeta, error) { License: raw.Metadata.License, Tags: raw.Metadata.Tags, } - if d := raw.Metadata.Deprecation; d != nil { - meta.Deprecated = &PatternDeprecated{ - MigratedTo: d.MigratedTo, - Message: d.Message, - TimelineFrom: d.TimelineFrom(), - TimelineTo: d.TimelineTo(), + if raw.Lifecycle != nil { + if d := raw.Lifecycle.Deprecation; d != nil { + meta.Deprecated = &PatternDeprecated{ + MigratedTo: d.MigratedTo, + Message: d.Message, + TimelineFrom: d.TimelineFrom(), + TimelineTo: d.TimelineTo(), + } } } if meta.Version == "" { diff --git a/pkg/registry/simulate/helper.go b/pkg/registry/simulate/helper.go index 9b1cc5259..111aa154b 100644 --- a/pkg/registry/simulate/helper.go +++ b/pkg/registry/simulate/helper.go @@ -7,9 +7,9 @@ import ( "strings" "time" + orktarget "github.com/orkspace/orkestra/pkg/intent/target" orklabels "github.com/orkspace/orkestra/pkg/labels" orktypes "github.com/orkspace/orkestra/pkg/types" - orktarget "github.com/orkspace/orkestra/pkg/intent/target" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/client-go/tools/cache" ) diff --git a/pkg/registry/testdata/katalog.yaml b/pkg/registry/testdata/katalog.yaml index 609e01b62..49e178202 100644 --- a/pkg/registry/testdata/katalog.yaml +++ b/pkg/registry/testdata/katalog.yaml @@ -4,6 +4,7 @@ metadata: name: registry-pack-probe version: v0.1.0 description: Minimal fixture for typed annotations and deprecation detection +lifecycle: deprecation: migratedTo: ghcr.io/orkspace/orkestra-registry/registry-pack-probe:v0.2.0 message: "Upgrade to v0.2.0" diff --git a/pkg/runtime/kordinator/dependency_kordinator.go b/pkg/runtime/kordinator/dependency_kordinator.go index 6aba598cc..b1b5c3cbf 100644 --- a/pkg/runtime/kordinator/dependency_kordinator.go +++ b/pkg/runtime/kordinator/dependency_kordinator.go @@ -502,7 +502,7 @@ func (k *DependencyKordinator) startCRDWorkers(ctx context.Context, gvk string, crdCtx, cancel := context.WithCancel(ctx) wg := &sync.WaitGroup{} - // + // rec := entry.ReconcilerFactory() // Inject the per-CRD workqueue so SetQueueDepthLimit and the resync goroutine diff --git a/pkg/runtime/reconciler/generic.go b/pkg/runtime/reconciler/generic.go index 51e97aa72..292cfa865 100644 --- a/pkg/runtime/reconciler/generic.go +++ b/pkg/runtime/reconciler/generic.go @@ -258,7 +258,6 @@ func NewGenericReconciler[PTR domain.Object]( var _ domain.Reconciler = (*GenericReconciler[domain.Object])(nil) - // Reconcile dispatches to the correct reconcile implementation. // Order: // 1. Conditional provisioning (when blocks) — handled by runTemplateReconcile diff --git a/pkg/runtime/reconciler/generic_target.go b/pkg/runtime/reconciler/generic_target.go index 3bdab6518..ad632c62a 100644 --- a/pkg/runtime/reconciler/generic_target.go +++ b/pkg/runtime/reconciler/generic_target.go @@ -6,9 +6,9 @@ import ( "slices" "github.com/orkspace/orkestra/domain" + orktarget "github.com/orkspace/orkestra/pkg/intent/target" "github.com/orkspace/orkestra/pkg/kubeclient" "github.com/orkspace/orkestra/pkg/labels" - orktarget "github.com/orkspace/orkestra/pkg/intent/target" orktypes "github.com/orkspace/orkestra/pkg/types" ) diff --git a/pkg/runtime/reconciler/run_surface_cleanup.go b/pkg/runtime/reconciler/run_surface_cleanup.go index 3e08bd0c1..54a2112d2 100644 --- a/pkg/runtime/reconciler/run_surface_cleanup.go +++ b/pkg/runtime/reconciler/run_surface_cleanup.go @@ -3,10 +3,10 @@ package reconciler import ( "context" + orktarget "github.com/orkspace/orkestra/pkg/intent/target" "github.com/orkspace/orkestra/pkg/labels" "github.com/orkspace/orkestra/pkg/logger" "github.com/orkspace/orkestra/pkg/runtime/runners" - orktarget "github.com/orkspace/orkestra/pkg/intent/target" ) // cleanupPreviousSurface deletes all resources belonging to the surface the CR diff --git a/pkg/types/katalog.go b/pkg/types/katalog.go index 188012bc2..f5334d7a4 100644 --- a/pkg/types/katalog.go +++ b/pkg/types/katalog.go @@ -384,16 +384,35 @@ func (a APIAuth) IsEmpty() bool { return len(a.Tokens) == 0 } +// KatalogLifecyclePolicy holds lifecycle-related enforcement rules within a policy: block. +type KatalogLifecyclePolicy struct { + // MinMaturity sets the minimum lifecycle maturity allowed for imported patterns. + // Imports below this floor are errors at ork validate time rather than warnings. + // Valid values: alpha, beta, stable (deprecated imports are always errors without accept). + MinMaturity LifecycleMaturity `yaml:"minMaturity,omitempty" json:"minMaturity,omitempty"` +} + +// KatalogPolicy declares platform-level enforcement rules for a Komposer. +// Policy is distinct from lifecycle: — it is a platform-tier concern that +// governs what imports are allowed, not what the pattern itself signals. +// Structured as policy..* so new policy categories (security, registry, +// user-defined) can grow alongside lifecycle without flattening into one block. +type KatalogPolicy struct { + Lifecycle *KatalogLifecyclePolicy `yaml:"lifecycle,omitempty" json:"lifecycle,omitempty"` +} + // KatalogFile is the top-level structure of a katalog.yaml file. // It contains optional imports (files and helm charts) plus inline CRDs. // Orkestra's in-built merger resolves all imports and merges everything into one KatalogSpec. type KatalogFile struct { - APIVersion string `yaml:"apiVersion"` - Kind string `yaml:"kind"` - Metadata KatalogMeta `yaml:"metadata"` - Imports *KatalogSources `yaml:"imports,omitempty"` - Spec KatalogSpec `yaml:"spec"` - Security KatalogSecurity `yaml:"security"` + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata KatalogMeta `yaml:"metadata"` + Lifecycle *KatalogLifecycle `yaml:"lifecycle,omitempty" json:"lifecycle,omitempty"` + Policy *KatalogPolicy `yaml:"policy,omitempty" json:"policy,omitempty"` + Imports *KatalogSources `yaml:"imports,omitempty"` + Spec KatalogSpec `yaml:"spec"` + Security KatalogSecurity `yaml:"security"` // CrossAccess sets the default cross-read policy for all CRDs in this Katalog. // When false, no other Katalog may read any CRD in this one via cross:. @@ -496,10 +515,6 @@ type KatalogMeta struct { // time. The operator and runtime ignore this field — it is purely for // persona-aware tooling and Control Center UI. Projects map[string]interface{} `yaml:"projects,omitempty" json:"projects,omitempty"` - - // Deprecation marks this pattern as deprecated. When set, consumers - // (ork validate, ork inspect, ork patterns) display a warning. - Deprecation *KatalogDeprecation `yaml:"deprecation,omitempty" json:"deprecation,omitempty"` } // DeprecationTimeline sets the date window for deprecation display. @@ -509,21 +524,78 @@ type DeprecationTimeline struct { To string `yaml:"to,omitempty" json:"to,omitempty"` // EOL on this date } -// DeprecationAccept records explicit operator acknowledgement that a deprecated -// or EOL katalog is intentionally kept running. -type DeprecationAccept struct { - // BeforeEol allows ork run / ork gate to start while the pattern is in the - // deprecation warning window (from ≤ today < to, or no timeline). - BeforeEol bool `yaml:"beforeEol,omitempty" json:"beforeEol,omitempty"` - // Eol allows ork run / ork gate to start after the pattern has passed its - // end-of-life date (today ≥ to). Requires BeforeEol to also be true. - Eol bool `yaml:"eol,omitempty" json:"eol,omitempty"` +// LifecycleMaturity signals the stability level of a Katalog pattern. +type LifecycleMaturity string + +const ( + MaturityAlpha LifecycleMaturity = "alpha" + MaturityBeta LifecycleMaturity = "beta" + MaturityStable LifecycleMaturity = "stable" + MaturityDeprecated LifecycleMaturity = "deprecated" +) + +// LifecycleCompat declares which Kubernetes and Orkestra versions this pattern +// has been verified against. Both fields accept Masterminds semver range syntax. +type LifecycleCompat struct { + Kubernetes string `yaml:"kubernetes,omitempty" json:"kubernetes,omitempty"` + Orkestra string `yaml:"orkestra,omitempty" json:"orkestra,omitempty"` +} + +// KomposerAcceptEntry acknowledges the lifecycle state of a single imported pattern. +// Naming a pattern here accepts any deprecation or pre-stable maturity concern for +// that import. Version, when set, scopes the acceptance to a semver range — ork +// validate warns when the imported version no longer matches (stale acceptance). +type KomposerAcceptEntry struct { + Name string `yaml:"name"` + Author string `yaml:"author,omitempty" json:"author,omitempty"` + Version string `yaml:"version,omitempty" json:"version,omitempty"` // semver range; omit = all versions +} + +// KomposerAccept is valid only on a Komposer. It declares which imported patterns +// the Komposer author has evaluated and accepted, regardless of their lifecycle state. +type KomposerAccept struct { + Patterns []KomposerAcceptEntry `yaml:"patterns,omitempty" json:"patterns,omitempty"` +} + +// Accepts reports whether the given pattern name (and optional author) is covered. +func (a *KomposerAccept) Accepts(name, author string) bool { + if a == nil { + return false + } + for _, e := range a.Patterns { + if e.Name != name { + continue + } + if author != "" && e.Author != "" && e.Author != author { + continue + } + return true + } + return false +} + +// KatalogLifecycle is the top-level policy block for a Katalog. It governs +// maturity signals, deprecation, and compatibility gates. The runtime ignores +// this field — it is read only by tooling (ork validate, ork push, ork inspect). +type KatalogLifecycle struct { + Maturity LifecycleMaturity `yaml:"maturity,omitempty" json:"maturity,omitempty"` + Deprecation *KatalogDeprecation `yaml:"deprecation,omitempty" json:"deprecation,omitempty"` + Compatibility *LifecycleCompat `yaml:"compatibility,omitempty" json:"compatibility,omitempty"` + // Accept is only valid on a Komposer. It acknowledges lifecycle concerns of imported patterns. + Accept *KomposerAccept `yaml:"accept,omitempty" json:"accept,omitempty"` +} + +// IsDeprecated reports whether the lifecycle block declares the pattern deprecated. +func (l *KatalogLifecycle) IsDeprecated() bool { + if l == nil { + return false + } + return l.Maturity == MaturityDeprecated || (l.Deprecation != nil && l.Deprecation.IsDeprecated()) } // KatalogDeprecation carries deprecation guidance for registry consumers. type KatalogDeprecation struct { Timeline *DeprecationTimeline `yaml:"timeline,omitempty" json:"timeline,omitempty"` - Accept *DeprecationAccept `yaml:"accept,omitempty" json:"accept,omitempty"` MigratedTo string `yaml:"migratedTo,omitempty" json:"migratedTo,omitempty"` Message string `yaml:"message,omitempty" json:"message,omitempty"` } @@ -626,24 +698,6 @@ func (d *KatalogDeprecation) DaysUntilEOL(today time.Time) int { return days } -// AcceptsBeforeEol reports whether the operator has acknowledged running this -// pattern during the deprecation warning window. -func (d *KatalogDeprecation) AcceptsBeforeEol() bool { - if d == nil || d.Accept == nil { - return false - } - return d.Accept.BeforeEol -} - -// AcceptsEol reports whether the operator has acknowledged running this pattern -// after it has passed its end-of-life date. Both accept.beforeEol and accept.eol -// must be true. -func (d *KatalogDeprecation) AcceptsEol() bool { - if d == nil || d.Accept == nil { - return false - } - return d.Accept.BeforeEol && d.Accept.Eol -} // KatalogSources declares where to load CRD definitions from. // Sources are loaded before spec.crds — inline CRDs are merged last diff --git a/pkg/types/methods.go b/pkg/types/methods.go index 3b5927b54..8470f44ca 100644 --- a/pkg/types/methods.go +++ b/pkg/types/methods.go @@ -439,7 +439,6 @@ func (c *CRDEntry) HasTargetConstructorFactories() bool { return false } - // IsEnabledAllEndpoints reports whether the all endpoints are disabled for this CRD. // Defaults to false when omitted. func (c *CRDEntry) IsEnabledAllEndpoints() bool { diff --git a/pkg/types/types.go b/pkg/types/types.go index e50c116e2..b05ef3c79 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -178,4 +178,4 @@ func (q *Queue) IsEmpty() bool { return false } return true -} \ No newline at end of file +} diff --git a/pkg/types/types_operatorbox.go b/pkg/types/types_operatorbox.go index 8dd1b154d..5a2e84eff 100644 --- a/pkg/types/types_operatorbox.go +++ b/pkg/types/types_operatorbox.go @@ -185,59 +185,59 @@ type ReconcilerConfig struct { // IsDefault returns true when the reconciler should use the GenericReconciler. // When Default is nil (not declared), it defaults to true. func (r *ReconcilerConfig) IsDefault() bool { - if r == nil { - return true - } - if r.Default == nil { - return true - } - return *r.Default + if r == nil { + return true + } + if r.Default == nil { + return true + } + return *r.Default } // HasHooksDecl reports whether a hook declaration exists. func (r *ReconcilerConfig) HasHooksDecl() bool { - if r == nil { - return false - } - return r.Hooks != nil + if r == nil { + return false + } + return r.Hooks != nil } // HasConstructorDecl reports whether a constructor declaration exists. func (r *ReconcilerConfig) HasConstructorDecl() bool { - if r == nil { - return false - } - return r.ConstructorDecl != nil + if r == nil { + return false + } + return r.ConstructorDecl != nil } // IsEmpty reports whether the reconciler config has no meaningful settings. // Used to skip unnecessary config blocks in the Katalog. func (r *ReconcilerConfig) IsEmpty() bool { - if r == nil { - return true - } - if r.Default != nil { - return false - } - if r.Hooks != nil { - return false - } - if r.ConstructorDecl != nil { - return false - } - if r.Profile != "" { - return false - } - if r.Workers != 0 { - return false - } - if r.Resync.Duration != 0 { - return false - } - if !r.Queue.IsEmpty() { - return false - } - return true + if r == nil { + return true + } + if r.Default != nil { + return false + } + if r.Hooks != nil { + return false + } + if r.ConstructorDecl != nil { + return false + } + if r.Profile != "" { + return false + } + if r.Workers != 0 { + return false + } + if r.Resync.Duration != 0 { + return false + } + if !r.Queue.IsEmpty() { + return false + } + return true } // OperatorBoxConfig is the per-CRD configuration block in a Katalog. It controls @@ -368,7 +368,6 @@ func (box *OperatorBoxConfig) IsEmpty() bool { return box == nil } - // HookDeclaration declares where a Go hook function lives. // Read by ork generate to emit HookRegistry entries in zz_generated_runtime_registry.go. // The declared function must match the signature: func() domain.AnyReconcileHooks diff --git a/pkg/utils/semver.go b/pkg/utils/semver.go new file mode 100644 index 000000000..ff94c61c1 --- /dev/null +++ b/pkg/utils/semver.go @@ -0,0 +1,93 @@ +package utils + +import ( + "fmt" + "strings" + + "github.com/Masterminds/semver/v3" +) + +// SemverValid reports whether v is a valid semver string. +func SemverValid(v string) bool { + _, err := semver.NewVersion(v) + return err == nil +} + +// SemverMajor returns the major component of v. +func SemverMajor(v string) (uint64, error) { + sv, err := semver.NewVersion(v) + if err != nil { + return 0, fmt.Errorf("invalid semver %q: %w", v, err) + } + return sv.Major(), nil +} + +// SemverMinor returns the minor component of v. +func SemverMinor(v string) (uint64, error) { + sv, err := semver.NewVersion(v) + if err != nil { + return 0, fmt.Errorf("invalid semver %q: %w", v, err) + } + return sv.Minor(), nil +} + +// SemverPatch returns the patch component of v. +func SemverPatch(v string) (uint64, error) { + sv, err := semver.NewVersion(v) + if err != nil { + return 0, fmt.Errorf("invalid semver %q: %w", v, err) + } + return sv.Patch(), nil +} + +// SemverCompare compares two semver strings. +// Returns -1 when a < b, 0 when a == b, 1 when a > b. +func SemverCompare(a, b string) (int, error) { + sa, err := semver.NewVersion(a) + if err != nil { + return 0, fmt.Errorf("invalid semver %q: %w", a, err) + } + sb, err := semver.NewVersion(b) + if err != nil { + return 0, fmt.Errorf("invalid semver %q: %w", b, err) + } + return sa.Compare(sb), nil +} + +// SemverCheck reports whether version v satisfies the constraint expression. +// Uses Masterminds semver constraint syntax: ">=1.0.0", "^2.0", "~1.2", "1.x". +func SemverCheck(v, constraint string) (bool, error) { + sv, err := semver.NewVersion(v) + if err != nil { + return false, fmt.Errorf("invalid semver %q: %w", v, err) + } + c, err := semver.NewConstraint(constraint) + if err != nil { + return false, fmt.Errorf("invalid semver constraint %q: %w", constraint, err) + } + return c.Check(sv), nil +} + +// SemverBump increments the given component ("major", "minor", or "patch") of v, +// dropping any prerelease or build metadata from the result. +func SemverBump(v, component string) (string, error) { + sv, err := semver.NewVersion(strings.TrimPrefix(v, "v")) + if err != nil { + return "", fmt.Errorf("invalid semver %q: %w", v, err) + } + major, minor, patch := sv.Major(), sv.Minor(), sv.Patch() + switch strings.ToLower(component) { + case "major": + major++ + minor = 0 + patch = 0 + case "minor": + minor++ + patch = 0 + case "patch": + patch++ + default: + return "", fmt.Errorf("unknown semver component %q: must be major, minor, or patch", component) + } + return semver.New(major, minor, patch, "", "").String(), nil +} diff --git a/pkg/utils/semver_test.go b/pkg/utils/semver_test.go new file mode 100644 index 000000000..dd5b971b9 --- /dev/null +++ b/pkg/utils/semver_test.go @@ -0,0 +1,130 @@ +package utils + +import ( + "testing" +) + +func TestSemverValid(t *testing.T) { + tests := []struct { + v string + want bool + }{ + {"1.2.3", true}, + {"v2.0.0", true}, + {"1.2.3-alpha.1", true}, + {"1.2.3+build", true}, + {"latest", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.v, func(t *testing.T) { + if got := SemverValid(tt.v); got != tt.want { + t.Errorf("SemverValid(%q) = %v, want %v", tt.v, got, tt.want) + } + }) + } +} + +func TestSemverMajorMinorPatch(t *testing.T) { + major, err := SemverMajor("1.2.3") + if err != nil || major != 1 { + t.Errorf("SemverMajor: got %d, %v", major, err) + } + minor, err := SemverMinor("1.2.3") + if err != nil || minor != 2 { + t.Errorf("SemverMinor: got %d, %v", minor, err) + } + patch, err := SemverPatch("1.2.3") + if err != nil || patch != 3 { + t.Errorf("SemverPatch: got %d, %v", patch, err) + } + if _, err := SemverMajor("invalid"); err == nil { + t.Error("SemverMajor: expected error for invalid input") + } +} + +func TestSemverCompare(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"1.2.3", "1.3.0", -1}, + {"1.2.3", "1.2.3", 0}, + {"2.0.0", "1.9.9", 1}, + {"v1.2.3", "1.2.3", 0}, + } + for _, tt := range tests { + t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) { + got, err := SemverCompare(tt.a, tt.b) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("SemverCompare(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } + if _, err := SemverCompare("invalid", "1.0.0"); err == nil { + t.Error("SemverCompare: expected error for invalid input") + } +} + +func TestSemverCheck(t *testing.T) { + tests := []struct { + v, constraint string + want bool + }{ + {"1.2.3", ">=1.0.0", true}, + {"1.2.3", ">=1.0.0,<2.0.0", true}, + {"2.1.0", "^1.0", false}, + {"1.2.3", "~1.2", true}, + {"1.31.0", ">=1.31", true}, + {"1.30.0", ">=1.31", false}, + } + for _, tt := range tests { + t.Run(tt.constraint, func(t *testing.T) { + got, err := SemverCheck(tt.v, tt.constraint) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("SemverCheck(%q, %q) = %v, want %v", tt.v, tt.constraint, got, tt.want) + } + }) + } + if _, err := SemverCheck("invalid", ">=1.0.0"); err == nil { + t.Error("SemverCheck: expected error for invalid version") + } + if _, err := SemverCheck("1.0.0", "!!"); err == nil { + t.Error("SemverCheck: expected error for invalid constraint") + } +} + +func TestSemverBump(t *testing.T) { + tests := []struct { + v, component, want string + }{ + {"1.2.3", "patch", "1.2.4"}, + {"1.2.3", "minor", "1.3.0"}, + {"1.2.3", "major", "2.0.0"}, + {"v1.2.3", "patch", "1.2.4"}, + {"1.2.3-alpha", "patch", "1.2.4"}, + } + for _, tt := range tests { + t.Run(tt.v+"_"+tt.component, func(t *testing.T) { + got, err := SemverBump(tt.v, tt.component) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("SemverBump(%q, %q) = %q, want %q", tt.v, tt.component, got, tt.want) + } + }) + } + if _, err := SemverBump("invalid", "patch"); err == nil { + t.Error("SemverBump: expected error for invalid version") + } + if _, err := SemverBump("1.2.3", "unknown"); err == nil { + t.Error("SemverBump: expected error for unknown component") + } +}