From aca15c2803b380af12f1f9d31a73270ec2312365 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 10:41:54 -0500 Subject: [PATCH 1/9] Prepare for first release: rewrite README, move API group Rewrite the README for people meeting Weft for the first time: what it is, a runnable quick start, a feature summary, install and verification, and a documentation index. The behavioural detail and design rationale the old README carried move into docs/operating.md (status conditions, pruning, teardown, flags, metrics, uninstall) and docs/design.md (comparison with other tools, why Starlark, non-goals), so nothing is lost. Install instructions in the README, chart README and demo no longer assume there is no stable release: plain `helm install` resolves the newest release, with --devel described for prereleases. Move the API group from weft.run to weft.alethic.solutions before anything is released under the old one. This changes the CRD name (weaves.weft.alethic.solutions), the apiVersion, and every annotation, label and finalizer key Weft writes. The group is still defined once in internal/naming; the rest is generated output, markers, docs and examples. --- Makefile | 4 +- README.md | 451 ++++++------------ api/v1alpha1/groupversion_info.go | 2 +- api/v1alpha1/weave_types.go | 2 +- charts/weft/Chart.yaml | 4 +- charts/weft/README.md | 16 +- charts/weft/files/weaves-crd.yaml | 6 +- charts/weft/templates/rbac.yaml | 6 +- charts/weft/values.yaml | 2 +- cmd/weft/chart_test.go | 4 +- ...aml => weft.alethic.solutions_weaves.yaml} | 6 +- config/rbac/role.yaml | 6 +- demo/01-app-stack.yaml | 2 +- demo/02-waiting.yaml | 2 +- demo/03-forbidden.yaml | 2 +- demo/04-teardown.yaml | 4 +- demo/05-pruning.yaml | 2 +- demo/06-finalizer.yaml | 2 +- demo/07-bounds.yaml | 16 +- demo/README.md | 4 +- docs/design.md | 84 ++++ docs/language.md | 12 +- docs/operating.md | 143 ++++++ docs/ownership.md | 24 +- docs/rbac.md | 4 +- examples/app.yaml | 2 +- examples/env.yaml | 2 +- examples/release-values.yaml | 2 +- examples/sql.yaml | 2 +- internal/controller/owned_test.go | 6 +- internal/controller/reclaim_test.go | 2 +- internal/controller/release.go | 2 +- internal/controller/weave_controller.go | 6 +- internal/inventory/inventory.go | 2 +- internal/naming/naming.go | 6 +- 35 files changed, 454 insertions(+), 388 deletions(-) rename config/crd/{weft.run_weaves.yaml => weft.alethic.solutions_weaves.yaml} (99%) create mode 100644 docs/design.md create mode 100644 docs/operating.md diff --git a/Makefile b/Makefile index 0cbde04..4fbf0f3 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ generate: ## Regenerate deepcopy, the CRD and the controller ClusterRole. $(CONTROLLER_GEN) rbac:roleName=weft-controller paths=./internal/controller/... output:rbac:artifacts:config=config/rbac # The chart ships the generated CRD verbatim so the two cannot drift. # TestChartCRDMatchesGenerated fails if this copy is skipped. - cp config/crd/weft.run_weaves.yaml $(CHART)/files/weaves-crd.yaml + cp config/crd/weft.alethic.solutions_weaves.yaml $(CHART)/files/weaves-crd.yaml .PHONY: fmt fmt: @@ -77,7 +77,7 @@ lint-chart: ## Lint and render the chart. .PHONY: install install: ## Install just the CRD. - kubectl apply -f config/crd/weft.run_weaves.yaml + kubectl apply -f config/crd/weft.alethic.solutions_weaves.yaml .PHONY: deploy deploy: ## Install the controller with Helm. diff --git a/README.md b/README.md index 6fd8a4b..eb14a72 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,55 @@ -# Weft +

+ +

-Weft is a Kubernetes operator for the job you currently do with a CronJob that -runs `kubectl get | template | kubectl apply`. +

Weft

-You write one namespaced custom resource, a `Weave`. It reads resources that -already exist in your namespace, runs a small program over them, and applies -whatever that program returns — reactively, whenever anything it read changes, -with ownership and garbage collection handled for you. +

+ Reactive, namespace-scoped resource composition for Kubernetes — running as a ServiceAccount you choose. +

-Two things make it different from the other tools in this space. You do not need -a platform team to install anything for you: a `Weave` is namespaced and there -is no second, cluster-scoped object to register. And every read and every write -runs as a ServiceAccount **you** name, so a `Weave` can never create anything -you could not have created by hand. +

+ CI + Release + License +

-The name comes from weaving. The **warp** is the threads already on the loom — -resources you read but do not own. The **weft** is what you pass across them. +--- -## What one looks like +Weft replaces the CronJob that runs `kubectl get | template | kubectl apply`. + +You write one namespaced custom resource, a **`Weave`**. It reads resources that +already exist in its namespace, runs a small [Starlark](https://github.com/bazelbuild/starlark) +program over them, and applies whatever the program declares. When anything it +read changes, it runs again. What it created is owned, kept current, pruned when +no longer declared, and torn down in order when the `Weave` is deleted. + +Two properties set it apart: + +- **No platform team required.** A `Weave` is namespaced and self-contained. + There is no cluster-scoped definition to register, and no CRD generated per + composition. Anyone with `edit` in a namespace can write one. +- **It can never exceed its author's permissions.** Every read and every write + is impersonated as a ServiceAccount named in the `Weave`. The controller holds + no write access of its own to anything a program generates, so a `Weave` + cannot create or read anything that ServiceAccount could not. + +## At a glance ```yaml -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: app namespace: my-namespace spec: - # Every read and write below happens as this ServiceAccount. - serviceAccountName: composer + serviceAccountName: composer # every read and write happens as this identity - # Optional. Configuration, merged in order, later entries winning. - variables: + variables: # optional configuration, merged in order - configMap: - name: platform # a base somebody else maintains + name: platform # a shared base somebody else maintains - values: - prefix: demo # overrides for this Weave + prefix: demo # overrides for this Weave program: | def compose(variable): @@ -56,319 +71,143 @@ NAME READY WAITING AGE app True Applied 13s ``` -Create it, and `demo-settings` appears. Edit the `tenant` ConfigMap, and it -updates. Delete the `Weave`, and it goes away. - -If `composer` cannot read the `tenant` ConfigMap or write the one being -generated, nothing is created and the `Weave` tells you exactly which -RoleBinding is missing, in a command you can paste. +Create it and `demo-settings` appears. Edit the `tenant` ConfigMap and it +updates. Delete the `Weave` and it goes away. + +If `composer` is not allowed to read `tenant` or write `demo-settings`, nothing +is created, and the `Weave`'s status contains the exact `kubectl` commands that +would grant what is missing. + +## Features + +- **Reactive.** Watches are derived from what the program actually read, so + there is nothing to declare and nothing to keep in step. +- **Waits are first-class.** A program can be *not ready yet* — blocked on a + resource that does not exist, or on a status field another controller has not + filled in. That is reported as `Waiting`, distinct from `Ready` and + `Degraded`, and resolves itself when the field appears. +- **Staged rollout.** A program can see what it previously created, read back + live, and declare only what it can so far — the natural shape for + infrastructure that provisions in steps. +- **Safe ownership.** Weft refuses to take over an object it did not create. + Handing one over takes an annotation on that object. Individual resources can + opt out of ownership so they outlive the `Weave`. +- **Ordered teardown.** Declaration order is apply order, and teardown runs in + reverse. `needs=` lets independent siblings be deleted in parallel. +- **Cautious pruning.** A resource that disappears from the program is deleted + only after it has stayed gone for a configurable delay, so a provider restart + does not churn real infrastructure. +- **Bounded evaluation.** Programs are untrusted by design: no I/O beyond + `read()` and `select()`, no recursion, no clock, and step, size and read + budgets. Exceeding one fails that `Weave`, never the controller. +- **Provider-agnostic.** Weft composes whatever CRDs are installed — + Crossplane managed resources, cert-manager, your own — and knows nothing + about any of them. ## Install -No stable release is cut yet, so install the latest prerelease: +Requires Kubernetes 1.27 or later and Helm 3.8 or later. ```bash -helm install weft oci://ghcr.io/alethic/charts/weft --devel \ +helm install weft oci://ghcr.io/alethic/charts/weft \ --namespace weft-system --create-namespace ``` -Pin `--version 0.1.0-pre.23` (or whichever) for anything you care about; once -`1.0` exists, pin that instead. The chart and the controller image are published -to GitHub Packages on every build of `main`, both signed with cosign. - -The chart is the supported install path and is documented in -[charts/weft/README.md](charts/weft/README.md). It ships sensible defaults, a -values schema that rejects malformed input at install time, and refuses to -render a handful of configurations that would only fail later. - -Then, as an ordinary namespace user with `edit`, apply a `Weave`. There is no -second step and nothing for an administrator to register. - -## Why you might want it - -| | namespaced authoring | impersonated writes | status-reactive | -|---|---|---|---| -| **Weft** | **yes** | **yes** | **yes** | -| [kro](https://kro.run) | no — `ResourceGraphDefinition` is cluster-scoped and generates a CRD | no | yes | -| [Crossplane v2](https://crossplane.io) | XRs are namespaced, but XRDs and Compositions are cluster-scoped admin artifacts | no — the RBAC manager runs with `escalate` | yes | -| Metacontroller, Yoke ATC | no | no | yes | -| [Flux](https://fluxcd.io) | yes | **yes** — this is where the model comes from | no — `dependsOn` between whole Kustomizations, and `substituteFrom` limited to ConfigMaps and Secrets | -| Kyverno `generate` | yes | no — the background controller's own ServiceAccount does the creating, checked once at policy admission | yes | - -If you already have Crossplane or a provider whose resources report status -asynchronously, the third column is the one that will matter to you day to day. - -## How it behaves - -### Three outcomes, not two - -Resolution can succeed, fail permanently, or **not yet**. "Not yet" is the -normal case, not an edge case, and it has a condition of its own: - -```console -$ kubectl get weave -NAME READY WAITING AGE -app False FieldUnresolved 7s - -$ kubectl get weave app -o jsonpath='{.status.conditions[?(@.type=="Waiting")].message}' -status.atProvider.principalId is not set on UserAssignedIdentity/demo-app -``` - -Existence does not unblock. **Field resolution** does. Applying a Crossplane -managed resource returns instantly, but its `status.atProvider` populates -minutes later — so a resource you created behaves exactly like an external one -that is not ready, and one rule covers both. +Pin a version with `--version` for anything you care about. Builds of `main` +are also published as prereleases; add `--devel` to install the newest one. -Alerting on `Degraded` is useful. Alerting on `Waiting` is not: it is the steady -state of any composition that spans several provisioning steps. +The controller image (`ghcr.io/alethic/weft`) and the chart are both signed +with [cosign](https://github.com/sigstore/cosign), keyless. Each +[release](https://github.com/alethic/weft/releases) lists the image digest and +the command to verify it. A released chart pins the image by digest. -### Reading the cluster - -```python -rg = read("azure.m.upbound.io/v1beta1", "ResourceGroup", "sweep-env") -tenants = select("v1", "ConfigMap", labels={"role": "tenant"}) -``` +Every chart value is documented in [charts/weft/README.md](charts/weft/README.md), +including hardening options such as confining impersonation to a set of +namespaces. -Nothing is declared in the spec. The controller records what each pass actually -read and registers its watches from that recording, so a program that starts -reading something starts being woken by it, and one that stops, stops — there is -no second place to keep in step. +## Quick start -Both are namespaced and both are impersonated: `read` needs `get` on that kind, -`select` needs `list`. A resource that does not exist comes back falsey; a -resource you are not allowed to read is an error, never a quiet `None`. - -### Staging happens through `observed` - -`observed` holds what this `Weave` previously created, read back live. A -composition advances in phases by returning only what it can: - -```python -def compose(variable): - out = {"identity": {...}} - - # The provider writes principalId back minutes after the apply returns. - principal = get(observed, ["identity", "status", "atProvider", "principalId"]) - if not principal: - pending("principalId on the app identity") - return out # the identity exists; nothing else yet - - for role in variable.roles: - out["ra-" + role.name] = {...} - return out -``` - -`pending()` rather than `wait()` matters: a wait produces no resources at all, -so the identity would never be created and the composition could never advance. - -### It manages only what it made - -A program that names an object somebody else owns is **refused**, not allowed to -take it over — otherwise choosing a name would be enough to make Weft's owner -reference appear on it, and deleting the `Weave` would delete something Weft -never created. - -Handing an existing resource over is deliberate, and the consent lives on the -object: +With the controller installed, everything below runs as an ordinary namespace +user — there is no administrator step. ```bash -kubectl annotate configmap legacy weft.run/adopt= -``` - -The value is a pattern, so `weft.run/adopt='*'` consents to any `Weave` in that -namespace — the form for onboarding a set of objects at once, where naming the -same `Weave` on each of them says nothing extra. - -Everything Weft applies is stamped with `weft.run/weave` and -`weft.run/weave-uid`, so an object carries the record of what made it. A `Weave` -whose status was lost reclaims what it recognises rather than refusing to touch -it — which matters most for unowned resources, since those have no owner -reference to fall back on. - -[docs/ownership.md](docs/ownership.md) covers the whole story, including what -happens when a program is edited to declare a different object. - -An individual resource can opt out of being owned, so that it outlives the -composition describing it: - -```python -"metadata": { - "name": "app-db", - "annotations": {"weft.run/owned": "false"}, -} -``` - -Weft applies it and keeps it current, but places no owner reference: it is not -deleted when the program stops declaring it, and not collected when the `Weave` -is deleted. The absence of the reference is what makes that true whether or not -this controller is running. - -### A resource is its object - -`apiVersion`, `kind` and `name` are the whole identity — there is no key beside -them. Renaming what a program declares is one object no longer declared and -another declared in its place: the new one is created, the old one pruned. - -### Ordering - -Declaration order becomes apply order, and therefore reverse teardown order. -Cascading garbage collection would remove everything a `Weave` owns but in no -particular order, which is wrong for anything with a dependency. The `if` -statements in a composition already put a thing before the things that consume -it, so the default is correct with nothing annotated. - -What the default costs is parallelism: teardown waits for each object to be -confirmed gone before the next, and a managed resource takes minutes to delete. -Siblings that depend on something in common but not on each other can say so: - -```python -identity = resource({...}) - -for role in variable.roles: - resource({...}, needs=identity) +kubectl create namespace weft-quickstart +kubectl -n weft-quickstart create serviceaccount composer +kubectl -n weft-quickstart create rolebinding composer-edit \ + --clusterrole=edit --serviceaccount=weft-quickstart:composer +kubectl -n weft-quickstart create configmap tenant --from-literal=tenantId=acme-42 ``` -Seven role assignments naming one identity come out at the same depth and are -deleted together — one wait instead of seven. Waves are derived from what each -resource names, never written as a number, and `needs` takes the value -`resource()` returned rather than a name, so a misspelling is an undefined -variable with a backtrace rather than an ordering that is quietly wrong. - -### Waiting on a resource nothing reads - -A dependency that is never referenced is invisible to any design that infers -edges from expression references. Weft does not infer — the read is written, and -what an absence means is the line after it: - -```python -if not read("sql.azure.m.upbound.io/v1beta1", "MSSQLDatabase", "app"): - return wait("the database has not been created yet") -``` - -Which also expresses what a declared flag could not — a gate that depends on -configuration: +```bash +kubectl apply -f - <<'EOF' +apiVersion: weft.alethic.solutions/v1alpha1 +kind: Weave +metadata: + name: hello + namespace: weft-quickstart +spec: + serviceAccountName: composer + program: | + def compose(variable): + tenant = read("v1", "ConfigMap", "tenant") + if not tenant: + return wait("no tenant ConfigMap yet") -```python -if variable.useSql and not read("sql.azure.m.upbound.io/v1beta1", "MSSQLDatabase", "app"): - return wait("SQL is enabled but the database is not there yet") + resource({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": "settings"}, + "data": {"tenantId": require(tenant, "data.tenantId")}, + }) +EOF ``` -Turning `useSql` off releases the `Weave` with no other edit. - -### Pruning - -A resource that stops being returned is deleted — but not immediately. It has to -be absent from successful evaluations continuously for `--prune-delay` -(default 2 minutes) *and* for at least `--prune-threshold` of them. - -The delay is the half that matters. Reconciles are event-driven and the applies -in a single pass generate watch events of their own, so a count of them measures -controller activity rather than elapsed time; in testing, a removed resource -accumulated 79 "consecutive evaluations" in 56 seconds. A managed resource drops -its status for as long as its provider takes to restart, and a program waiting -on that status legitimately stops declaring what depends on it for that whole -period. Deleting on the first sight of that churns real infrastructure. - -## The language - -[Starlark](https://github.com/bazelbuild/starlark), embedded in-process — Python -you can read on sight, without the parts that make Python unsafe to run for -somebody else. See [docs/language.md](docs/language.md) for the full surface. - -Untrusted authorship is the premise, so the language is bounded rather than -trusted: no `load()`, no recursion, no clock, no randomness, no I/O beyond -`read()` and `select()`, an execution-step budget and caps on result size and on -how much of a namespace one pass may pull in. Every limit fails as a `Degraded` -condition with a distinct reason, not as a wedged controller. - -Considered and rejected: **CEL** cannot emit variable-length structure, so -conditional inclusion and fan-out become YAML constructs that accumulate into a -bad programming language. **CUE** is an adoption tax with no cost budget for -untrusted evaluation. **WASM** needs a build pipeline per composition. **Go -templates** have unbounded recursion and `sprig` reaches the environment. - -## Trying it - -[demo/](demo/) has seven `Weave`s covering staging, waiting, permission -failures, ordered teardown, pruning, held finalizers and the evaluator's bounds, -with a walkthrough in [demo/README.md](demo/README.md). [examples/](examples/) -has four longer compositions taken from real Crossplane workloads. - -## Operating it - -| flag | default | why you would change it | -|---|---|---| -| `--prune-delay` | `2m` | how long a resource must be gone before deletion | -| `--hold-timeout` | `10m` | how long a `hold=True` read may block somebody else's object | -| `--teardown-timeout` | `15m` | how long ordered teardown runs before cascading collection takes over | -| `--max-reads` | `100` | distinct resources one evaluation may read | -| `--max-selected` | `500` | objects one `select()` may match | -| `--max-steps` | `20000000` | the execution budget for one `compose()` | -| `--impersonate-groups` | `system:serviceaccounts,system:authenticated` | see [docs/rbac.md](docs/rbac.md) | - -`weft --help` lists the rest. The chart exposes all of them; see -[charts/weft/README.md](charts/weft/README.md). - -Editing a `Weave` converges: what the program stopped declaring is pruned, and -what it started declaring is applied. - -### Metrics - -`weft_weave_status` reports each `Weave` as ready, waiting or degraded, so -alerting on "degraded for more than N minutes" is a single expression. Alongside -it: resources owned per `Weave`, evaluation duration and outcome, applies, -prunes, permission denials by verb and resource, and active versus degraded -watches. Enable the `ServiceMonitor` with `metrics.serviceMonitor.enabled=true`. - -### Uninstalling - -Deleting a `Weave` deletes what it created, in reverse wave order. To keep the -resources instead, use the verb Kubernetes already has for it: - ```bash -kubectl delete weave app --cascade=orphan +kubectl -n weft-quickstart get weave,configmap +kubectl -n weft-quickstart patch configmap tenant -p '{"data":{"tenantId":"acme-43"}}' +kubectl -n weft-quickstart get configmap settings -o jsonpath='{.data.tenantId}' ``` -If any program uses `read(..., hold=True)`, release those finalizers -**before** removing the controller, or the objects holding them cannot be -deleted: +The `edit` binding is the quick path. In production, scope the ServiceAccount +to exactly what its program needs — [docs/rbac.md](docs/rbac.md) explains how, +and a `Weave` that is missing a permission tells you which one. -```bash -kubectl -n weft-system exec deploy/weft -- /weft reap --dry-run -kubectl -n weft-system exec deploy/weft -- /weft reap -helm uninstall weft --namespace weft-system -``` +To go further, [demo/](demo/README.md) walks through seven `Weave`s covering +staging, waiting, permission failures, ordered teardown, pruning, finalizers +and the evaluator's limits, using only core Kubernetes types. +[examples/](examples/) has longer compositions taken from real Crossplane +workloads. -`helm uninstall` does not do this for you. It also leaves the CRD behind on -purpose: deleting it deletes every `Weave` in the cluster, and each one deleted -that way takes the resources it owns with it. +## Documentation -## What it will not do +| | | +|---|---| +| [The composition language](docs/language.md) | `compose()`, `resource()`, `read()`, `select()`, `wait()`, `pending()`, variables, and every built-in | +| [Ownership](docs/ownership.md) | what Weft manages, adopting existing objects, opting out, renaming, deletion | +| [Permissions](docs/rbac.md) | what a `Weave`'s ServiceAccount needs, and what the controller itself holds | +| [Operating Weft](docs/operating.md) | status conditions, pruning, teardown, controller flags, metrics, uninstalling | +| [Design and alternatives](docs/design.md) | how Weft compares with kro, Crossplane, Flux and Kyverno; why Starlark; non-goals | +| [Helm chart](charts/weft/README.md) | installation, every value, common configurations | +| [Security](SECURITY.md) | threat model, hardening, reporting a vulnerability | -- Generate a CRD per composition. One CRD, forever. -- Offer a typed API surface for consumers. That is kro's model, and it requires - the platform team this project exists to remove. -- Anything cross-namespace. Same-namespace is a hard rule, and it is what keeps - ownership and garbage collection simple enough to need no machinery. -- Integrate with any cloud provider. Weft composes whatever CRDs are installed - and knows nothing about any of them. +## Project status -## Status +Weft is **alpha**. The API is `weft.alethic.solutions/v1alpha1`, and its shape may still +change between minor versions before `1.0`; breaking changes will be called out +in the release notes. -`v1alpha1`. The API group is provisional and the shape may still move. +CI runs the reconcile loop against a real API server on Kubernetes 1.27 (the +minimum supported), 1.33 and 1.37, alongside unit tests of the evaluator and +planner, rendering tests of the Helm chart, and a check that every shipped +example evaluates. -Tested at four layers: the evaluator, inventory planning, normalisation and RBAC -diagnostics with no dependencies; every shipped example parsed, evaluated and -normalised, so a broken example fails the build; the Helm chart rendered and its -arguments parsed with the binary's own flag set; and the reconcile loop against a -real API server — apply and ownership, staging, pruning hysteresis, ordered -teardown, program faults, recreation, and steady-state stability. +## Contributing -That last layer matters most: every bug found during development was in the -reconcile loop, and none were visible without an API server to react to. +Issues and pull requests are welcome. [CONTRIBUTING.md](CONTRIBUTING.md) covers +building, the test layers, and the two design properties a change must not +weaken. Please report security issues privately as described in +[SECURITY.md](SECURITY.md). -Verified by hand against a live cluster with RBAC enforced, and not yet -automated: impersonation *denial* specifically. +## License -Contributions and the repository layout are covered in -[CONTRIBUTING.md](CONTRIBUTING.md). +[Apache License 2.0](LICENSE). diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go index 1120b33..fdda2e2 100644 --- a/api/v1alpha1/groupversion_info.go +++ b/api/v1alpha1/groupversion_info.go @@ -7,7 +7,7 @@ // the wrong group. // // +kubebuilder:object:generate=true -// +groupName=weft.run +// +groupName=weft.alethic.solutions package v1alpha1 import ( diff --git a/api/v1alpha1/weave_types.go b/api/v1alpha1/weave_types.go index 7ef830a..21c08ba 100644 --- a/api/v1alpha1/weave_types.go +++ b/api/v1alpha1/weave_types.go @@ -136,7 +136,7 @@ type InventoryEntry struct { Wave int32 `json:"wave"` // Owned records whether this Weave holds an owner reference on the - // resource. False means it was applied with weft.run/owned=false and will + // resource. False means it was applied with weft.alethic.solutions/owned=false and will // never be deleted by this Weave. // // It is remembered here rather than read from the object, because the diff --git a/charts/weft/Chart.yaml b/charts/weft/Chart.yaml index f9a733d..96c6075 100644 --- a/charts/weft/Chart.yaml +++ b/charts/weft/Chart.yaml @@ -39,13 +39,13 @@ annotations: artifacthub.io/crds: | - kind: Weave version: v1alpha1 - name: weaves.weft.run + name: weaves.weft.alethic.solutions displayName: Weave description: | Reads existing resources in its namespace and generates others from them, applying everything as the ServiceAccount it names. artifacthub.io/crdsExamples: | - - apiVersion: weft.run/v1alpha1 + - apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: app diff --git a/charts/weft/README.md b/charts/weft/README.md index f79e710..43288ce 100644 --- a/charts/weft/README.md +++ b/charts/weft/README.md @@ -10,17 +10,17 @@ See the [project README](../../README.md) for what a `Weave` is and ## Install ```bash -helm install weft oci://ghcr.io/alethic/charts/weft --devel \ +helm install weft oci://ghcr.io/alethic/charts/weft \ --namespace weft-system --create-namespace ``` -The chart lives in GitHub Packages as an OCI artifact, published on every build -of `main`. No stable release is cut yet, so `--devel` is needed to resolve a -prerelease; without it Helm looks for a `1.0`-style version and finds nothing. -Pin an exact `--version 0.1.0-pre.23` for anything you care about. +The chart lives in GitHub Packages as an OCI artifact. Without `--version`, +Helm resolves the newest release; pin an exact `--version` for anything you +care about. Every build of `main` is also published as a prerelease +(`0.1.1-pre.3` and so on), which Helm only considers with `--devel`. ```bash -helm show chart oci://ghcr.io/alethic/charts/weft --devel +helm show chart oci://ghcr.io/alethic/charts/weft ``` The chart's `appVersion` selects the image tag, so a chart and the controller it @@ -58,7 +58,7 @@ owns with it — an uninstall should not be able to destroy infrastructure. Remove it deliberately when you mean to: ```bash -kubectl delete crd weaves.weft.run +kubectl delete crd weaves.weft.alethic.solutions ``` ## Values @@ -127,7 +127,7 @@ controller: |---|---|---| | `controller.watchNamespace` | `""` | Restrict to one namespace. Empty watches all. | | `controller.leaderElection.enabled` | `true` | | -| `controller.leaderElection.id` | `weft.run` | Change only to run two independent installations. | +| `controller.leaderElection.id` | `weft.alethic.solutions` | Change only to run two independent installations. | | `controller.impersonateGroups` | `[system:serviceaccounts, system:authenticated]` | Impersonating the username alone yields an identity weaker than the real ServiceAccount, so RBAC bound to `system:authenticated` would silently not apply. | | `controller.pruneDelay` | `2m` | How long a resource must be continuously absent before deletion. Raise it above your provider's restart time. | | `controller.pruneThreshold` | `3` | Successful evaluations it must also be absent from. | diff --git a/charts/weft/files/weaves-crd.yaml b/charts/weft/files/weaves-crd.yaml index 9bdaac5..aa09785 100644 --- a/charts/weft/files/weaves-crd.yaml +++ b/charts/weft/files/weaves-crd.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.22.0 - name: weaves.weft.run + name: weaves.weft.alethic.solutions spec: - group: weft.run + group: weft.alethic.solutions names: kind: Weave listKind: WeaveList @@ -332,7 +332,7 @@ spec: owned: description: |- Owned records whether this Weave holds an owner reference on the - resource. False means it was applied with weft.run/owned=false and will + resource. False means it was applied with weft.alethic.solutions/owned=false and will never be deleted by this Weave. It is remembered here rather than read from the object, because the diff --git a/charts/weft/templates/rbac.yaml b/charts/weft/templates/rbac.yaml index 90b6ca7..9f5c62f 100644 --- a/charts/weft/templates/rbac.yaml +++ b/charts/weft/templates/rbac.yaml @@ -18,13 +18,13 @@ rules: # Weaves themselves. update is for the finalizer, which is what buys ordered # teardown; without it, deleting a Weave hands everything to cascading # collection at once and in no particular order. - - apiGroups: ["weft.run"] + - apiGroups: ["weft.alethic.solutions"] resources: ["weaves"] verbs: ["get", "list", "watch", "update", "patch"] - - apiGroups: ["weft.run"] + - apiGroups: ["weft.alethic.solutions"] resources: ["weaves/status"] verbs: ["get", "update", "patch"] - - apiGroups: ["weft.run"] + - apiGroups: ["weft.alethic.solutions"] resources: ["weaves/finalizers"] verbs: ["update"] diff --git a/charts/weft/values.yaml b/charts/weft/values.yaml index b403c31..f02fc5c 100644 --- a/charts/weft/values.yaml +++ b/charts/weft/values.yaml @@ -75,7 +75,7 @@ controller: leaderElection: enabled: true # Lease name. Change it only to run two independent Weft installations. - id: weft.run + id: weft.alethic.solutions # Groups impersonated alongside the ServiceAccount username. # diff --git a/cmd/weft/chart_test.go b/cmd/weft/chart_test.go index b0495f9..84916d7 100644 --- a/cmd/weft/chart_test.go +++ b/cmd/weft/chart_test.go @@ -372,7 +372,7 @@ func TestChartKeepsTheCRDByDefault(t *testing.T) { // The chart's CRD is the generated one, so the two cannot drift. func TestChartCRDMatchesGenerated(t *testing.T) { - generated := readFile(t, "../../config/crd/weft.run_weaves.yaml") + generated := readFile(t, "../../config/crd/weft.alethic.solutions_weaves.yaml") shipped := readFile(t, "../../charts/weft/files/weaves-crd.yaml") if generated != shipped { t.Error("charts/weft/files/weaves-crd.yaml differs from the generated CRD; run make generate") @@ -411,7 +411,7 @@ func TestChartCRDRoundTripIsLossless(t *testing.T) { if err := yaml.Unmarshal([]byte(rendered), &fromChart); err != nil { t.Fatalf("parsing the chart's CRD: %v", err) } - if err := yaml.Unmarshal([]byte(readFile(t, "../../config/crd/weft.run_weaves.yaml")), &fromGenerator); err != nil { + if err := yaml.Unmarshal([]byte(readFile(t, "../../config/crd/weft.alethic.solutions_weaves.yaml")), &fromGenerator); err != nil { t.Fatalf("parsing the generated CRD: %v", err) } diff --git a/config/crd/weft.run_weaves.yaml b/config/crd/weft.alethic.solutions_weaves.yaml similarity index 99% rename from config/crd/weft.run_weaves.yaml rename to config/crd/weft.alethic.solutions_weaves.yaml index 9bdaac5..aa09785 100644 --- a/config/crd/weft.run_weaves.yaml +++ b/config/crd/weft.alethic.solutions_weaves.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.22.0 - name: weaves.weft.run + name: weaves.weft.alethic.solutions spec: - group: weft.run + group: weft.alethic.solutions names: kind: Weave listKind: WeaveList @@ -332,7 +332,7 @@ spec: owned: description: |- Owned records whether this Weave holds an owner reference on the - resource. False means it was applied with weft.run/owned=false and will + resource. False means it was applied with weft.alethic.solutions/owned=false and will never be deleted by this Weave. It is remembered here rather than read from the object, because the diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 35606f1..21ffbc1 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -27,7 +27,7 @@ rules: verbs: - impersonate - apiGroups: - - weft.run + - weft.alethic.solutions resources: - weaves verbs: @@ -37,13 +37,13 @@ rules: - update - watch - apiGroups: - - weft.run + - weft.alethic.solutions resources: - weaves/finalizers verbs: - update - apiGroups: - - weft.run + - weft.alethic.solutions resources: - weaves/status verbs: diff --git a/demo/01-app-stack.yaml b/demo/01-app-stack.yaml index 1e4b4cb..6501570 100644 --- a/demo/01-app-stack.yaml +++ b/demo/01-app-stack.yaml @@ -8,7 +8,7 @@ # # kubectl -n weft-demo get weave app-stack -w # -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: app-stack diff --git a/demo/02-waiting.yaml b/demo/02-waiting.yaml index ae9c646..6a4dd1b 100644 --- a/demo/02-waiting.yaml +++ b/demo/02-waiting.yaml @@ -9,7 +9,7 @@ # # kubectl -n weft-demo create configmap tenant --from-literal=tenantId=acme-42 # -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: waiting diff --git a/demo/03-forbidden.yaml b/demo/03-forbidden.yaml index 07a790a..122e9fb 100644 --- a/demo/03-forbidden.yaml +++ b/demo/03-forbidden.yaml @@ -9,7 +9,7 @@ # The condition names the exact verb and resource, and gives you the two # commands that fix it. Run them and the Weave resolves itself - then tells you # about the next missing permission, one at a time, until it is done. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: forbidden diff --git a/demo/04-teardown.yaml b/demo/04-teardown.yaml index 7612b62..892b771 100644 --- a/demo/04-teardown.yaml +++ b/demo/04-teardown.yaml @@ -10,14 +10,14 @@ # kubectl -n weft-demo patch cm layer-middle --type=merge \ # -p '{"metadata":{"finalizers":["example.test/hold"]}}' # kubectl -n weft-demo delete weave teardown --wait=false -# kubectl -n weft-demo get cm -l weft.run/weave=teardown +# kubectl -n weft-demo get cm -l weft.alethic.solutions/weave=teardown # # top is gone, middle is Terminating, and base is untouched - no deletion # timestamp at all. Release the hold and the rest drains: # # kubectl -n weft-demo patch cm layer-middle --type=merge \ # -p '{"metadata":{"finalizers":null}}' -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: teardown diff --git a/demo/05-pruning.yaml b/demo/05-pruning.yaml index 6295c05..73f21c5 100644 --- a/demo/05-pruning.yaml +++ b/demo/05-pruning.yaml @@ -16,7 +16,7 @@ # infrastructure with it. # # This install sets pruneDelay to 45s so it can be watched. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: pruning diff --git a/demo/06-finalizer.yaml b/demo/06-finalizer.yaml index 6f2c3a9..5e79e2f 100644 --- a/demo/06-finalizer.yaml +++ b/demo/06-finalizer.yaml @@ -18,7 +18,7 @@ # # It orders the deletion of an API object and nothing more. It does not order # the destruction of whatever that object represents. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: finalizer diff --git a/demo/07-bounds.yaml b/demo/07-bounds.yaml index a0e2c1a..6b7b550 100644 --- a/demo/07-bounds.yaml +++ b/demo/07-bounds.yaml @@ -12,7 +12,7 @@ --- # Recursion turns a step budget into a stack-depth problem, which a step budget # does not catch. Rejected by the compiler. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: bound-recursion @@ -33,7 +33,7 @@ spec: --- # A program is self-contained. There is nowhere for load() to load from, and # saying so at compile time beats a confusing failure during evaluation. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: bound-load @@ -48,7 +48,7 @@ spec: --- # Runtime bounding is what replaces the apply-time type checking a # schema-generating design would have had. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: bound-budget @@ -64,7 +64,7 @@ spec: --- # Same-namespace is the hard rule that makes plain owner references sufficient, # so an output reaching outside is refused rather than relocated. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: bound-namespace @@ -81,7 +81,7 @@ spec: --- # A namespaced owner cannot own a cluster-scoped object, so it could never be # garbage collected. Refused before anything is created. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: bound-cluster-scoped @@ -98,7 +98,7 @@ spec: --- # Weft owns what it creates and writes the reference itself. Letting a program # set one would let it hand its output to an owner that outlives the Weave. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: bound-owner @@ -122,7 +122,7 @@ spec: }) --- # Declaring one object twice cannot mean two objects. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: bound-declared-twice @@ -137,7 +137,7 @@ spec: # A field that is not set reads as None, but reaching through one does not: a # path that assumed an object where there is nothing fails where it happened, # rather than producing a resource with a blank field. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: bound-typo diff --git a/demo/README.md b/demo/README.md index acf2264..7260e4b 100644 --- a/demo/README.md +++ b/demo/README.md @@ -6,7 +6,7 @@ Kubernetes types so they run anywhere — including a laptop cluster. ## Setup ```bash -helm install weft oci://ghcr.io/alethic/charts/weft --devel \ +helm install weft oci://ghcr.io/alethic/charts/weft \ --namespace weft-system --create-namespace \ --set controller.pruneDelay=45s @@ -235,7 +235,7 @@ kubectl -n weft-demo delete weave --all kubectl delete namespace weft-demo kubectl -n weft-system exec deploy/weft -- /weft reap helm uninstall weft --namespace weft-system -kubectl delete crd weaves.weft.run +kubectl delete crd weaves.weft.alethic.solutions ``` Delete the Weaves before the namespace: they hold a finalizer so their resources diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..9038d75 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,84 @@ +# Design and alternatives + +Why Weft exists alongside the other composition tools, why its language is +Starlark, and what it deliberately does not do. + +## Compared with other tools + +| | namespaced authoring | impersonated writes | status-reactive | +|---|---|---|---| +| **Weft** | **yes** | **yes** | **yes** | +| [kro](https://kro.run) | no — `ResourceGraphDefinition` is cluster-scoped and generates a CRD | no | yes | +| [Crossplane v2](https://crossplane.io) | XRs are namespaced, but XRDs and Compositions are cluster-scoped admin artifacts | no — the RBAC manager runs with `escalate` | yes | +| Metacontroller, Yoke ATC | no | no | yes | +| [Flux](https://fluxcd.io) | yes | **yes** — this is where the model comes from | no — `dependsOn` between whole Kustomizations, and `substituteFrom` limited to ConfigMaps and Secrets | +| Kyverno `generate` | yes | no — the background controller's own ServiceAccount does the creating, checked once at policy admission | yes | + +- **Namespaced authoring** — an ordinary namespace user can write a + composition without a cluster administrator installing or registering + anything first. +- **Impersonated writes** — the composition acts as an identity the author + chose and could have used directly, so it cannot create anything the author + could not. +- **Status-reactive** — a composition can wait on a field another controller + fills in later, and proceed when it appears. + +If you already run Crossplane, or any provider whose resources report status +asynchronously, the third column is the one that will matter day to day. + +## Why Starlark + +[Starlark](https://github.com/bazelbuild/starlark) is Python you can read on +sight, without the parts that make Python unsafe to run on somebody else's +behalf. Untrusted authorship is the premise, so the language is bounded rather +than trusted: no `load()`, no recursion, no clock, no randomness, no I/O beyond +`read()` and `select()`, an execution-step budget, and caps on result size and on +how much of a namespace one pass may pull in. Every limit fails as a `Degraded` +condition with a distinct reason, not as a wedged controller. + +Considered and rejected: + +- **CEL** cannot emit variable-length structure, so conditional inclusion and + fan-out become YAML constructs that accumulate into a bad programming + language. +- **CUE** is an adoption tax, with no cost budget for untrusted evaluation. +- **WASM** needs a build pipeline per composition. +- **Go templates** have unbounded recursion, and `sprig` reaches the + environment. + +## Dependencies are written, not inferred + +Weft does not infer ordering from expression references, because a dependency +that is never referenced would then be invisible. The read is written, and what +an absence means is the line after it: + +```python +if variable.useSql and not read("sql.azure.m.upbound.io/v1beta1", "MSSQLDatabase", "app"): + return wait("SQL is enabled but the database is not there yet") +``` + +That also expresses what a declared "required" flag could not: a gate that +depends on configuration. Turning `useSql` off releases the `Weave` with no +other edit. + +## Watches come from what was read + +Nothing about inputs is declared in the spec. The controller records what each +pass actually read and registers its watches from that recording, so a program +that starts reading something starts being woken by it, and one that stops, +stops. There is no second place to keep in step with the program. + +## Non-goals + +- **Generating a CRD per composition.** One CRD, forever. +- **A typed API surface for consumers.** That is kro's model, and it requires + the platform team this project exists to remove. +- **Anything cross-namespace.** Same-namespace is a hard rule, and it is what + keeps ownership and garbage collection simple enough to need no machinery. +- **Cloud-provider integration.** Weft composes whatever CRDs are installed and + knows nothing about any of them. + +## The name + +From weaving. The **warp** is the threads already on the loom — resources a +`Weave` reads but does not own. The **weft** is what is passed across them. diff --git a/docs/language.md b/docs/language.md index 4abd98b..03bdf6c 100644 --- a/docs/language.md +++ b/docs/language.md @@ -381,8 +381,8 @@ Weft stamps provenance on everything it applies: | | | |---|---| -| `weft.run/weave` | label — the `Weave`'s name, so `kubectl get -l weft.run/weave=` works | -| `weft.run/weave-uid` | the `Weave`'s UID | +| `weft.alethic.solutions/weave` | label — the `Weave`'s name, so `kubectl get -l weft.alethic.solutions/weave=` works | +| `weft.alethic.solutions/weave-uid` | the `Weave`'s UID | Only facts that do not change from pass to pass. A timestamp or a revision here would rewrite every object on every reconcile, and each of those writes is a @@ -466,7 +466,7 @@ provider writes back minutes later only once it is there. A resource Weft created behaves exactly like an external one that is not ready, and one rule covers both. -### `weft.run/owned: "false"` +### `weft.alethic.solutions/owned: "false"` An unowned resource is applied and kept current like any other, but no owner reference is placed on it. It is not deleted when the program stops declaring @@ -475,7 +475,7 @@ it, and not collected when the `Weave` is deleted: ```python "metadata": { "name": "app-db", - "annotations": {"weft.run/owned": "false"}, + "annotations": {"weft.alethic.solutions/owned": "false"}, } ``` @@ -491,7 +491,7 @@ depend on. When such a resource stops being declared, the inventory entry is dropped at once — there is no hysteresis, because nothing is being destroyed — and an event -records what was let go. It keeps its `weft.run/weave` label, so it is still +records what was let go. It keeps its `weft.alethic.solutions/weave` label, so it is still findable as something this `Weave` once made. The only accepted values are `"true"` and `"false"`. Anything else is refused, @@ -500,7 +500,7 @@ being deleted. A resource that already exists and was not created by this `Weave` is refused rather than taken over, unless the object itself carries -`weft.run/adopt: `. See [ownership.md](ownership.md). +`weft.alethic.solutions/adopt: `. See [ownership.md](ownership.md). ## Worked example diff --git a/docs/operating.md b/docs/operating.md new file mode 100644 index 0000000..6c73f00 --- /dev/null +++ b/docs/operating.md @@ -0,0 +1,143 @@ +# Operating Weft + +How a `Weave` reports what it is doing, how the controller decides what to +delete and when, and the knobs an operator has. Installation and chart values +are in [charts/weft/README.md](../charts/weft/README.md). + +## Reading a Weave's status + +```console +$ kubectl get weave +NAME READY WAITING AGE +app False FieldUnresolved 7s +``` + +Resolution has three outcomes, not two: it succeeded, it failed permanently, or +it cannot proceed **yet**. Each has its own condition. + +| condition | meaning | +|---|---| +| `Ready` | the program ran and everything it declared is applied | +| `Waiting` | the program is blocked on something that has not happened yet; the message says what | +| `Degraded` | a permanent error — a program fault, a permission denial, a limit exceeded; the reason is distinct for each | + +```console +$ kubectl get weave app -o jsonpath='{.status.conditions[?(@.type=="Waiting")].message}' +status.atProvider.principalId is not set on UserAssignedIdentity/demo-app +``` + +"Not yet" is the normal case. Existence does not unblock a program — **field +resolution** does. Applying a Crossplane managed resource returns instantly, but +its `status.atProvider` populates minutes later, so a resource the `Weave` +created behaves exactly like an external one that is not ready, and one rule +covers both. + +Alert on `Degraded`. Do not alert on `Waiting`: it is the steady state of any +composition that spans several provisioning steps. + +A permission denial is reported as `Degraded` with the exact `kubectl` commands +that would grant what is missing. See [rbac.md](rbac.md). + +## Apply order and teardown + +Declaration order becomes apply order, and therefore reverse teardown order. +Cascading garbage collection would remove everything a `Weave` owns, but in no +particular order — wrong for anything with a dependency. The `if` statements in +a composition already put a thing before the things that consume it, so the +default is correct with nothing annotated. + +What the default costs is parallelism: teardown waits for each object to be +confirmed gone before the next, and a managed resource can take minutes to +delete. Siblings that depend on something in common but not on each other can +say so with `needs`: + +```python +identity = resource({...}) + +for role in variable.roles: + resource({...}, needs=identity) +``` + +Seven role assignments naming one identity come out at the same depth and are +deleted together — one wait instead of seven. Waves are derived from what each +resource names, never written as a number, and `needs` takes the value +`resource()` returned rather than a name, so a misspelling is an undefined +variable with a backtrace rather than an ordering that is quietly wrong. + +If ordered teardown has not finished after `--teardown-timeout`, cascading +garbage collection takes over. + +## Pruning + +A resource the program stops declaring is deleted — but not immediately. It +has to be absent from successful evaluations continuously for `--prune-delay` +(default 2 minutes) *and* for at least `--prune-threshold` of them. + +The delay is the half that matters. Reconciles are event-driven, and the applies +in a single pass generate watch events of their own, so a count of evaluations +measures controller activity rather than elapsed time; in testing, a removed +resource accumulated 79 "consecutive evaluations" in 56 seconds. A managed +resource loses its status for as long as its provider takes to restart, and a +program waiting on that status legitimately stops declaring what depends on it +for that whole period. Deleting on first sight of that churns real +infrastructure. + +Set `--prune-delay` above the longest restart of your slowest provider. + +Editing a `Weave` converges the same way: what the program stopped declaring is +pruned, and what it started declaring is applied. + +## Controller flags + +| flag | default | why you would change it | +|---|---|---| +| `--prune-delay` | `2m` | how long a resource must be undeclared before it is deleted | +| `--prune-threshold` | `3` | how many successful evaluations it must be absent from | +| `--hold-timeout` | `10m` | how long a `hold=True` read may block deletion of somebody else's object | +| `--teardown-timeout` | `15m` | how long ordered teardown runs before cascading collection takes over | +| `--max-steps` | `20000000` | the execution budget for one `compose()` | +| `--max-resources` | `250` | resources one evaluation may declare | +| `--max-reads` | `100` | distinct resources one evaluation may read | +| `--max-selected` | `500` | objects one `select()` may match | +| `--namespace` | *(all)* | restrict the controller to one namespace | +| `--impersonate-groups` | `system:serviceaccounts,system:authenticated` | see [rbac.md](rbac.md) | + +`weft --help` lists the rest. Every flag is exposed as a chart value; see +[charts/weft/README.md](../charts/weft/README.md). + +## Metrics + +`weft_weave_status` reports each `Weave` as ready, waiting or degraded, so +alerting on "degraded for more than N minutes" is a single expression. +Alongside it: resources owned per `Weave`, evaluation duration and outcome, +applies, prunes, permission denials by verb and resource, and active versus +degraded watches. + +Enable the `ServiceMonitor` with `metrics.serviceMonitor.enabled=true`. + +## Deleting a Weave + +Deleting a `Weave` deletes what it created, in reverse wave order. To keep the +resources instead, use the verb Kubernetes already has for it: + +```bash +kubectl delete weave app --cascade=orphan +``` + +Resources annotated `weft.run/owned: "false"` are never deleted with the +`Weave`. See [ownership.md](ownership.md). + +## Uninstalling the controller + +If any program uses `read(..., hold=True)`, release those finalizers **before** +removing the controller, or the objects holding them cannot be deleted: + +```bash +kubectl -n weft-system exec deploy/weft -- /weft reap --dry-run +kubectl -n weft-system exec deploy/weft -- /weft reap +helm uninstall weft --namespace weft-system +``` + +`helm uninstall` does not do this for you. It also leaves the CRD behind on +purpose: deleting the CRD deletes every `Weave` in the cluster, and each one +deleted that way takes the resources it owns with it. diff --git a/docs/ownership.md b/docs/ownership.md index c86c934..ae832a3 100644 --- a/docs/ownership.md +++ b/docs/ownership.md @@ -26,7 +26,7 @@ made. To hand it over deliberately, annotate the object itself. Consent belongs to whoever holds the resource, not to whoever wrote the program: - kubectl -n weft-demo annotate ConfigMap legacy weft.run/adopt=onboard + kubectl -n weft-demo annotate ConfigMap legacy weft.alethic.solutions/adopt=onboard From then on this Weave manages it, and deleting the Weave deletes it. Otherwise change the name the program produces, or remove what is there: @@ -40,7 +40,7 @@ Nothing is written, and nothing is recorded in the inventory. Annotate the object with the name of the `Weave` allowed to take it: ```bash -kubectl -n weft-demo annotate ConfigMap legacy weft.run/adopt=onboard +kubectl -n weft-demo annotate ConfigMap legacy weft.alethic.solutions/adopt=onboard ``` The annotation goes **on the object, not in the Weave**. That is the point: if a @@ -55,10 +55,10 @@ naming the same `Weave` on every object: ```bash # Any Weave in this namespace. -kubectl -n weft-demo annotate ConfigMap legacy weft.run/adopt='*' +kubectl -n weft-demo annotate ConfigMap legacy weft.alethic.solutions/adopt='*' # Any whose name starts with app-. -kubectl -n weft-demo annotate ConfigMap legacy weft.run/adopt='app-*' +kubectl -n weft-demo annotate ConfigMap legacy weft.alethic.solutions/adopt='app-*' ``` A pattern is still consent and still namespaced. A `Weave` only ever acts in its @@ -77,7 +77,7 @@ a change worth being able to find later: ``` Normal Adopted took ownership of ConfigMap "legacy", which was annotated - weft.run/adopt=onboard. Deleting this Weave now deletes it. + weft.alethic.solutions/adopt=onboard. Deleting this Weave now deletes it. ``` That last sentence is the part to understand before annotating. Adoption is @@ -102,7 +102,7 @@ instead. ## Opting a resource out of ownership -A resource annotated `weft.run/owned: "false"` is applied without an owner +A resource annotated `weft.alethic.solutions/owned: "false"` is applied without an owner reference. Weft keeps it current for as long as the program declares it, and never deletes it: not when it stops being declared, not when the `Weave` is deleted. @@ -110,7 +110,7 @@ deleted. ```python "metadata": { "name": "app-db", - "annotations": {"weft.run/owned": "false"}, + "annotations": {"weft.alethic.solutions/owned": "false"}, } ``` @@ -121,7 +121,7 @@ the object outlives the thing describing it. Ceasing to declare it drops the inventory entry immediately, with no hysteresis: hysteresis exists to avoid destroying something over a transient absence, and nothing here is destroyed. An event records it, and the object -keeps its `weft.run/weave` label. +keeps its `weft.alethic.solutions/weave` label. ## Deleting a Weave takes its resources @@ -165,14 +165,14 @@ real consequence for something that existed first. If you want it handed back rather than destroyed, release it with `--cascade=orphan` — and if you want it back under management later, annotate it again. -Orphaned resources keep their `weft.run/weave` label and `weft.run/weave-uid` +Orphaned resources keep their `weft.alethic.solutions/weave` label and `weft.alethic.solutions/weave-uid` annotation. A new `Weave` that tries to produce them will be refused as belonging to nobody, and can be given them again with the adopt annotation. ## Reclaiming what a Weave already made -Everything Weft applies carries `weft.run/weave`, `weft.run/weave-uid` and -`weft.run/weave-uid`. The `Weave`'s status records the same thing, but a status can be +Everything Weft applies carries `weft.alethic.solutions/weave`, `weft.alethic.solutions/weave-uid` and +`weft.alethic.solutions/weave-uid`. The `Weave`'s status records the same thing, but a status can be lost, and an unowned resource has no owner reference to fall back on — so without something written on the object, a lost inventory would leave a `Weave` permanently refusing to touch resources it made itself. @@ -232,5 +232,5 @@ declarations cannot mean two of them | a declared object changes name or kind | the new one is created, the old one pruned after the delay | | an object stops being declared | pruned, after the delay | | the `Weave` is deleted | its resources are deleted, in reverse wave order | -| a resource is annotated `weft.run/owned: "false"` | never deleted; released when it leaves the set | +| a resource is annotated `weft.alethic.solutions/owned: "false"` | never deleted; released when it leaves the set | | the `Weave` is deleted with `--cascade=orphan` | resources kept, owner references stripped | diff --git a/docs/rbac.md b/docs/rbac.md index bd33b99..0a8f33b 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -198,11 +198,11 @@ kubectl -n weft-system exec deploy/weft -- /weft reap The image is distroless and has no shell, but this execs the binary directly so it needs none. Running the binary locally against your kubecontext works too. -Anything missed carries a finalizer beginning `weft.run/src-`, which is a fixed +Anything missed carries a finalizer beginning `weft.alethic.solutions/src-`, which is a fixed prefix precisely so a human can find them: ```bash kubectl get -A -o json \ - | jq -r '.items[] | select(.metadata.finalizers[]? | startswith("weft.run/src-")) + | jq -r '.items[] | select(.metadata.finalizers[]? | startswith("weft.alethic.solutions/src-")) | "\(.metadata.namespace)/\(.metadata.name)"' ``` diff --git a/examples/app.yaml b/examples/app.yaml index ed2a7b4..634c3c3 100644 --- a/examples/app.yaml +++ b/examples/app.yaml @@ -12,7 +12,7 @@ # Note what is not here. The FederatedIdentityCredentials that sat alongside the # original template are static - they read no live status - so they stay in # Helm. Weft is for the parts that have to react. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: app diff --git a/examples/env.yaml b/examples/env.yaml index 9c09c8c..7cbe12e 100644 --- a/examples/env.yaml +++ b/examples/env.yaml @@ -5,7 +5,7 @@ # Structurally the same shape as examples/app.yaml - identity first, then what # consumes it - which is the point: once staging is expressible, most of these # compositions turn out to be the same composition. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: env diff --git a/examples/release-values.yaml b/examples/release-values.yaml index 16fcfb4..c271b71 100644 --- a/examples/release-values.yaml +++ b/examples/release-values.yaml @@ -8,7 +8,7 @@ # It is the clearest case for to_yaml(). The original did this by hand in a Go # template with careful nindent counts, which is exactly the kind of thing that # breaks silently when someone adds a nesting level. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: release-values diff --git a/examples/sql.yaml b/examples/sql.yaml index 38843e9..1e92303 100644 --- a/examples/sql.yaml +++ b/examples/sql.yaml @@ -7,7 +7,7 @@ # forever and re-applied the same grants. With a real watch it collapses into a # single Job keyed off the identity's generation: it runs when the identity # actually changes, and not otherwise. -apiVersion: weft.run/v1alpha1 +apiVersion: weft.alethic.solutions/v1alpha1 kind: Weave metadata: name: sql diff --git a/internal/controller/owned_test.go b/internal/controller/owned_test.go index efc84f9..98467f3 100644 --- a/internal/controller/owned_test.go +++ b/internal/controller/owned_test.go @@ -21,7 +21,7 @@ def compose(variable): "apiVersion": "v1", "kind": "ConfigMap", "metadata": { "name": "keeper", - "annotations": {"weft.run/owned": "false"}, + "annotations": {"weft.alethic.solutions/owned": "false"}, }, "data": {"kept": "yes"}, }) @@ -151,13 +151,13 @@ func TestOwnedAnnotationRejectsAnythingElse(t *testing.T) { def compose(variable): resource({ "apiVersion": "v1", "kind": "ConfigMap", - "metadata": {"name": "x", "annotations": {"weft.run/owned": "no"}}, + "metadata": {"name": "x", "annotations": {"weft.alethic.solutions/owned": "no"}}, }) `, "") h.settle("typo", 2) c := requireCondition(t, h.weave("typo"), naming.ConditionDegraded, metav1.ConditionTrue) - if !containsAll(c.Message, "weft.run/owned", `"true"`, `"false"`) { + if !containsAll(c.Message, "weft.alethic.solutions/owned", `"true"`, `"false"`) { t.Errorf("message should say what the accepted values are: %s", c.Message) } if h.exists("x") { diff --git a/internal/controller/reclaim_test.go b/internal/controller/reclaim_test.go index 7c37901..14b4453 100644 --- a/internal/controller/reclaim_test.go +++ b/internal/controller/reclaim_test.go @@ -45,7 +45,7 @@ func TestUnownedResourceIsReclaimedAfterStatusLoss(t *testing.T) { def compose(variable): resource({ "apiVersion": "v1", "kind": "ConfigMap", - "metadata": {"name": "keeper", "annotations": {"weft.run/owned": "false"}}, + "metadata": {"name": "keeper", "annotations": {"weft.alethic.solutions/owned": "false"}}, "data": {"v": "1"}, }) `, "") diff --git a/internal/controller/release.go b/internal/controller/release.go index b9228c7..334b823 100644 --- a/internal/controller/release.go +++ b/internal/controller/release.go @@ -23,7 +23,7 @@ import ( // takes our reference back off when we stop setting it, and this catches an // object that was already dropped from the returned set in the same pass. // -// What stays is the weft.run/weave label and the weft.run/key annotation, so a +// What stays is the weft.alethic.solutions/weave label and the weft.alethic.solutions/key annotation, so a // released object is still findable as something this Weave once made. func (r *WeaveReconciler) releaseAll(ctx context.Context, c *kube.Client, weave *v1alpha1.Weave, entries []v1alpha1.InventoryEntry) error { log := logf.FromContext(ctx) diff --git a/internal/controller/weave_controller.go b/internal/controller/weave_controller.go index 9471fe8..84a685e 100644 --- a/internal/controller/weave_controller.go +++ b/internal/controller/weave_controller.go @@ -162,9 +162,9 @@ func (r *WeaveReconciler) SetupWithManager(mgr ctrl.Manager) error { // impersonate on groups is pinned to two names. Granting it unrestricted would // let anyone who compromised the controller impersonate system:masters. // -// +kubebuilder:rbac:groups=weft.run,resources=weaves,verbs=get;list;watch;update;patch -// +kubebuilder:rbac:groups=weft.run,resources=weaves/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=weft.run,resources=weaves/finalizers,verbs=update +// +kubebuilder:rbac:groups=weft.alethic.solutions,resources=weaves,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=weft.alethic.solutions,resources=weaves/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=weft.alethic.solutions,resources=weaves/finalizers,verbs=update // +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=impersonate // +kubebuilder:rbac:groups="",resources=groups,verbs=impersonate,resourceNames=system:serviceaccounts;system:authenticated // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch diff --git a/internal/inventory/inventory.go b/internal/inventory/inventory.go index 7a714cd..78d6b2d 100644 --- a/internal/inventory/inventory.go +++ b/internal/inventory/inventory.go @@ -39,7 +39,7 @@ type Item struct { // deleted in descending wave. Wave int32 - // Owned is false when the resource carries weft.run/owned=false. An unowned + // Owned is false when the resource carries weft.alethic.solutions/owned=false. An unowned // resource is applied and kept current, but no owner reference is placed on // it and it is never deleted by this Weave. Owned bool diff --git a/internal/naming/naming.go b/internal/naming/naming.go index bf58720..b66e11f 100644 --- a/internal/naming/naming.go +++ b/internal/naming/naming.go @@ -1,6 +1,6 @@ // Package naming is the single source of truth for every externally visible -// identifier Weft writes into a cluster. The API group is provisional; renaming -// it must be one edit here, so nothing anywhere else may hardcode "weft.run". +// identifier Weft writes into a cluster. Renaming the API group must be one +// edit here, so nothing anywhere else may hardcode "weft.alethic.solutions". package naming import ( @@ -11,7 +11,7 @@ import ( const ( // Group is the API group. Everything below derives from it. - Group = "weft.run" + Group = "weft.alethic.solutions" // Version is the current (and only) API version. Version = "v1alpha1" // Kind is the singular kind name. From c6c0b6cf06a6ee8f742f4ea395603d431826ead4 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 11:16:35 -0500 Subject: [PATCH 2/9] Serve weft.alethic.solutions/v1 alongside weft.run/v1alpha1 The previous commit renamed the API group outright, which would strand every Weave in clusters that already run weft.run. Instead, serve both: weft.alethic.solutions/v1 is the current API, and weft.run/v1alpha1 keeps working unchanged so existing installations can upgrade without touching anything and move their Weaves across one at a time. Kubernetes cannot convert between groups, so these are two CRDs. The canonical types live in api/v1; api/v1alpha1 embeds v1's spec and status so the schemas cannot drift, and a test asserts the generated CRDs agree. The weft.run CRD is unchanged apart from descriptions. internal/naming now describes each group, and every key a Weave writes follows its own group: finalizers, provenance, the owned and adopt annotations, and the server-side-apply field manager. The weft.run identifiers are pinned by a test, because they are on objects that predate v1. The leader election lease keeps its old name so a rolling upgrade's old and new replicas share one. The controller runs one reconciler per group, working on v1 internally and converting the envelope at the API boundary. The watch registry, metrics and kube client are group-aware, so a weft.run Weave and a v1 Weave of the same name are fully independent. Moving a Weave is a delete with --cascade=orphan and a create. A v1 Weave takes over what its same-named weft.run predecessor made, in place, once that predecessor is gone: it transfers field ownership from the weft.run manager to its own, so its first apply prunes the old provenance and anything else the program no longer sets. While the predecessor exists it refuses with the exact kubectl command to finish the move; while it is deleting it waits. A v1 program still using weft.run/owned is refused rather than silently owning what it meant to keep. The chart installs both CRDs and grants both groups by default; legacyAPI.enabled=false drops the CRD, the grant and the reconciler together. reap covers both groups. docs/migrating.md describes the move. Per-Weave metrics gain a group label, and the weft.run controller is now named weave-weft-run in controller-runtime's metrics. --- .github/workflows/weft.yml | 1 + CONTRIBUTING.md | 20 +- Makefile | 14 +- README.md | 17 +- api/v1/groupversion_info.go | 47 ++ api/v1/groupversion_test.go | 136 ++++++ api/v1/weave_types.go | 309 +++++++++++++ api/v1/zz_generated.deepcopy.go | 231 ++++++++++ api/v1alpha1/groupversion_info.go | 21 +- api/v1alpha1/groupversion_test.go | 101 ----- api/v1alpha1/weave_types.go | 287 +------------ api/v1alpha1/zz_generated.deepcopy.go | 163 ------- charts/weft/Chart.yaml | 11 +- charts/weft/README.md | 17 +- .../weft.alethic.solutions_weaves.yaml} | 6 +- charts/weft/files/crds/weft.run_weaves.yaml | 405 ++++++++++++++++++ charts/weft/templates/NOTES.txt | 7 + charts/weft/templates/_helpers.tpl | 1 + charts/weft/templates/crds.yaml | 31 +- charts/weft/templates/rbac.yaml | 12 + charts/weft/values.schema.json | 6 + charts/weft/values.yaml | 16 +- cmd/weft/chart_test.go | 110 +++-- cmd/weft/main.go | 67 ++- cmd/weft/reap.go | 85 ++-- config/crd/weft.alethic.solutions_weaves.yaml | 6 +- config/crd/weft.run_weaves.yaml | 405 ++++++++++++++++++ config/rbac/role.yaml | 3 + demo/01-app-stack.yaml | 2 +- demo/02-waiting.yaml | 2 +- demo/03-forbidden.yaml | 2 +- demo/04-teardown.yaml | 2 +- demo/05-pruning.yaml | 2 +- demo/06-finalizer.yaml | 2 +- demo/07-bounds.yaml | 16 +- docs/migrating.md | 156 +++++++ docs/operating.md | 6 +- examples/app.yaml | 2 +- examples/env.yaml | 2 +- examples/release-values.yaml | 2 +- examples/sql.yaml | 2 +- internal/controller/adopt.go | 163 ++++++- internal/controller/adopt_test.go | 4 +- internal/controller/apply.go | 18 +- internal/controller/bootstrap_cluster_test.go | 42 +- internal/controller/bootstrap_envtest_test.go | 2 + internal/controller/conditions.go | 11 +- internal/controller/edit_test.go | 12 +- internal/controller/finalizers.go | 29 +- internal/controller/handover_test.go | 374 ++++++++++++++++ internal/controller/observed.go | 6 +- internal/controller/owned_test.go | 6 +- internal/controller/read_test.go | 12 +- internal/controller/reader.go | 24 +- internal/controller/reclaim_test.go | 14 +- internal/controller/release.go | 13 +- internal/controller/served.go | 72 ++++ internal/controller/suite_test.go | 49 ++- internal/controller/variables.go | 8 +- internal/controller/variables_test.go | 44 +- internal/controller/weave_controller.go | 132 ++++-- internal/examples/examples_test.go | 17 +- internal/inventory/inventory.go | 59 ++- internal/inventory/inventory_test.go | 55 ++- internal/kube/client.go | 126 +++++- internal/kube/normalize.go | 17 +- internal/metrics/metrics.go | 23 +- internal/naming/naming.go | 231 ++++++---- internal/naming/naming_test.go | 143 +++++-- internal/variables/variables.go | 4 +- internal/variables/variables_test.go | 12 +- internal/watches/registry.go | 80 ++-- 72 files changed, 3422 insertions(+), 1113 deletions(-) create mode 100644 api/v1/groupversion_info.go create mode 100644 api/v1/groupversion_test.go create mode 100644 api/v1/weave_types.go create mode 100644 api/v1/zz_generated.deepcopy.go delete mode 100644 api/v1alpha1/groupversion_test.go rename charts/weft/files/{weaves-crd.yaml => crds/weft.alethic.solutions_weaves.yaml} (99%) create mode 100644 charts/weft/files/crds/weft.run_weaves.yaml create mode 100644 config/crd/weft.run_weaves.yaml create mode 100644 docs/migrating.md create mode 100644 internal/controller/handover_test.go create mode 100644 internal/controller/served.go diff --git a/.github/workflows/weft.yml b/.github/workflows/weft.yml index 320d1ec..6427fce 100644 --- a/.github/workflows/weft.yml +++ b/.github/workflows/weft.yml @@ -146,6 +146,7 @@ jobs: render --set metrics.enabled=false render --set crds.install=false render --set crds.keep=false + render --set legacyAPI.enabled=false render --set controller.watchNamespace=team-a render --set rbac.impersonation.scope=namespaced --set 'rbac.impersonation.namespaces={a,b}' render --set replicaCount=2 --set podDisruptionBudget.enabled=true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 991a294..82a1537 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,9 +151,23 @@ one of those looks arbitrary, read the comment before changing it: make generate ``` -This regenerates the deepcopy functions, the CRD and the controller ClusterRole, -and copies the CRD into the chart. CI fails if the result differs from what is -committed, so run it before pushing. +This regenerates the deepcopy functions, the CRDs and the controller +ClusterRole, and copies the CRDs into the chart. CI fails if the result differs +from what is committed, so run it before pushing. + +## Two API groups + +Weft serves `weft.alethic.solutions/v1` and, for installations that predate it, +`weft.run/v1alpha1`. The canonical types live in `api/v1`; `api/v1alpha1` +embeds v1's spec and status, so the schemas cannot drift, and a test asserts +that the generated CRDs agree. The controller reconciles everything as v1, +with one reconciler per group (`internal/controller/served.go`). + +Every key a Weave writes into a cluster comes from `internal/naming`, per +group. The `weft.run` identifiers are frozen, and `TestLegacyIdentifiersAreFrozen` +fails if one changes: they are on objects in clusters that predate v1, and +changing one strands those objects. [docs/migrating.md](docs/migrating.md) +describes the handover from one group to the other from the user's side. ## The parts that are not negotiable diff --git a/Makefile b/Makefile index 4fbf0f3..870fd2e 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,8 @@ IMG ?= ghcr.io/alethic/weft:latest # GitVersion owns the version. It is derived from the branch and the history, # so nothing is tagged by hand and a build knows what it is without being told. # git describe is the fallback for a checkout without the tool installed. -VERSION ?= $(shell dotnet-gitversion -showvariable SemVer 2>/dev/null | tr -d ' ' || true) +VERSION ?= $(shell dotnet-gitversion -showvariable SemVer 2>/dev/null | tr -d ' +' || true) ifeq ($(strip $(VERSION)),) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) endif @@ -27,12 +28,13 @@ all: generate fmt vet lint test build .PHONY: generate generate: ## Regenerate deepcopy, the CRD and the controller ClusterRole. - $(CONTROLLER_GEN) object paths=./api/v1alpha1/... - $(CONTROLLER_GEN) crd paths=./api/v1alpha1/... output:crd:artifacts:config=config/crd + $(CONTROLLER_GEN) object paths=./api/v1/... paths=./api/v1alpha1/... + $(CONTROLLER_GEN) crd paths=./api/v1/... paths=./api/v1alpha1/... output:crd:artifacts:config=config/crd $(CONTROLLER_GEN) rbac:roleName=weft-controller paths=./internal/controller/... output:rbac:artifacts:config=config/rbac - # The chart ships the generated CRD verbatim so the two cannot drift. + # The chart ships the generated CRDs verbatim so the two cannot drift. # TestChartCRDMatchesGenerated fails if this copy is skipped. - cp config/crd/weft.alethic.solutions_weaves.yaml $(CHART)/files/weaves-crd.yaml + mkdir -p $(CHART)/files/crds + cp config/crd/*.yaml $(CHART)/files/crds/ .PHONY: fmt fmt: @@ -77,7 +79,7 @@ lint-chart: ## Lint and render the chart. .PHONY: install install: ## Install just the CRD. - kubectl apply -f config/crd/weft.alethic.solutions_weaves.yaml + kubectl apply -f config/crd/ .PHONY: deploy deploy: ## Install the controller with Helm. diff --git a/README.md b/README.md index eb14a72..77fec0f 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Two properties set it apart: ## At a glance ```yaml -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: app @@ -140,7 +140,7 @@ kubectl -n weft-quickstart create configmap tenant --from-literal=tenantId=acme- ```bash kubectl apply -f - <<'EOF' -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: hello @@ -188,13 +188,20 @@ workloads. | [Operating Weft](docs/operating.md) | status conditions, pruning, teardown, controller flags, metrics, uninstalling | | [Design and alternatives](docs/design.md) | how Weft compares with kro, Crossplane, Flux and Kyverno; why Starlark; non-goals | | [Helm chart](charts/weft/README.md) | installation, every value, common configurations | +| [Moving from weft.run](docs/migrating.md) | for installations that predate `weft.alethic.solutions/v1` | | [Security](SECURITY.md) | threat model, hardening, reporting a vulnerability | ## Project status -Weft is **alpha**. The API is `weft.alethic.solutions/v1alpha1`, and its shape may still -change between minor versions before `1.0`; breaking changes will be called out -in the release notes. +The API is `weft.alethic.solutions/v1`. Weft itself is pre-1.0, so the +controller, the chart and their defaults may still change between minor +releases. Every such change is called out in the release notes. + +Weft was first deployed under the API group `weft.run/v1alpha1`. That group is +still served alongside v1, with the same schema, so existing installations +upgrade without changing anything and can move their Weaves to v1 one at a +time. [docs/migrating.md](docs/migrating.md) explains how. New installations +can leave it out with `--set legacyAPI.enabled=false`. CI runs the reconcile loop against a real API server on Kubernetes 1.27 (the minimum supported), 1.33 and 1.37, alongside unit tests of the evaluator and diff --git a/api/v1/groupversion_info.go b/api/v1/groupversion_info.go new file mode 100644 index 0000000..00efcde --- /dev/null +++ b/api/v1/groupversion_info.go @@ -0,0 +1,47 @@ +// Package v1 contains the Weave API, weft.alethic.solutions/v1. +// +// These are the canonical types. weft.run/v1alpha1 serves the same spec and +// status under the group Weft was first deployed with, by embedding them, so +// the two schemas cannot drift apart. +// +// The +groupName marker below is the one place the API group appears as a +// literal: kubebuilder reads comments, not constants, so it cannot be derived +// from naming.V1. TestGroupMarkerMatchesNaming asserts the two agree. +// +// +kubebuilder:object:generate=true +// +groupName=weft.alethic.solutions +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/alethic/weft/internal/naming" +) + +var ( + // GroupVersion is the group version used to register these objects. + GroupVersion = schema.GroupVersion{Group: naming.V1.Group, Version: naming.V1.Version} + + // SchemeBuilder registers the types in this group-version. + // + // This is apimachinery's builder rather than controller-runtime's, which is + // deprecated for api packages: an api package should be cheap to import, + // and pulling controller-runtime in makes it anything but. + SchemeBuilder = &runtime.SchemeBuilder{} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) + +// addKnownTypes registers every type in this group-version. +func addKnownTypes(s *runtime.Scheme) error { + s.AddKnownTypes(GroupVersion, &Weave{}, &WeaveList{}) + metav1.AddToGroupVersion(s, GroupVersion) + return nil +} + +func init() { + SchemeBuilder.Register(addKnownTypes) +} diff --git a/api/v1/groupversion_test.go b/api/v1/groupversion_test.go new file mode 100644 index 0000000..0d414d2 --- /dev/null +++ b/api/v1/groupversion_test.go @@ -0,0 +1,136 @@ +package v1_test + +import ( + "os" + "regexp" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + + v1 "github.com/alethic/weft/api/v1" + "github.com/alethic/weft/api/v1alpha1" + "github.com/alethic/weft/internal/naming" +) + +// served pairs each API package with the naming entry it must agree with. +var served = []struct { + dir string + gv schema.GroupVersion + api naming.API +}{ + {"../v1", v1.GroupVersion, naming.V1}, + {"../v1alpha1", v1alpha1.GroupVersion, naming.Legacy}, +} + +// Everything derives from the naming package - everything except the +// +groupName marker, which kubebuilder reads as a comment and which therefore +// cannot reference a constant. +// +// This test is what keeps that one exception honest: a marker that disagrees +// with naming fails here instead of shipping a CRD in one group and a +// controller that talks to another. +func TestGroupMarkerMatchesNaming(t *testing.T) { + marker := regexp.MustCompile(`(?m)^//\s*\+groupName=(\S+)\s*$`) + for _, s := range served { + src, err := os.ReadFile(s.dir + "/groupversion_info.go") + if err != nil { + t.Fatal(err) + } + m := marker.FindSubmatch(src) + if m == nil { + t.Fatalf("%s has no +groupName marker; controller-gen needs one to emit the CRD", s.dir) + } + if got := string(m[1]); got != s.api.Group { + t.Errorf("%s: +groupName=%s but naming says %q; the CRD and the controller would disagree", + s.dir, got, s.api.Group) + } + } +} + +func TestGroupVersionMatchesNaming(t *testing.T) { + for _, s := range served { + if s.gv.String() != s.api.GroupVersion { + t.Errorf("GroupVersion = %q, want %q", s.gv.String(), s.api.GroupVersion) + } + } +} + +// The generated CRDs must agree too, since they are what the API server +// enforces. +func TestGeneratedCRDMatchesNaming(t *testing.T) { + for _, s := range served { + path := "../../config/crd/" + s.api.Group + "_" + naming.Plural + ".yaml" + src, err := os.ReadFile(path) + if err != nil { + t.Skipf("generated CRD not present at %s; run make generate", path) + } + text := string(src) + + for _, want := range []string{ + "name: " + s.api.CRDName, + "group: " + s.api.Group, + "kind: " + naming.Kind, + "plural: " + naming.Plural, + "name: " + s.api.Version, + } { + if !strings.Contains(text, want) { + t.Errorf("%s is missing %q", path, want) + } + } + + if !strings.Contains(text, "scope: Namespaced") { + t.Errorf("%s: the CRD must be namespaced: a cluster-scoped kind would put a platform team back "+ + "in the loop, which is the property the project exists to remove", path) + } + } +} + +// The two groups serve one schema. If they ever differed, a Weave moved from +// weft.run to v1 could be accepted by one and rejected by the other. Only the +// kind's own description may differ, so the comparison starts below it. +func TestServedSchemasAreIdentical(t *testing.T) { + read := func(api naming.API) string { + src, err := os.ReadFile("../../config/crd/" + api.Group + "_" + naming.Plural + ".yaml") + if err != nil { + t.Skipf("generated CRDs not present; run make generate") + } + text := strings.ReplaceAll(string(src), "\r\n", "\n") + i := strings.Index(text, " properties:\n apiVersion:") + j := strings.Index(text, " served: true") + if i < 0 || j < i { + t.Fatalf("cannot find the schema in the %s CRD", api.Group) + } + return text[i:j] + } + if read(naming.V1) != read(naming.Legacy) { + t.Error("weft.run/v1alpha1 and weft.alethic.solutions/v1 have different schemas") + } +} + +func TestHelpers(t *testing.T) { + status := v1.WeaveStatus{ + Inventory: []v1.InventoryEntry{ + {APIVersion: "v1", Kind: "ConfigMap", Name: "a"}, + {APIVersion: "v1", Kind: "ConfigMap", Name: "b"}, + }, + Held: []v1.HeldResource{ + {APIVersion: "v1", Kind: "ConfigMap", Name: "upstream"}, + }, + } + if e, ok := status.InventoryFor("v1", "ConfigMap", "b"); !ok || e.Name != "b" { + t.Error("InventoryFor should find an entry") + } + if _, ok := status.InventoryFor("v1", "ConfigMap", "c"); ok { + t.Error("InventoryFor should not invent one") + } + if _, ok := status.InventoryFor("v1", "Secret", "b"); ok { + t.Error("InventoryFor must match on kind, not only name") + } + if _, ok := status.HeldByRef("v1", "ConfigMap", "upstream"); !ok { + t.Error("HeldByRef should find a recorded hold") + } + if _, ok := status.HeldByRef("v1", "Secret", "upstream"); ok { + t.Error("HeldByRef must match on kind, not only name") + } +} diff --git a/api/v1/weave_types.go b/api/v1/weave_types.go new file mode 100644 index 0000000..fc15a32 --- /dev/null +++ b/api/v1/weave_types.go @@ -0,0 +1,309 @@ +package v1 + +import ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// Variable is one entry in spec.variables. Exactly one field is set. +// +// +kubebuilder:validation:XValidation:rule="(has(self.values) ? 1 : 0) + (has(self.configMap) ? 1 : 0) + (has(self.secret) ? 1 : 0) == 1",message="set exactly one of values, configMap or secret" +type Variable struct { + // Values is configuration written here. Plain YAML, never templated or + // evaluated. + // + // The explicit type is what lets the validation rule above see this field + // at all: a property with preserved unknown fields and no type is invisible + // to CEL, and the rule fails to compile rather than failing to apply. + // + // +optional + // +kubebuilder:validation:Type=object + // +kubebuilder:pruning:PreserveUnknownFields + Values *apiextensionsv1.JSON `json:"values,omitempty"` + + // ConfigMap takes configuration from a ConfigMap in this namespace. + // + // +optional + ConfigMap *VariableRef `json:"configMap,omitempty"` + + // Secret takes configuration from a Secret in this namespace. + // + // Read through the same impersonated client as everything else, so a Weave + // can only read a Secret its ServiceAccount could read directly. Be aware + // that a value reaching a program can be written into any resource that + // ServiceAccount may create. + // + // +optional + Secret *VariableRef `json:"secret,omitempty"` +} + +// VariableRef selects a ConfigMap or Secret to take configuration from. +type VariableRef struct { + // Name of the object, in the Weave's own namespace. + // + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Name string `json:"name"` + + // Key selects a single entry, whose content is parsed as YAML and merged as + // a mapping. Without it every entry becomes one variable, with its value as a + // string. + // + // This is the difference between a ConfigMap of flat settings and one + // holding a values.yaml document, and both are common enough to deserve + // support. + // + // +optional + Key string `json:"key,omitempty"` + + // Optional skips this entry when the object does not exist. Without it a + // missing object leaves the Weave waiting, because a composition built on + // configuration that has not arrived is not ready. + // + // +optional + Optional bool `json:"optional,omitempty"` +} + +// WeaveSpec defines a composition. +type WeaveSpec struct { + // ServiceAccountName names a ServiceAccount in this namespace. Every read + // and every write Weft performs for this Weave is impersonated as that + // ServiceAccount. + // + // The controller holds impersonation rights, not write access, so a Weave + // can never cause the creation of anything its ServiceAccount could not + // have created directly. + // + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + ServiceAccountName string `json:"serviceAccountName"` + + // Variables is static configuration, assembled from these entries and + // reaching the program as one mapping bound to variable. + // + // Entries are merged in order and later ones win, so a base can come from a + // ConfigMap somebody else maintains and be overridden inline here. Mappings + // merge key by key; anything else is replaced outright, which is the same + // rule Helm values follow and the one people already expect. + // + // A program cannot tell where a value came from, which is the point: moving + // a setting from inline to a ConfigMap is not a change to the composition. + // + // Variables are keys; sources are resources. Nothing here is watched for + // the sake of an ordering edge - a ConfigMap named here is read for the + // values in it, and a change to it re-runs the program. + // + // +optional + // +listType=atomic + Variables []Variable `json:"variables,omitempty"` + + // Program is a Starlark program defining compose(variable), which declares + // the resources that should exist by calling resource() on each of them. + // + // A resource is identified by the apiVersion, kind and name in its own body, + // which is the only identity anything outside the program can see. Renaming + // one is not a rename: it stops declaring one object and starts declaring + // another, so the first is pruned and the second created. + // + // What a declared object looks like in the cluster now is reached through the + // value resource() returned, not through a second parameter, because it is a + // fact about that resource rather than a mapping to look it up in. + // + // +kubebuilder:validation:MinLength=1 + Program string `json:"program"` +} + +// InventoryEntry records one resource this Weave created. +type InventoryEntry struct { + // APIVersion, Kind and Name identify the live object, and are its whole + // identity. A composition describes objects, so there is nothing else for + // an entry to be about. Its namespace is + // always the Weave's own. + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Name string `json:"name"` + + // UID of the applied object, recorded so teardown can tell a resource we + // created from a later resource that happens to share its name. + // + // +optional + UID types.UID `json:"uid,omitempty"` + + // Wave is the apply order. Resources are applied in ascending wave and + // deleted in descending wave, because cascading garbage collection is + // unordered and self-reference creates a real internal DAG. + Wave int32 `json:"wave"` + + // Owned records whether this Weave holds an owner reference on the + // resource. False means it was applied with the owned annotation set to + // "false" and will never be deleted by this Weave. + // + // It is remembered here rather than read from the object, because the + // moment it matters is the moment the program stopped returning it - there + // is no returned object left to read it from. + // + // Written on every entry rather than omitted when false, so that reading + // the inventory shows which resources this Weave will take with it and + // which it will leave standing. + Owned bool `json:"owned"` + + // MissingCount is the number of consecutive successful evaluations in + // which this resource was not returned. + // + // It stops climbing once it reaches the threshold. That is not cosmetic: + // a status write wakes the Weave through its own watch, so a field that + // changes on every pass is a self-sustaining reconcile loop. + // + // +optional + MissingCount int32 `json:"missingCount,omitempty"` + + // MissingSince is when this resource first stopped being returned by a + // successful evaluation, and is cleared the moment it comes back. + // + // Pruning waits on elapsed time, not only on a count. Reconciles are + // event-driven and the applies in a single pass generate watch events of + // their own, so several "consecutive evaluations" can complete inside a + // second - which is no protection at all against the thing hysteresis + // exists for. A managed resource drops its status for as long as its + // provider takes to restart, and a program waiting on that status + // legitimately stops returning whatever depends on it for that whole + // period. Only a clock measures that. + // + // +optional + MissingSince *metav1.Time `json:"missingSince,omitempty"` + + // There is deliberately no "last applied" timestamp here. Every reconcile + // re-applies, so such a field would change on every pass, and because a + // status write wakes the Weave through its own watch it would spin every + // Weave in the cluster forever for a purely informational value. The + // object's own managedFields already record when Weft last wrote to it. +} + +// HeldResource is a resource this Weave has placed a finalizer on, because a +// program read it with hold=True. +// +// It is recorded rather than derived because the request lives in the program, +// and a program that stops asking - or stops running at all - must still leave +// something behind that says what to release. +type HeldResource struct { + // APIVersion of the held resource. + APIVersion string `json:"apiVersion"` + + // Kind of the held resource. + Kind string `json:"kind"` + + // Name of the held resource, in the Weave's own namespace. + Name string `json:"name"` + + // TeardownStartedAt is set when a held resource began deleting. It starts + // the clock on the hard timeout after which the finalizer is released + // regardless of teardown progress. + // + // +optional + TeardownStartedAt *metav1.Time `json:"teardownStartedAt,omitempty"` +} + +// ReadRef identifies one resource a program read, or one selection it made. +type ReadRef struct { + // APIVersion of what was read. + APIVersion string `json:"apiVersion"` + + // Kind of what was read. + Kind string `json:"kind"` +} + +// WeaveStatus is the observed state of a Weave. +type WeaveStatus struct { + // ObservedGeneration is the spec generation most recently evaluated. + // + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Conditions follow the standard Ready / Waiting / Degraded split. + // + // Waiting deserves emphasis: resolution has three outcomes, not two, and + // "not yet" is the normal one. A resource that is not there, an unpopulated status + // field and an explicit wait() all land here, each naming the specific + // thing that has not resolved. + // + // +optional + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + + // Inventory is every resource this Weave currently owns, with its apply + // order. + // + // +optional + // +listType=atomic + Inventory []InventoryEntry `json:"inventory,omitempty"` + + // Reads records the kinds this composition read on its last successful + // pass. It is the watch set: a program that reads a ResourceGroup has to + // be woken when one changes, and the only record of that is what it + // actually asked for. + // + // Kept across restarts so a Weave that is waiting still has its watches + // re-established without having to evaluate first. + // + // +optional + // +listType=atomic + Reads []ReadRef `json:"reads,omitempty"` + + // Held records the resources this Weave has placed a finalizer on. + // + // +optional + // +listType=atomic + Held []HeldResource `json:"held,omitempty"` +} + +// Weave reads existing resources in its namespace and generates others from +// them, reactively, as the ServiceAccount it names. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=wv +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=".status.conditions[?(@.type=='Ready')].status" +// +kubebuilder:printcolumn:name="Waiting",type=string,JSONPath=".status.conditions[?(@.type=='Waiting')].reason" +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=".metadata.creationTimestamp" +type Weave struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec WeaveSpec `json:"spec,omitempty"` + Status WeaveStatus `json:"status,omitempty"` +} + +// WeaveList contains a list of Weave. +// +// +kubebuilder:object:root=true +type WeaveList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Weave `json:"items"` +} + +// InventoryFor returns the inventory entry for an object. +func (s *WeaveStatus) InventoryFor(apiVersion, kind, name string) (*InventoryEntry, bool) { + for i := range s.Inventory { + e := &s.Inventory[i] + if e.APIVersion == apiVersion && e.Kind == kind && e.Name == name { + return e, true + } + } + return nil, false +} + +// HeldByRef returns the tracked hold on a resource. +func (s *WeaveStatus) HeldByRef(apiVersion, kind, name string) (*HeldResource, bool) { + for i := range s.Held { + h := &s.Held[i] + if h.APIVersion == apiVersion && h.Kind == kind && h.Name == name { + return h, true + } + } + return nil, false +} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go new file mode 100644 index 0000000..8495dc7 --- /dev/null +++ b/api/v1/zz_generated.deepcopy.go @@ -0,0 +1,231 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1 + +import ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HeldResource) DeepCopyInto(out *HeldResource) { + *out = *in + if in.TeardownStartedAt != nil { + in, out := &in.TeardownStartedAt, &out.TeardownStartedAt + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HeldResource. +func (in *HeldResource) DeepCopy() *HeldResource { + if in == nil { + return nil + } + out := new(HeldResource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InventoryEntry) DeepCopyInto(out *InventoryEntry) { + *out = *in + if in.MissingSince != nil { + in, out := &in.MissingSince, &out.MissingSince + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InventoryEntry. +func (in *InventoryEntry) DeepCopy() *InventoryEntry { + if in == nil { + return nil + } + out := new(InventoryEntry) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReadRef) DeepCopyInto(out *ReadRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReadRef. +func (in *ReadRef) DeepCopy() *ReadRef { + if in == nil { + return nil + } + out := new(ReadRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Variable) DeepCopyInto(out *Variable) { + *out = *in + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } + if in.ConfigMap != nil { + in, out := &in.ConfigMap, &out.ConfigMap + *out = new(VariableRef) + **out = **in + } + if in.Secret != nil { + in, out := &in.Secret, &out.Secret + *out = new(VariableRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Variable. +func (in *Variable) DeepCopy() *Variable { + if in == nil { + return nil + } + out := new(Variable) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VariableRef) DeepCopyInto(out *VariableRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VariableRef. +func (in *VariableRef) DeepCopy() *VariableRef { + if in == nil { + return nil + } + out := new(VariableRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Weave) DeepCopyInto(out *Weave) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Weave. +func (in *Weave) DeepCopy() *Weave { + if in == nil { + return nil + } + out := new(Weave) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Weave) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WeaveList) DeepCopyInto(out *WeaveList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Weave, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WeaveList. +func (in *WeaveList) DeepCopy() *WeaveList { + if in == nil { + return nil + } + out := new(WeaveList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *WeaveList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WeaveSpec) DeepCopyInto(out *WeaveSpec) { + *out = *in + if in.Variables != nil { + in, out := &in.Variables, &out.Variables + *out = make([]Variable, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WeaveSpec. +func (in *WeaveSpec) DeepCopy() *WeaveSpec { + if in == nil { + return nil + } + out := new(WeaveSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WeaveStatus) DeepCopyInto(out *WeaveStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Inventory != nil { + in, out := &in.Inventory, &out.Inventory + *out = make([]InventoryEntry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Reads != nil { + in, out := &in.Reads, &out.Reads + *out = make([]ReadRef, len(*in)) + copy(*out, *in) + } + if in.Held != nil { + in, out := &in.Held, &out.Held + *out = make([]HeldResource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WeaveStatus. +func (in *WeaveStatus) DeepCopy() *WeaveStatus { + if in == nil { + return nil + } + out := new(WeaveStatus) + in.DeepCopyInto(out) + return out +} diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go index fdda2e2..10c74cf 100644 --- a/api/v1alpha1/groupversion_info.go +++ b/api/v1alpha1/groupversion_info.go @@ -1,13 +1,18 @@ -// Package v1alpha1 contains the Weave API. +// Package v1alpha1 contains weft.run/v1alpha1, the group Weft was first +// deployed under. +// +// It is served alongside weft.alethic.solutions/v1 so that installations built +// on it keep working and can move to v1 one Weave at a time. Its spec and +// status are v1's, embedded rather than copied, so the two cannot drift: the +// groups differ in name and in the keys a Weave writes onto what it manages, +// never in what a Weave can express. // // The +groupName marker below is the one place the API group appears as a // literal: kubebuilder reads comments, not constants, so it cannot be derived -// from naming.Group. TestGroupMarkerMatchesNaming asserts the two agree, so a -// rename that misses this file fails the tests rather than shipping a CRD in -// the wrong group. +// from naming.Legacy. TestGroupMarkerMatchesNaming asserts the two agree. // // +kubebuilder:object:generate=true -// +groupName=weft.alethic.solutions +// +groupName=weft.run package v1alpha1 import ( @@ -20,13 +25,9 @@ import ( var ( // GroupVersion is the group version used to register these objects. - GroupVersion = schema.GroupVersion{Group: naming.Group, Version: naming.Version} + GroupVersion = schema.GroupVersion{Group: naming.Legacy.Group, Version: naming.Legacy.Version} // SchemeBuilder registers the types in this group-version. - // - // This is apimachinery's builder rather than controller-runtime's, which is - // deprecated for api packages: an api package should be cheap to import, - // and pulling controller-runtime in makes it anything but. SchemeBuilder = &runtime.SchemeBuilder{} // AddToScheme adds the types in this group-version to the given scheme. diff --git a/api/v1alpha1/groupversion_test.go b/api/v1alpha1/groupversion_test.go deleted file mode 100644 index e97230a..0000000 --- a/api/v1alpha1/groupversion_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package v1alpha1 - -import ( - "os" - "regexp" - "strings" - "testing" - - "github.com/alethic/weft/internal/naming" -) - -// The API group is provisional and expected to change before v1alpha1 is -// published. Everything derives from naming.Group so a rename is one edit - -// everything except the +groupName marker, which kubebuilder reads as a comment -// and which therefore cannot reference a constant. -// -// This test is what keeps that one exception honest: a rename that misses the -// marker fails here instead of shipping a CRD in one group and a controller -// that talks to another. -func TestGroupMarkerMatchesNaming(t *testing.T) { - src, err := os.ReadFile("groupversion_info.go") - if err != nil { - t.Fatal(err) - } - - marker := regexp.MustCompile(`(?m)^//\s*\+groupName=(\S+)\s*$`) - m := marker.FindSubmatch(src) - if m == nil { - t.Fatal("groupversion_info.go has no +groupName marker; controller-gen needs one to emit the CRD") - } - - if got := string(m[1]); got != naming.Group { - t.Errorf("+groupName=%s but naming.Group is %q; the CRD and the controller would disagree", got, naming.Group) - } -} - -func TestGroupVersionMatchesNaming(t *testing.T) { - if GroupVersion.Group != naming.Group { - t.Errorf("GroupVersion.Group = %q, want %q", GroupVersion.Group, naming.Group) - } - if GroupVersion.Version != naming.Version { - t.Errorf("GroupVersion.Version = %q, want %q", GroupVersion.Version, naming.Version) - } - if GroupVersion.String() != naming.GroupVersion { - t.Errorf("GroupVersion = %q, want %q", GroupVersion.String(), naming.GroupVersion) - } -} - -// The generated CRD must agree too, since it is what the API server enforces. -func TestGeneratedCRDMatchesNaming(t *testing.T) { - path := "../../config/crd/" + naming.Group + "_" + naming.Plural + ".yaml" - src, err := os.ReadFile(path) - if err != nil { - t.Skipf("generated CRD not present at %s; run make generate", path) - } - text := string(src) - - for _, want := range []string{ - "name: " + naming.CRDName, - "group: " + naming.Group, - "kind: " + naming.Kind, - "plural: " + naming.Plural, - "name: " + naming.Version, - } { - if !strings.Contains(text, want) { - t.Errorf("generated CRD is missing %q", want) - } - } - - if !strings.Contains(text, "scope: Namespaced") { - t.Error("the CRD must be namespaced: a cluster-scoped kind would put a platform team back in the loop, " + - "which is the property the project exists to remove") - } -} - -func TestHelpers(t *testing.T) { - status := WeaveStatus{ - Inventory: []InventoryEntry{ - {APIVersion: "v1", Kind: "ConfigMap", Name: "a"}, - {APIVersion: "v1", Kind: "ConfigMap", Name: "b"}, - }, - Held: []HeldResource{ - {APIVersion: "v1", Kind: "ConfigMap", Name: "upstream"}, - }, - } - if e, ok := status.InventoryFor("v1", "ConfigMap", "b"); !ok || e.Name != "b" { - t.Error("InventoryFor should find an entry") - } - if _, ok := status.InventoryFor("v1", "ConfigMap", "c"); ok { - t.Error("InventoryFor should not invent one") - } - if _, ok := status.InventoryFor("v1", "Secret", "b"); ok { - t.Error("InventoryFor must match on kind, not only name") - } - if _, ok := status.HeldByRef("v1", "ConfigMap", "upstream"); !ok { - t.Error("HeldByRef should find a recorded hold") - } - if _, ok := status.HeldByRef("v1", "Secret", "upstream"); ok { - t.Error("HeldByRef must match on kind, not only name") - } -} diff --git a/api/v1alpha1/weave_types.go b/api/v1alpha1/weave_types.go index 21c08ba..1de02ca 100644 --- a/api/v1alpha1/weave_types.go +++ b/api/v1alpha1/weave_types.go @@ -1,268 +1,17 @@ package v1alpha1 import ( - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" -) - -// Variable is one entry in spec.variables. Exactly one field is set. -// -// +kubebuilder:validation:XValidation:rule="(has(self.values) ? 1 : 0) + (has(self.configMap) ? 1 : 0) + (has(self.secret) ? 1 : 0) == 1",message="set exactly one of values, configMap or secret" -type Variable struct { - // Values is configuration written here. Plain YAML, never templated or - // evaluated. - // - // The explicit type is what lets the validation rule above see this field - // at all: a property with preserved unknown fields and no type is invisible - // to CEL, and the rule fails to compile rather than failing to apply. - // - // +optional - // +kubebuilder:validation:Type=object - // +kubebuilder:pruning:PreserveUnknownFields - Values *apiextensionsv1.JSON `json:"values,omitempty"` - - // ConfigMap takes configuration from a ConfigMap in this namespace. - // - // +optional - ConfigMap *VariableRef `json:"configMap,omitempty"` - - // Secret takes configuration from a Secret in this namespace. - // - // Read through the same impersonated client as everything else, so a Weave - // can only read a Secret its ServiceAccount could read directly. Be aware - // that a value reaching a program can be written into any resource that - // ServiceAccount may create. - // - // +optional - Secret *VariableRef `json:"secret,omitempty"` -} - -// VariableRef selects a ConfigMap or Secret to take configuration from. -type VariableRef struct { - // Name of the object, in the Weave's own namespace. - // - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=253 - Name string `json:"name"` - - // Key selects a single entry, whose content is parsed as YAML and merged as - // a mapping. Without it every entry becomes one variable, with its value as a - // string. - // - // This is the difference between a ConfigMap of flat settings and one - // holding a values.yaml document, and both are common enough to deserve - // support. - // - // +optional - Key string `json:"key,omitempty"` - - // Optional skips this entry when the object does not exist. Without it a - // missing object leaves the Weave waiting, because a composition built on - // configuration that has not arrived is not ready. - // - // +optional - Optional bool `json:"optional,omitempty"` -} - -// WeaveSpec defines a composition. -type WeaveSpec struct { - // ServiceAccountName names a ServiceAccount in this namespace. Every read - // and every write Weft performs for this Weave is impersonated as that - // ServiceAccount. - // - // The controller holds impersonation rights, not write access, so a Weave - // can never cause the creation of anything its ServiceAccount could not - // have created directly. - // - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=253 - ServiceAccountName string `json:"serviceAccountName"` - - // Variables is static configuration, assembled from these entries and - // reaching the program as one mapping bound to variable. - // - // Entries are merged in order and later ones win, so a base can come from a - // ConfigMap somebody else maintains and be overridden inline here. Mappings - // merge key by key; anything else is replaced outright, which is the same - // rule Helm values follow and the one people already expect. - // - // A program cannot tell where a value came from, which is the point: moving - // a setting from inline to a ConfigMap is not a change to the composition. - // - // Variables are keys; sources are resources. Nothing here is watched for - // the sake of an ordering edge - a ConfigMap named here is read for the - // values in it, and a change to it re-runs the program. - // - // +optional - // +listType=atomic - Variables []Variable `json:"variables,omitempty"` - - // Program is a Starlark program defining compose(variable), which declares - // the resources that should exist by calling resource() on each of them. - // - // A resource is identified by the apiVersion, kind and name in its own body, - // which is the only identity anything outside the program can see. Renaming - // one is not a rename: it stops declaring one object and starts declaring - // another, so the first is pruned and the second created. - // - // What a declared object looks like in the cluster now is reached through the - // value resource() returned, not through a second parameter, because it is a - // fact about that resource rather than a mapping to look it up in. - // - // +kubebuilder:validation:MinLength=1 - Program string `json:"program"` -} - -// InventoryEntry records one resource this Weave created. -type InventoryEntry struct { - // APIVersion, Kind and Name identify the live object, and are its whole - // identity. A composition describes objects, so there is nothing else for - // an entry to be about. Its namespace is - // always the Weave's own. - APIVersion string `json:"apiVersion"` - Kind string `json:"kind"` - Name string `json:"name"` - - // UID of the applied object, recorded so teardown can tell a resource we - // created from a later resource that happens to share its name. - // - // +optional - UID types.UID `json:"uid,omitempty"` - - // Wave is the apply order. Resources are applied in ascending wave and - // deleted in descending wave, because cascading garbage collection is - // unordered and self-reference creates a real internal DAG. - Wave int32 `json:"wave"` - - // Owned records whether this Weave holds an owner reference on the - // resource. False means it was applied with weft.alethic.solutions/owned=false and will - // never be deleted by this Weave. - // - // It is remembered here rather than read from the object, because the - // moment it matters is the moment the program stopped returning it - there - // is no returned object left to read it from. - // - // Written on every entry rather than omitted when false, so that reading - // the inventory shows which resources this Weave will take with it and - // which it will leave standing. - Owned bool `json:"owned"` - - // MissingCount is the number of consecutive successful evaluations in - // which this resource was not returned. - // - // It stops climbing once it reaches the threshold. That is not cosmetic: - // a status write wakes the Weave through its own watch, so a field that - // changes on every pass is a self-sustaining reconcile loop. - // - // +optional - MissingCount int32 `json:"missingCount,omitempty"` - - // MissingSince is when this resource first stopped being returned by a - // successful evaluation, and is cleared the moment it comes back. - // - // Pruning waits on elapsed time, not only on a count. Reconciles are - // event-driven and the applies in a single pass generate watch events of - // their own, so several "consecutive evaluations" can complete inside a - // second - which is no protection at all against the thing hysteresis - // exists for. A managed resource drops its status for as long as its - // provider takes to restart, and a program waiting on that status - // legitimately stops returning whatever depends on it for that whole - // period. Only a clock measures that. - // - // +optional - MissingSince *metav1.Time `json:"missingSince,omitempty"` - - // There is deliberately no "last applied" timestamp here. Every reconcile - // re-applies, so such a field would change on every pass, and because a - // status write wakes the Weave through its own watch it would spin every - // Weave in the cluster forever for a purely informational value. The - // object's own managedFields already record when Weft last wrote to it. -} - -// HeldResource is a resource this Weave has placed a finalizer on, because a -// program read it with hold=True. -// -// It is recorded rather than derived because the request lives in the program, -// and a program that stops asking - or stops running at all - must still leave -// something behind that says what to release. -type HeldResource struct { - // APIVersion of the held resource. - APIVersion string `json:"apiVersion"` - - // Kind of the held resource. - Kind string `json:"kind"` - - // Name of the held resource, in the Weave's own namespace. - Name string `json:"name"` - - // TeardownStartedAt is set when a held resource began deleting. It starts - // the clock on the hard timeout after which the finalizer is released - // regardless of teardown progress. - // - // +optional - TeardownStartedAt *metav1.Time `json:"teardownStartedAt,omitempty"` -} - -// ReadRef identifies one resource a program read, or one selection it made. -type ReadRef struct { - // APIVersion of what was read. - APIVersion string `json:"apiVersion"` - - // Kind of what was read. - Kind string `json:"kind"` -} - -// WeaveStatus is the observed state of a Weave. -type WeaveStatus struct { - // ObservedGeneration is the spec generation most recently evaluated. - // - // +optional - ObservedGeneration int64 `json:"observedGeneration,omitempty"` - // Conditions follow the standard Ready / Waiting / Degraded split. - // - // Waiting deserves emphasis: resolution has three outcomes, not two, and - // "not yet" is the normal one. A resource that is not there, an unpopulated status - // field and an explicit wait() all land here, each naming the specific - // thing that has not resolved. - // - // +optional - // +listType=map - // +listMapKey=type - // +patchStrategy=merge - // +patchMergeKey=type - Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` - - // Inventory is every resource this Weave currently owns, with its apply - // order. - // - // +optional - // +listType=atomic - Inventory []InventoryEntry `json:"inventory,omitempty"` - - // Reads records the kinds this composition read on its last successful - // pass. It is the watch set: a program that reads a ResourceGroup has to - // be woken when one changes, and the only record of that is what it - // actually asked for. - // - // Kept across restarts so a Weave that is waiting still has its watches - // re-established without having to evaluate first. - // - // +optional - // +listType=atomic - Reads []ReadRef `json:"reads,omitempty"` - - // Held records the resources this Weave has placed a finalizer on. - // - // +optional - // +listType=atomic - Held []HeldResource `json:"held,omitempty"` -} + v1 "github.com/alethic/weft/api/v1" +) // Weave reads existing resources in its namespace and generates others from // them, reactively, as the ServiceAccount it names. // +// This is the weft.run/v1alpha1 form. New Weaves should use +// weft.alethic.solutions/v1, which has the same spec. +// // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Namespaced,shortName=wv @@ -273,8 +22,8 @@ type Weave struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Spec WeaveSpec `json:"spec,omitempty"` - Status WeaveStatus `json:"status,omitempty"` + Spec v1.WeaveSpec `json:"spec,omitempty"` + Status v1.WeaveStatus `json:"status,omitempty"` } // WeaveList contains a list of Weave. @@ -285,25 +34,3 @@ type WeaveList struct { metav1.ListMeta `json:"metadata,omitempty"` Items []Weave `json:"items"` } - -// InventoryFor returns the inventory entry for an object. -func (s *WeaveStatus) InventoryFor(apiVersion, kind, name string) (*InventoryEntry, bool) { - for i := range s.Inventory { - e := &s.Inventory[i] - if e.APIVersion == apiVersion && e.Kind == kind && e.Name == name { - return e, true - } - } - return nil, false -} - -// HeldByRef returns the tracked hold on a resource. -func (s *WeaveStatus) HeldByRef(apiVersion, kind, name string) (*HeldResource, bool) { - for i := range s.Held { - h := &s.Held[i] - if h.APIVersion == apiVersion && h.Kind == kind && h.Name == name { - return h, true - } - } - return nil, false -} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 508f99c..9e90c06 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5,109 +5,9 @@ package v1alpha1 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HeldResource) DeepCopyInto(out *HeldResource) { - *out = *in - if in.TeardownStartedAt != nil { - in, out := &in.TeardownStartedAt, &out.TeardownStartedAt - *out = (*in).DeepCopy() - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HeldResource. -func (in *HeldResource) DeepCopy() *HeldResource { - if in == nil { - return nil - } - out := new(HeldResource) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InventoryEntry) DeepCopyInto(out *InventoryEntry) { - *out = *in - if in.MissingSince != nil { - in, out := &in.MissingSince, &out.MissingSince - *out = (*in).DeepCopy() - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InventoryEntry. -func (in *InventoryEntry) DeepCopy() *InventoryEntry { - if in == nil { - return nil - } - out := new(InventoryEntry) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ReadRef) DeepCopyInto(out *ReadRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReadRef. -func (in *ReadRef) DeepCopy() *ReadRef { - if in == nil { - return nil - } - out := new(ReadRef) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Variable) DeepCopyInto(out *Variable) { - *out = *in - if in.Values != nil { - in, out := &in.Values, &out.Values - *out = new(v1.JSON) - (*in).DeepCopyInto(*out) - } - if in.ConfigMap != nil { - in, out := &in.ConfigMap, &out.ConfigMap - *out = new(VariableRef) - **out = **in - } - if in.Secret != nil { - in, out := &in.Secret, &out.Secret - *out = new(VariableRef) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Variable. -func (in *Variable) DeepCopy() *Variable { - if in == nil { - return nil - } - out := new(Variable) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *VariableRef) DeepCopyInto(out *VariableRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VariableRef. -func (in *VariableRef) DeepCopy() *VariableRef { - if in == nil { - return nil - } - out := new(VariableRef) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Weave) DeepCopyInto(out *Weave) { *out = *in @@ -166,66 +66,3 @@ func (in *WeaveList) DeepCopyObject() runtime.Object { } return nil } - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WeaveSpec) DeepCopyInto(out *WeaveSpec) { - *out = *in - if in.Variables != nil { - in, out := &in.Variables, &out.Variables - *out = make([]Variable, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WeaveSpec. -func (in *WeaveSpec) DeepCopy() *WeaveSpec { - if in == nil { - return nil - } - out := new(WeaveSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WeaveStatus) DeepCopyInto(out *WeaveStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]metav1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.Inventory != nil { - in, out := &in.Inventory, &out.Inventory - *out = make([]InventoryEntry, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.Reads != nil { - in, out := &in.Reads, &out.Reads - *out = make([]ReadRef, len(*in)) - copy(*out, *in) - } - if in.Held != nil { - in, out := &in.Held, &out.Held - *out = make([]HeldResource, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WeaveStatus. -func (in *WeaveStatus) DeepCopy() *WeaveStatus { - if in == nil { - return nil - } - out := new(WeaveStatus) - in.DeepCopyInto(out) - return out -} diff --git a/charts/weft/Chart.yaml b/charts/weft/Chart.yaml index 96c6075..28d1b47 100644 --- a/charts/weft/Chart.yaml +++ b/charts/weft/Chart.yaml @@ -38,14 +38,21 @@ annotations: artifacthub.io/operatorCapabilities: Basic Install artifacthub.io/crds: | - kind: Weave - version: v1alpha1 + version: v1 name: weaves.weft.alethic.solutions displayName: Weave description: | Reads existing resources in its namespace and generates others from them, applying everything as the ServiceAccount it names. + - kind: Weave + version: v1alpha1 + name: weaves.weft.run + displayName: Weave (weft.run, legacy) + description: | + The group Weft was first deployed under, served alongside v1 with the + same schema so existing Weaves can be moved across one at a time. artifacthub.io/crdsExamples: | - - apiVersion: weft.alethic.solutions/v1alpha1 + - apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: app diff --git a/charts/weft/README.md b/charts/weft/README.md index 43288ce..821d23e 100644 --- a/charts/weft/README.md +++ b/charts/weft/README.md @@ -52,13 +52,14 @@ To remove the controller but keep everything the Weaves produced, delete them with `kubectl delete weave --all --cascade=orphan` first. Weft honours orphan propagation: the resources stay and simply stop being managed. -The CRD is left behind on purpose (`crds.keep`). Deleting it deletes every -`Weave` in the cluster, and each `Weave` deleted that way takes the resources it -owns with it — an uninstall should not be able to destroy infrastructure. -Remove it deliberately when you mean to: +The CRDs are left behind on purpose (`crds.keep`). Deleting one deletes every +`Weave` of that group in the cluster, and each `Weave` deleted that way takes +the resources it owns with it — an uninstall should not be able to destroy +infrastructure. Remove them deliberately when you mean to: ```bash kubectl delete crd weaves.weft.alethic.solutions +kubectl delete crd weaves.weft.run # if legacyAPI was enabled ``` ## Values @@ -86,7 +87,13 @@ kubectl delete crd weaves.weft.alethic.solutions | key | default | | |---|---|---| | `crds.install` | `true` | Turn off when CRDs are managed separately. | -| `crds.keep` | `true` | Keep the CRD on uninstall. Read the warning above before changing this. | +| `crds.keep` | `true` | Keep the CRDs on uninstall. Read the warning above before changing this. | + +### Legacy API + +| key | default | | +|---|---|---| +| `legacyAPI.enabled` | `true` | Also serve `weft.run/v1alpha1`, the group Weft was first deployed under: install its CRD, grant the controller access to it, and reconcile its Weaves. A cluster that has never had one can turn this off. Turning it off while `weft.run` Weaves remain leaves them unreconciled and holding finalizers. See [docs/migrating.md](../../docs/migrating.md). | ### RBAC diff --git a/charts/weft/files/weaves-crd.yaml b/charts/weft/files/crds/weft.alethic.solutions_weaves.yaml similarity index 99% rename from charts/weft/files/weaves-crd.yaml rename to charts/weft/files/crds/weft.alethic.solutions_weaves.yaml index aa09785..95a9855 100644 --- a/charts/weft/files/weaves-crd.yaml +++ b/charts/weft/files/crds/weft.alethic.solutions_weaves.yaml @@ -26,7 +26,7 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date - name: v1alpha1 + name: v1 schema: openAPIV3Schema: description: |- @@ -332,8 +332,8 @@ spec: owned: description: |- Owned records whether this Weave holds an owner reference on the - resource. False means it was applied with weft.alethic.solutions/owned=false and will - never be deleted by this Weave. + resource. False means it was applied with the owned annotation set to + "false" and will never be deleted by this Weave. It is remembered here rather than read from the object, because the moment it matters is the moment the program stopped returning it - there diff --git a/charts/weft/files/crds/weft.run_weaves.yaml b/charts/weft/files/crds/weft.run_weaves.yaml new file mode 100644 index 0000000..e546ecd --- /dev/null +++ b/charts/weft/files/crds/weft.run_weaves.yaml @@ -0,0 +1,405 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.22.0 + name: weaves.weft.run +spec: + group: weft.run + names: + kind: Weave + listKind: WeaveList + plural: weaves + shortNames: + - wv + singular: weave + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=='Waiting')].reason + name: Waiting + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + Weave reads existing resources in its namespace and generates others from + them, reactively, as the ServiceAccount it names. + + This is the weft.run/v1alpha1 form. New Weaves should use + weft.alethic.solutions/v1, which has the same spec. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: WeaveSpec defines a composition. + properties: + program: + description: |- + Program is a Starlark program defining compose(variable), which declares + the resources that should exist by calling resource() on each of them. + + A resource is identified by the apiVersion, kind and name in its own body, + which is the only identity anything outside the program can see. Renaming + one is not a rename: it stops declaring one object and starts declaring + another, so the first is pruned and the second created. + + What a declared object looks like in the cluster now is reached through the + value resource() returned, not through a second parameter, because it is a + fact about that resource rather than a mapping to look it up in. + minLength: 1 + type: string + serviceAccountName: + description: |- + ServiceAccountName names a ServiceAccount in this namespace. Every read + and every write Weft performs for this Weave is impersonated as that + ServiceAccount. + + The controller holds impersonation rights, not write access, so a Weave + can never cause the creation of anything its ServiceAccount could not + have created directly. + maxLength: 253 + minLength: 1 + type: string + variables: + description: |- + Variables is static configuration, assembled from these entries and + reaching the program as one mapping bound to variable. + + Entries are merged in order and later ones win, so a base can come from a + ConfigMap somebody else maintains and be overridden inline here. Mappings + merge key by key; anything else is replaced outright, which is the same + rule Helm values follow and the one people already expect. + + A program cannot tell where a value came from, which is the point: moving + a setting from inline to a ConfigMap is not a change to the composition. + + Variables are keys; sources are resources. Nothing here is watched for + the sake of an ordering edge - a ConfigMap named here is read for the + values in it, and a change to it re-runs the program. + items: + description: Variable is one entry in spec.variables. Exactly one + field is set. + properties: + configMap: + description: ConfigMap takes configuration from a ConfigMap + in this namespace. + properties: + key: + description: |- + Key selects a single entry, whose content is parsed as YAML and merged as + a mapping. Without it every entry becomes one variable, with its value as a + string. + + This is the difference between a ConfigMap of flat settings and one + holding a values.yaml document, and both are common enough to deserve + support. + type: string + name: + description: Name of the object, in the Weave's own namespace. + maxLength: 253 + minLength: 1 + type: string + optional: + description: |- + Optional skips this entry when the object does not exist. Without it a + missing object leaves the Weave waiting, because a composition built on + configuration that has not arrived is not ready. + type: boolean + required: + - name + type: object + secret: + description: |- + Secret takes configuration from a Secret in this namespace. + + Read through the same impersonated client as everything else, so a Weave + can only read a Secret its ServiceAccount could read directly. Be aware + that a value reaching a program can be written into any resource that + ServiceAccount may create. + properties: + key: + description: |- + Key selects a single entry, whose content is parsed as YAML and merged as + a mapping. Without it every entry becomes one variable, with its value as a + string. + + This is the difference between a ConfigMap of flat settings and one + holding a values.yaml document, and both are common enough to deserve + support. + type: string + name: + description: Name of the object, in the Weave's own namespace. + maxLength: 253 + minLength: 1 + type: string + optional: + description: |- + Optional skips this entry when the object does not exist. Without it a + missing object leaves the Weave waiting, because a composition built on + configuration that has not arrived is not ready. + type: boolean + required: + - name + type: object + values: + description: |- + Values is configuration written here. Plain YAML, never templated or + evaluated. + + The explicit type is what lets the validation rule above see this field + at all: a property with preserved unknown fields and no type is invisible + to CEL, and the rule fails to compile rather than failing to apply. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + x-kubernetes-validations: + - message: set exactly one of values, configMap or secret + rule: '(has(self.values) ? 1 : 0) + (has(self.configMap) ? 1 : + 0) + (has(self.secret) ? 1 : 0) == 1' + type: array + x-kubernetes-list-type: atomic + required: + - program + - serviceAccountName + type: object + status: + description: WeaveStatus is the observed state of a Weave. + properties: + conditions: + description: |- + Conditions follow the standard Ready / Waiting / Degraded split. + + Waiting deserves emphasis: resolution has three outcomes, not two, and + "not yet" is the normal one. A resource that is not there, an unpopulated status + field and an explicit wait() all land here, each naming the specific + thing that has not resolved. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + held: + description: Held records the resources this Weave has placed a finalizer + on. + items: + description: |- + HeldResource is a resource this Weave has placed a finalizer on, because a + program read it with hold=True. + + It is recorded rather than derived because the request lives in the program, + and a program that stops asking - or stops running at all - must still leave + something behind that says what to release. + properties: + apiVersion: + description: APIVersion of the held resource. + type: string + kind: + description: Kind of the held resource. + type: string + name: + description: Name of the held resource, in the Weave's own namespace. + type: string + teardownStartedAt: + description: |- + TeardownStartedAt is set when a held resource began deleting. It starts + the clock on the hard timeout after which the finalizer is released + regardless of teardown progress. + format: date-time + type: string + required: + - apiVersion + - kind + - name + type: object + type: array + x-kubernetes-list-type: atomic + inventory: + description: |- + Inventory is every resource this Weave currently owns, with its apply + order. + items: + description: InventoryEntry records one resource this Weave created. + properties: + apiVersion: + description: |- + APIVersion, Kind and Name identify the live object, and are its whole + identity. A composition describes objects, so there is nothing else for + an entry to be about. Its namespace is + always the Weave's own. + type: string + kind: + type: string + missingCount: + description: |- + MissingCount is the number of consecutive successful evaluations in + which this resource was not returned. + + It stops climbing once it reaches the threshold. That is not cosmetic: + a status write wakes the Weave through its own watch, so a field that + changes on every pass is a self-sustaining reconcile loop. + format: int32 + type: integer + missingSince: + description: |- + MissingSince is when this resource first stopped being returned by a + successful evaluation, and is cleared the moment it comes back. + + Pruning waits on elapsed time, not only on a count. Reconciles are + event-driven and the applies in a single pass generate watch events of + their own, so several "consecutive evaluations" can complete inside a + second - which is no protection at all against the thing hysteresis + exists for. A managed resource drops its status for as long as its + provider takes to restart, and a program waiting on that status + legitimately stops returning whatever depends on it for that whole + period. Only a clock measures that. + format: date-time + type: string + name: + type: string + owned: + description: |- + Owned records whether this Weave holds an owner reference on the + resource. False means it was applied with the owned annotation set to + "false" and will never be deleted by this Weave. + + It is remembered here rather than read from the object, because the + moment it matters is the moment the program stopped returning it - there + is no returned object left to read it from. + + Written on every entry rather than omitted when false, so that reading + the inventory shows which resources this Weave will take with it and + which it will leave standing. + type: boolean + uid: + description: |- + UID of the applied object, recorded so teardown can tell a resource we + created from a later resource that happens to share its name. + type: string + wave: + description: |- + Wave is the apply order. Resources are applied in ascending wave and + deleted in descending wave, because cascading garbage collection is + unordered and self-reference creates a real internal DAG. + format: int32 + type: integer + required: + - apiVersion + - kind + - name + - owned + - wave + type: object + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: ObservedGeneration is the spec generation most recently + evaluated. + format: int64 + type: integer + reads: + description: |- + Reads records the kinds this composition read on its last successful + pass. It is the watch set: a program that reads a ResourceGroup has to + be woken when one changes, and the only record of that is what it + actually asked for. + + Kept across restarts so a Weave that is waiting still has its watches + re-established without having to evaluate first. + items: + description: ReadRef identifies one resource a program read, or + one selection it made. + properties: + apiVersion: + description: APIVersion of what was read. + type: string + kind: + description: Kind of what was read. + type: string + required: + - apiVersion + - kind + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/charts/weft/templates/NOTES.txt b/charts/weft/templates/NOTES.txt index 8a99e4d..43e1826 100644 --- a/charts/weft/templates/NOTES.txt +++ b/charts/weft/templates/NOTES.txt @@ -21,6 +21,13 @@ their Weave names does need permission to read what its program reads and to write its outputs. Weft tells them exactly what is missing, in commands they can paste: kubectl describe weave +{{- if .Values.legacyAPI.enabled }} + +weft.run/v1alpha1 Weaves, from before weft.alethic.solutions/v1, are served too. +While both are installed, name the group to be sure which you get: +weaves.weft.alethic.solutions or weaves.weft.run. See docs/migrating.md for +moving them across. +{{- end }} To try it: diff --git a/charts/weft/templates/_helpers.tpl b/charts/weft/templates/_helpers.tpl index 0afc61b..ecd920f 100644 --- a/charts/weft/templates/_helpers.tpl +++ b/charts/weft/templates/_helpers.tpl @@ -131,6 +131,7 @@ parser rejects and the container crash-loops on. {{- if $c.watchNamespace }} - --namespace={{ $c.watchNamespace }} {{- end }} +- --serve-legacy-api={{ .Values.legacyAPI.enabled }} - --impersonate-groups={{ include "weft.impersonateGroups" . }} - --prune-delay={{ $c.pruneDelay }} - --prune-threshold={{ int $c.pruneThreshold }} diff --git a/charts/weft/templates/crds.yaml b/charts/weft/templates/crds.yaml index d22b90c..54f12a7 100644 --- a/charts/weft/templates/crds.yaml +++ b/charts/weft/templates/crds.yaml @@ -1,21 +1,30 @@ {{- if .Values.crds.install }} {{- /* -The CRD is the generated one, copied verbatim into files/ by "make generate", -so the chart cannot drift from the Go types. It is parsed here only to attach -the chart's labels and, when asked, the annotation that keeps it through an -uninstall. +The CRDs are the generated ones, copied verbatim into files/crds/ by "make +generate", so the chart cannot drift from the Go types. Each is parsed here only +to attach the chart's labels and, when asked, the annotation that keeps it +through an uninstall. + +weft.run is the group Weft was first deployed under, and is installed only +while legacyAPI.enabled says it is still served. */}} -{{- $crd := .Files.Get "files/weaves-crd.yaml" | fromYaml }} +{{- $root := . }} +{{- range $path, $_ := .Files.Glob "files/crds/*.yaml" }} +{{- if or $root.Values.legacyAPI.enabled (not (hasSuffix "weft.run_weaves.yaml" $path)) }} +{{- $crd := $root.Files.Get $path | fromYaml }} {{- $meta := $crd.metadata }} -{{- $_ := set $meta "labels" (merge (default (dict) $meta.labels) (include "weft.labels" . | fromYaml)) }} -{{- if .Values.crds.keep }} +{{- $_ := set $meta "labels" (merge (default (dict) $meta.labels) (include "weft.labels" $root | fromYaml)) }} +{{- if $root.Values.crds.keep }} {{- /* -Deleting this CRD deletes every Weave in the cluster, and each of those takes -the resources it owns with it. Uninstalling a controller must not be able to -destroy infrastructure. +Deleting a Weave CRD deletes every Weave of that group in the cluster, and each +of those takes the resources it owns with it. Uninstalling a controller must not +be able to destroy infrastructure. */}} {{- $_ := set $meta "annotations" (merge (default (dict) $meta.annotations) (dict "helm.sh/resource-policy" "keep")) }} {{- end }} {{- $_ := set $crd "metadata" $meta }} -{{- toYaml $crd }} +--- +{{ toYaml $crd }} +{{- end }} +{{- end }} {{- end }} diff --git a/charts/weft/templates/rbac.yaml b/charts/weft/templates/rbac.yaml index 9f5c62f..d62f223 100644 --- a/charts/weft/templates/rbac.yaml +++ b/charts/weft/templates/rbac.yaml @@ -27,6 +27,18 @@ rules: - apiGroups: ["weft.alethic.solutions"] resources: ["weaves/finalizers"] verbs: ["update"] + {{- if .Values.legacyAPI.enabled }} + # The same for weft.run, the group Weft was first deployed under. + - apiGroups: ["weft.run"] + resources: ["weaves"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: ["weft.run"] + resources: ["weaves/status"] + verbs: ["get", "update", "patch"] + - apiGroups: ["weft.run"] + resources: ["weaves/finalizers"] + verbs: ["update"] + {{- end }} - apiGroups: [""] resources: ["events"] diff --git a/charts/weft/values.schema.json b/charts/weft/values.schema.json index 1d52809..95a0ea3 100644 --- a/charts/weft/values.schema.json +++ b/charts/weft/values.schema.json @@ -43,6 +43,12 @@ "keep": { "type": "boolean" } } }, + "legacyAPI": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" } + } + }, "rbac": { "type": "object", "properties": { diff --git a/charts/weft/values.yaml b/charts/weft/values.yaml index f02fc5c..6d37954 100644 --- a/charts/weft/values.yaml +++ b/charts/weft/values.yaml @@ -43,6 +43,18 @@ crds: # be able to destroy infrastructure. keep: true +# weft.run/v1alpha1 is the API group Weft was first deployed under. It is +# served alongside weft.alethic.solutions/v1 so that Weaves written against it +# keep working and can be moved to v1 one at a time; see docs/migrating.md. +# +# A cluster that has never had a weft.run Weave can turn this off: its CRD is +# not installed, the controller does not reconcile it, and the controller is +# not granted access to it. Turning it off while weft.run Weaves remain leaves +# them unreconciled, holding finalizers nothing will release - move or reap +# them first. +legacyAPI: + enabled: true + rbac: create: true @@ -75,7 +87,9 @@ controller: leaderElection: enabled: true # Lease name. Change it only to run two independent Weft installations. - id: weft.alethic.solutions + # It predates v1 and keeps its name so that a rolling upgrade's old and new + # replicas contend for the same lease rather than both leading. + id: weft.run # Groups impersonated alongside the ServiceAccount username. # diff --git a/cmd/weft/chart_test.go b/cmd/weft/chart_test.go index 84916d7..ea1406c 100644 --- a/cmd/weft/chart_test.go +++ b/cmd/weft/chart_test.go @@ -12,6 +12,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/yaml" + + "github.com/alethic/weft/internal/naming" ) // The chart's rendered arguments are parsed here with the very flag set the @@ -353,15 +355,20 @@ func TestChartRefusesMultipleReplicasWithoutLeaderElection(t *testing.T) { // Uninstalling a controller must not be able to destroy infrastructure. func TestChartKeepsTheCRDByDefault(t *testing.T) { out := renderChart(t) - crd := documentContaining(t, out, "kind: CustomResourceDefinition") - if !strings.Contains(crd, "helm.sh/resource-policy: keep") { - t.Error("the CRD should be kept on uninstall by default: deleting it deletes every Weave, and each takes the resources it owns with it") + for _, api := range naming.APIs { + crd := documentContaining(t, out, "name: "+api.CRDName) + if !strings.Contains(crd, "helm.sh/resource-policy: keep") { + t.Errorf("the %s CRD should be kept on uninstall by default: deleting it deletes every Weave, "+ + "and each takes the resources it owns with it", api.Group) + } } out = renderChart(t, "--set", "crds.keep=false") - crd = documentContaining(t, out, "kind: CustomResourceDefinition") - if strings.Contains(crd, "helm.sh/resource-policy: keep") { - t.Error("crds.keep=false should drop the annotation") + for _, api := range naming.APIs { + crd := documentContaining(t, out, "name: "+api.CRDName) + if strings.Contains(crd, "helm.sh/resource-policy: keep") { + t.Errorf("crds.keep=false should drop the annotation from the %s CRD", api.Group) + } } out = renderChart(t, "--set", "crds.install=false") @@ -370,12 +377,57 @@ func TestChartKeepsTheCRDByDefault(t *testing.T) { } } -// The chart's CRD is the generated one, so the two cannot drift. +// crdFiles names the generated CRD for every served group. +func crdFiles() []string { + out := make([]string, 0, len(naming.APIs)) + for _, api := range naming.APIs { + out = append(out, api.Group+"_"+naming.Plural+".yaml") + } + return out +} + +// The chart's CRDs are the generated ones, so the two cannot drift. func TestChartCRDMatchesGenerated(t *testing.T) { - generated := readFile(t, "../../config/crd/weft.alethic.solutions_weaves.yaml") - shipped := readFile(t, "../../charts/weft/files/weaves-crd.yaml") - if generated != shipped { - t.Error("charts/weft/files/weaves-crd.yaml differs from the generated CRD; run make generate") + for _, f := range crdFiles() { + generated := readFile(t, "../../config/crd/"+f) + shipped := readFile(t, "../../charts/weft/files/crds/"+f) + if generated != shipped { + t.Errorf("charts/weft/files/crds/%s differs from the generated CRD; run make generate", f) + } + } +} + +// Both groups are served by default, so an upgrade from a release that only +// knew weft.run keeps every existing Weave reconciled. Turning the legacy group +// off removes all of it - CRD, grant and controller - together, since any one +// without the others is a controller that cannot start or a CRD nothing serves. +func TestChartServesLegacyAPIUnlessDisabled(t *testing.T) { + out := renderChart(t) + for _, want := range []string{ + "name: " + naming.V1.CRDName, + "name: " + naming.Legacy.CRDName, + `apiGroups: ["` + naming.Legacy.Group + `"]`, + "--serve-legacy-api=true", + } { + if !strings.Contains(out, want) { + t.Errorf("default render is missing %q", want) + } + } + + out = renderChart(t, "--set", "legacyAPI.enabled=false") + if !strings.Contains(out, "name: "+naming.V1.CRDName) { + t.Error("disabling the legacy API removed the v1 CRD") + } + for _, unwanted := range []string{ + "name: " + naming.Legacy.CRDName, + `apiGroups: ["` + naming.Legacy.Group + `"]`, + } { + if strings.Contains(out, unwanted) { + t.Errorf("legacyAPI.enabled=false still renders %q", unwanted) + } + } + if !strings.Contains(out, "--serve-legacy-api=false") { + t.Error("legacyAPI.enabled=false must tell the controller not to serve it") } } @@ -405,25 +457,29 @@ func readFile(t *testing.T, path string) string { // silently dropped validation rule would let the API server accept a Weave the // controller cannot honour. func TestChartCRDRoundTripIsLossless(t *testing.T) { - rendered := documentContaining(t, renderChart(t), "kind: CustomResourceDefinition") + out := renderChart(t) + for _, api := range naming.APIs { + rendered := documentContaining(t, out, "name: "+api.CRDName) - var fromChart, fromGenerator map[string]any - if err := yaml.Unmarshal([]byte(rendered), &fromChart); err != nil { - t.Fatalf("parsing the chart's CRD: %v", err) - } - if err := yaml.Unmarshal([]byte(readFile(t, "../../config/crd/weft.alethic.solutions_weaves.yaml")), &fromGenerator); err != nil { - t.Fatalf("parsing the generated CRD: %v", err) - } + var fromChart, fromGenerator map[string]any + if err := yaml.Unmarshal([]byte(rendered), &fromChart); err != nil { + t.Fatalf("parsing the chart's %s CRD: %v", api.Group, err) + } + generated := readFile(t, "../../config/crd/"+api.Group+"_"+naming.Plural+".yaml") + if err := yaml.Unmarshal([]byte(generated), &fromGenerator); err != nil { + t.Fatalf("parsing the generated %s CRD: %v", api.Group, err) + } - if !reflect.DeepEqual(fromChart["spec"], fromGenerator["spec"]) { - t.Error("the chart's rendered CRD spec differs from the generated one; the templating is lossy") - } + if !reflect.DeepEqual(fromChart["spec"], fromGenerator["spec"]) { + t.Errorf("the chart's rendered %s CRD spec differs from the generated one; the templating is lossy", api.Group) + } - // Only metadata should differ, and only by additions. - chartMeta := fromChart["metadata"].(map[string]any) - genMeta := fromGenerator["metadata"].(map[string]any) - if chartMeta["name"] != genMeta["name"] { - t.Errorf("name = %v, want %v", chartMeta["name"], genMeta["name"]) + // Only metadata should differ, and only by additions. + chartMeta := fromChart["metadata"].(map[string]any) + genMeta := fromGenerator["metadata"].(map[string]any) + if chartMeta["name"] != genMeta["name"] { + t.Errorf("name = %v, want %v", chartMeta["name"], genMeta["name"]) + } } } diff --git a/cmd/weft/main.go b/cmd/weft/main.go index c0554ee..ae70bbe 100644 --- a/cmd/weft/main.go +++ b/cmd/weft/main.go @@ -17,6 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/api/v1alpha1" "github.com/alethic/weft/internal/controller" "github.com/alethic/weft/internal/eval" @@ -33,6 +34,9 @@ func init() { if err := clientgoscheme.AddToScheme(scheme); err != nil { panic(err) } + if err := weftv1.AddToScheme(scheme); err != nil { + panic(err) + } if err := v1alpha1.AddToScheme(scheme); err != nil { panic(err) } @@ -65,6 +69,7 @@ type config struct { leaderElectID string groups string watchNamespace string + serveLegacy bool // pruneThreshold is bound separately because the flag package has no // int32 variant. @@ -81,8 +86,13 @@ func bindFlags(fs *flag.FlagSet, c *config) { fs.StringVar(&c.metricsAddr, "metrics-bind-address", "0", "Address the metrics endpoint binds to; 0 disables it.") fs.StringVar(&c.probeAddr, "health-probe-bind-address", ":8081", "Address the health probe endpoint binds to.") fs.BoolVar(&c.leaderElect, "leader-elect", false, "Elect a leader so only one replica reconciles.") - fs.StringVar(&c.leaderElectID, "leader-election-id", naming.Group, "Name of the leader election lease.") + // The lease keeps the name it had before v1 existed. A rolling upgrade has + // the old and new replicas contend for one lease; renaming it would let + // both lead at once for the length of the rollout. + fs.StringVar(&c.leaderElectID, "leader-election-id", naming.Legacy.Group, "Name of the leader election lease.") fs.StringVar(&c.watchNamespace, "namespace", "", "Restrict to a single namespace. Empty watches all of them.") + fs.BoolVar(&c.serveLegacy, "serve-legacy-api", true, + "Also reconcile weft.run/v1alpha1 Weaves, the group Weft was first deployed under. Needs its CRD installed.") fs.StringVar(&c.groups, "impersonate-groups", strings.Join(kube.DefaultGroups, ","), @@ -162,7 +172,7 @@ func runManager(args []string) error { factory, err := kube.NewFactory(restCfg, mgr.GetRESTMapper(), kube.FactoryOptions{ Groups: parseGroups(cfg.groups), - UserAgent: naming.Group + "/" + naming.Version, + UserAgent: "weft/" + version.Get().Version, }) if err != nil { return fmt.Errorf("building impersonation factory: %w", err) @@ -174,22 +184,33 @@ func runManager(args []string) error { return fmt.Errorf("registering watch registry: %w", err) } - reconciler := &controller.WeaveReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Factory: factory, - Evaluator: eval.NewStarlark(cfg.evalOpt), - Watches: registry, - // The newer events API would also change the RBAC this controller - // needs, from events in the core group to events.k8s.io, and getting - // that wrong makes events silently disappear. Migrating is a change of - // its own, not a drive-by. - //nolint:staticcheck // SA1019: deliberate; see above. - Recorder: mgr.GetEventRecorderFor(naming.Group), - Opts: cfg.opts, - } - if err := reconciler.SetupWithManager(mgr); err != nil { - return fmt.Errorf("registering controller: %w", err) + // One reconciler per served group. They share the impersonation factory, + // the evaluator's compiled-program cache and the watch registry, so serving + // weft.run alongside v1 costs a second work queue and nothing else. + evaluator := eval.NewStarlark(cfg.evalOpt) + // The newer events API would also change the RBAC this controller needs, + // from events in the core group to events.k8s.io, and getting that wrong + // makes events silently disappear. Migrating is a change of its own, not a + // drive-by. + //nolint:staticcheck // SA1019: deliberate; see above. + recorder := mgr.GetEventRecorderFor("weft") + for _, served := range controller.AllServed { + if served.API.Group == naming.Legacy.Group && !cfg.serveLegacy { + continue + } + reconciler := &controller.WeaveReconciler{ + Served: served, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Factory: factory, + Evaluator: evaluator, + Watches: registry, + Recorder: recorder, + Opts: cfg.opts, + } + if err := reconciler.SetupWithManager(mgr); err != nil { + return fmt.Errorf("registering the %s controller: %w", served.API.GroupVersion, err) + } } if err := mgr.AddHealthzCheck("ping", healthz.Ping); err != nil { @@ -204,7 +225,7 @@ func runManager(args []string) error { log.Info("starting", "build", build.Version, "commit", build.Commit, - "group", naming.Group, "apiVersion", naming.Version, + "serving", servedVersions(), "impersonateGroups", parseGroups(cfg.groups)) if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { return fmt.Errorf("running manager: %w", err) @@ -227,3 +248,11 @@ func parseGroups(s string) []string { } return out } + +func servedVersions() []string { + out := make([]string, 0, len(controller.AllServed)) + for _, s := range controller.AllServed { + out = append(out, s.API.GroupVersion) + } + return out +} diff --git a/cmd/weft/reap.go b/cmd/weft/reap.go index de5a45b..16a2363 100644 --- a/cmd/weft/reap.go +++ b/cmd/weft/reap.go @@ -7,11 +7,14 @@ import ( "os" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime/schema" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/api/v1alpha1" + "github.com/alethic/weft/internal/controller" "github.com/alethic/weft/internal/kube" "github.com/alethic/weft/internal/naming" ) @@ -22,7 +25,7 @@ import ( // own is how a namespace ends up undeletable, so reaping is part of the // product rather than a runbook step. The set of objects to visit is exactly // known: Weft only ever finalizes a source a Weave declared, so walking the -// Weaves finds all of them. +// Weaves - in every served group - finds all of them. func runReap(args []string) error { fs := flag.NewFlagSet("weft reap", flag.ExitOnError) namespace := fs.String("namespace", "", "Restrict to a single namespace. Empty covers all of them.") @@ -50,46 +53,78 @@ func runReap(args []string) error { return err } factory, err := kube.NewFactory(restCfg, mapper, kube.FactoryOptions{ - UserAgent: naming.Group + "/reap", + UserAgent: "weft/reap", }) if err != nil { return err } - var list v1alpha1.WeaveList var opts []client.ListOption if *namespace != "" { opts = append(opts, client.InNamespace(*namespace)) } - if err := c.List(ctx, &list, opts...); err != nil { - return fmt.Errorf("listing Weaves: %w", err) - } - released := 0 - for i := range list.Items { - w := &list.Items[i] - n, err := reapWeave(ctx, c, factory, w, *dryRun, *weaveFinalizers) - if err != nil { - fmt.Fprintf(os.Stderr, " %s/%s: %v\n", w.Namespace, w.Name, err) + released, weaves := 0, 0 + for _, served := range controller.AllServed { + list, err := listWeaves(ctx, c, served, opts) + if meta.IsNoMatchError(err) { + // That group's CRD is not installed, so it has no Weaves. continue } - released += n + if err != nil { + return fmt.Errorf("listing %s Weaves: %w", served.API.GroupVersion, err) + } + for _, w := range list { + n, err := reapWeave(ctx, c, factory, served, w, *dryRun, *weaveFinalizers) + if err != nil { + fmt.Fprintf(os.Stderr, " %s %s/%s: %v\n", served.API.Group, w.Namespace, w.Name, err) + continue + } + released += n + } + weaves += len(list) } verb := "released" if *dryRun { verb = "would release" } - fmt.Printf("%s %d finalizers across %d Weaves\n", verb, released, len(list.Items)) - fmt.Printf("\nAnything Weft missed carries a finalizer starting %q and can be found with:\n"+ - " kubectl get -A -o json | jq -r '.items[] | select(.metadata.finalizers[]? | startswith(\"%s\")) | \"\\(.metadata.namespace)/\\(.metadata.name)\"'\n", - naming.HeldFinalizerPrefix, naming.HeldFinalizerPrefix) + fmt.Printf("%s %d finalizers across %d Weaves\n", verb, released, weaves) + fmt.Printf("\nAnything Weft missed carries a finalizer starting %q or %q and can be found with:\n"+ + " kubectl get -A -o json | jq -r '.items[] | select(.metadata.finalizers[]? | test(\"^(%s|%s)\")) | \"\\(.metadata.namespace)/\\(.metadata.name)\"'\n", + naming.V1.HeldFinalizerPrefix, naming.Legacy.HeldFinalizerPrefix, + naming.V1.HeldFinalizerPrefix, naming.Legacy.HeldFinalizerPrefix) return nil } -func reapWeave(ctx context.Context, c client.Client, factory *kube.Factory, w *v1alpha1.Weave, dryRun, includeWeave bool) (int, error) { +// listWeaves lists one group's Weaves, as v1. +func listWeaves(ctx context.Context, c client.Client, served controller.Served, opts []client.ListOption) ([]*weftv1.Weave, error) { + var out []*weftv1.Weave + switch served.API.Group { + case naming.Legacy.Group: + var list v1alpha1.WeaveList + if err := c.List(ctx, &list, opts...); err != nil { + return nil, err + } + for i := range list.Items { + out = append(out, served.ToV1(&list.Items[i])) + } + default: + var list weftv1.WeaveList + if err := c.List(ctx, &list, opts...); err != nil { + return nil, err + } + for i := range list.Items { + out = append(out, served.ToV1(&list.Items[i])) + } + } + return out, nil +} + +func reapWeave(ctx context.Context, c client.Client, factory *kube.Factory, served controller.Served, w *weftv1.Weave, dryRun, includeWeave bool) (int, error) { released := 0 - want := naming.HeldFinalizer(w.Namespace, w.Name) + api := served.API + want := api.HeldFinalizer(w.Namespace, w.Name) var impersonated *kube.Client // status.held is the record of what this Weave actually placed. There is no @@ -109,7 +144,7 @@ func reapWeave(ctx context.Context, c client.Client, factory *kube.Factory, w *v } if impersonated == nil { - impersonated, err = factory.For(w.Namespace, w.Spec.ServiceAccountName) + impersonated, err = factory.For(api, w.Namespace, w.Spec.ServiceAccountName) if err != nil { return released, err } @@ -137,16 +172,16 @@ func reapWeave(ctx context.Context, c client.Client, factory *kube.Factory, w *v } } - if includeWeave && hasFinalizer(w.Finalizers, naming.WeaveFinalizer) { + if includeWeave && hasFinalizer(w.Finalizers, api.WeaveFinalizer) { if dryRun { - fmt.Printf(" %s/%s: would release %s from the Weave itself\n", w.Namespace, w.Name, naming.WeaveFinalizer) + fmt.Printf(" %s/%s: would release %s from the Weave itself\n", w.Namespace, w.Name, api.WeaveFinalizer) return released + 1, nil } - w.Finalizers = withoutFinalizer(w.Finalizers, naming.WeaveFinalizer) - if err := c.Update(ctx, w); err != nil && !apierrors.IsNotFound(err) { + w.Finalizers = withoutFinalizer(w.Finalizers, api.WeaveFinalizer) + if err := c.Update(ctx, served.FromV1(w)); err != nil && !apierrors.IsNotFound(err) { return released, fmt.Errorf("releasing the Weave finalizer: %w", err) } - fmt.Printf(" %s/%s: released %s from the Weave itself\n", w.Namespace, w.Name, naming.WeaveFinalizer) + fmt.Printf(" %s/%s: released %s from the Weave itself\n", w.Namespace, w.Name, api.WeaveFinalizer) released++ } diff --git a/config/crd/weft.alethic.solutions_weaves.yaml b/config/crd/weft.alethic.solutions_weaves.yaml index aa09785..95a9855 100644 --- a/config/crd/weft.alethic.solutions_weaves.yaml +++ b/config/crd/weft.alethic.solutions_weaves.yaml @@ -26,7 +26,7 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date - name: v1alpha1 + name: v1 schema: openAPIV3Schema: description: |- @@ -332,8 +332,8 @@ spec: owned: description: |- Owned records whether this Weave holds an owner reference on the - resource. False means it was applied with weft.alethic.solutions/owned=false and will - never be deleted by this Weave. + resource. False means it was applied with the owned annotation set to + "false" and will never be deleted by this Weave. It is remembered here rather than read from the object, because the moment it matters is the moment the program stopped returning it - there diff --git a/config/crd/weft.run_weaves.yaml b/config/crd/weft.run_weaves.yaml new file mode 100644 index 0000000..e546ecd --- /dev/null +++ b/config/crd/weft.run_weaves.yaml @@ -0,0 +1,405 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.22.0 + name: weaves.weft.run +spec: + group: weft.run + names: + kind: Weave + listKind: WeaveList + plural: weaves + shortNames: + - wv + singular: weave + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=='Waiting')].reason + name: Waiting + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + Weave reads existing resources in its namespace and generates others from + them, reactively, as the ServiceAccount it names. + + This is the weft.run/v1alpha1 form. New Weaves should use + weft.alethic.solutions/v1, which has the same spec. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: WeaveSpec defines a composition. + properties: + program: + description: |- + Program is a Starlark program defining compose(variable), which declares + the resources that should exist by calling resource() on each of them. + + A resource is identified by the apiVersion, kind and name in its own body, + which is the only identity anything outside the program can see. Renaming + one is not a rename: it stops declaring one object and starts declaring + another, so the first is pruned and the second created. + + What a declared object looks like in the cluster now is reached through the + value resource() returned, not through a second parameter, because it is a + fact about that resource rather than a mapping to look it up in. + minLength: 1 + type: string + serviceAccountName: + description: |- + ServiceAccountName names a ServiceAccount in this namespace. Every read + and every write Weft performs for this Weave is impersonated as that + ServiceAccount. + + The controller holds impersonation rights, not write access, so a Weave + can never cause the creation of anything its ServiceAccount could not + have created directly. + maxLength: 253 + minLength: 1 + type: string + variables: + description: |- + Variables is static configuration, assembled from these entries and + reaching the program as one mapping bound to variable. + + Entries are merged in order and later ones win, so a base can come from a + ConfigMap somebody else maintains and be overridden inline here. Mappings + merge key by key; anything else is replaced outright, which is the same + rule Helm values follow and the one people already expect. + + A program cannot tell where a value came from, which is the point: moving + a setting from inline to a ConfigMap is not a change to the composition. + + Variables are keys; sources are resources. Nothing here is watched for + the sake of an ordering edge - a ConfigMap named here is read for the + values in it, and a change to it re-runs the program. + items: + description: Variable is one entry in spec.variables. Exactly one + field is set. + properties: + configMap: + description: ConfigMap takes configuration from a ConfigMap + in this namespace. + properties: + key: + description: |- + Key selects a single entry, whose content is parsed as YAML and merged as + a mapping. Without it every entry becomes one variable, with its value as a + string. + + This is the difference between a ConfigMap of flat settings and one + holding a values.yaml document, and both are common enough to deserve + support. + type: string + name: + description: Name of the object, in the Weave's own namespace. + maxLength: 253 + minLength: 1 + type: string + optional: + description: |- + Optional skips this entry when the object does not exist. Without it a + missing object leaves the Weave waiting, because a composition built on + configuration that has not arrived is not ready. + type: boolean + required: + - name + type: object + secret: + description: |- + Secret takes configuration from a Secret in this namespace. + + Read through the same impersonated client as everything else, so a Weave + can only read a Secret its ServiceAccount could read directly. Be aware + that a value reaching a program can be written into any resource that + ServiceAccount may create. + properties: + key: + description: |- + Key selects a single entry, whose content is parsed as YAML and merged as + a mapping. Without it every entry becomes one variable, with its value as a + string. + + This is the difference between a ConfigMap of flat settings and one + holding a values.yaml document, and both are common enough to deserve + support. + type: string + name: + description: Name of the object, in the Weave's own namespace. + maxLength: 253 + minLength: 1 + type: string + optional: + description: |- + Optional skips this entry when the object does not exist. Without it a + missing object leaves the Weave waiting, because a composition built on + configuration that has not arrived is not ready. + type: boolean + required: + - name + type: object + values: + description: |- + Values is configuration written here. Plain YAML, never templated or + evaluated. + + The explicit type is what lets the validation rule above see this field + at all: a property with preserved unknown fields and no type is invisible + to CEL, and the rule fails to compile rather than failing to apply. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + x-kubernetes-validations: + - message: set exactly one of values, configMap or secret + rule: '(has(self.values) ? 1 : 0) + (has(self.configMap) ? 1 : + 0) + (has(self.secret) ? 1 : 0) == 1' + type: array + x-kubernetes-list-type: atomic + required: + - program + - serviceAccountName + type: object + status: + description: WeaveStatus is the observed state of a Weave. + properties: + conditions: + description: |- + Conditions follow the standard Ready / Waiting / Degraded split. + + Waiting deserves emphasis: resolution has three outcomes, not two, and + "not yet" is the normal one. A resource that is not there, an unpopulated status + field and an explicit wait() all land here, each naming the specific + thing that has not resolved. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + held: + description: Held records the resources this Weave has placed a finalizer + on. + items: + description: |- + HeldResource is a resource this Weave has placed a finalizer on, because a + program read it with hold=True. + + It is recorded rather than derived because the request lives in the program, + and a program that stops asking - or stops running at all - must still leave + something behind that says what to release. + properties: + apiVersion: + description: APIVersion of the held resource. + type: string + kind: + description: Kind of the held resource. + type: string + name: + description: Name of the held resource, in the Weave's own namespace. + type: string + teardownStartedAt: + description: |- + TeardownStartedAt is set when a held resource began deleting. It starts + the clock on the hard timeout after which the finalizer is released + regardless of teardown progress. + format: date-time + type: string + required: + - apiVersion + - kind + - name + type: object + type: array + x-kubernetes-list-type: atomic + inventory: + description: |- + Inventory is every resource this Weave currently owns, with its apply + order. + items: + description: InventoryEntry records one resource this Weave created. + properties: + apiVersion: + description: |- + APIVersion, Kind and Name identify the live object, and are its whole + identity. A composition describes objects, so there is nothing else for + an entry to be about. Its namespace is + always the Weave's own. + type: string + kind: + type: string + missingCount: + description: |- + MissingCount is the number of consecutive successful evaluations in + which this resource was not returned. + + It stops climbing once it reaches the threshold. That is not cosmetic: + a status write wakes the Weave through its own watch, so a field that + changes on every pass is a self-sustaining reconcile loop. + format: int32 + type: integer + missingSince: + description: |- + MissingSince is when this resource first stopped being returned by a + successful evaluation, and is cleared the moment it comes back. + + Pruning waits on elapsed time, not only on a count. Reconciles are + event-driven and the applies in a single pass generate watch events of + their own, so several "consecutive evaluations" can complete inside a + second - which is no protection at all against the thing hysteresis + exists for. A managed resource drops its status for as long as its + provider takes to restart, and a program waiting on that status + legitimately stops returning whatever depends on it for that whole + period. Only a clock measures that. + format: date-time + type: string + name: + type: string + owned: + description: |- + Owned records whether this Weave holds an owner reference on the + resource. False means it was applied with the owned annotation set to + "false" and will never be deleted by this Weave. + + It is remembered here rather than read from the object, because the + moment it matters is the moment the program stopped returning it - there + is no returned object left to read it from. + + Written on every entry rather than omitted when false, so that reading + the inventory shows which resources this Weave will take with it and + which it will leave standing. + type: boolean + uid: + description: |- + UID of the applied object, recorded so teardown can tell a resource we + created from a later resource that happens to share its name. + type: string + wave: + description: |- + Wave is the apply order. Resources are applied in ascending wave and + deleted in descending wave, because cascading garbage collection is + unordered and self-reference creates a real internal DAG. + format: int32 + type: integer + required: + - apiVersion + - kind + - name + - owned + - wave + type: object + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: ObservedGeneration is the spec generation most recently + evaluated. + format: int64 + type: integer + reads: + description: |- + Reads records the kinds this composition read on its last successful + pass. It is the watch set: a program that reads a ResourceGroup has to + be woken when one changes, and the only record of that is what it + actually asked for. + + Kept across restarts so a Weave that is waiting still has its watches + re-established without having to evaluate first. + items: + description: ReadRef identifies one resource a program read, or + one selection it made. + properties: + apiVersion: + description: APIVersion of what was read. + type: string + kind: + description: Kind of what was read. + type: string + required: + - apiVersion + - kind + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 21ffbc1..77327ca 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -28,6 +28,7 @@ rules: - impersonate - apiGroups: - weft.alethic.solutions + - weft.run resources: - weaves verbs: @@ -38,12 +39,14 @@ rules: - watch - apiGroups: - weft.alethic.solutions + - weft.run resources: - weaves/finalizers verbs: - update - apiGroups: - weft.alethic.solutions + - weft.run resources: - weaves/status verbs: diff --git a/demo/01-app-stack.yaml b/demo/01-app-stack.yaml index 6501570..4cbe2d6 100644 --- a/demo/01-app-stack.yaml +++ b/demo/01-app-stack.yaml @@ -8,7 +8,7 @@ # # kubectl -n weft-demo get weave app-stack -w # -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: app-stack diff --git a/demo/02-waiting.yaml b/demo/02-waiting.yaml index 6a4dd1b..1c83961 100644 --- a/demo/02-waiting.yaml +++ b/demo/02-waiting.yaml @@ -9,7 +9,7 @@ # # kubectl -n weft-demo create configmap tenant --from-literal=tenantId=acme-42 # -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: waiting diff --git a/demo/03-forbidden.yaml b/demo/03-forbidden.yaml index 122e9fb..b0a92a2 100644 --- a/demo/03-forbidden.yaml +++ b/demo/03-forbidden.yaml @@ -9,7 +9,7 @@ # The condition names the exact verb and resource, and gives you the two # commands that fix it. Run them and the Weave resolves itself - then tells you # about the next missing permission, one at a time, until it is done. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: forbidden diff --git a/demo/04-teardown.yaml b/demo/04-teardown.yaml index 892b771..926b801 100644 --- a/demo/04-teardown.yaml +++ b/demo/04-teardown.yaml @@ -17,7 +17,7 @@ # # kubectl -n weft-demo patch cm layer-middle --type=merge \ # -p '{"metadata":{"finalizers":null}}' -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: teardown diff --git a/demo/05-pruning.yaml b/demo/05-pruning.yaml index 73f21c5..ec819d4 100644 --- a/demo/05-pruning.yaml +++ b/demo/05-pruning.yaml @@ -16,7 +16,7 @@ # infrastructure with it. # # This install sets pruneDelay to 45s so it can be watched. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: pruning diff --git a/demo/06-finalizer.yaml b/demo/06-finalizer.yaml index 5e79e2f..276f746 100644 --- a/demo/06-finalizer.yaml +++ b/demo/06-finalizer.yaml @@ -18,7 +18,7 @@ # # It orders the deletion of an API object and nothing more. It does not order # the destruction of whatever that object represents. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: finalizer diff --git a/demo/07-bounds.yaml b/demo/07-bounds.yaml index 6b7b550..9c92861 100644 --- a/demo/07-bounds.yaml +++ b/demo/07-bounds.yaml @@ -12,7 +12,7 @@ --- # Recursion turns a step budget into a stack-depth problem, which a step budget # does not catch. Rejected by the compiler. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: bound-recursion @@ -33,7 +33,7 @@ spec: --- # A program is self-contained. There is nowhere for load() to load from, and # saying so at compile time beats a confusing failure during evaluation. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: bound-load @@ -48,7 +48,7 @@ spec: --- # Runtime bounding is what replaces the apply-time type checking a # schema-generating design would have had. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: bound-budget @@ -64,7 +64,7 @@ spec: --- # Same-namespace is the hard rule that makes plain owner references sufficient, # so an output reaching outside is refused rather than relocated. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: bound-namespace @@ -81,7 +81,7 @@ spec: --- # A namespaced owner cannot own a cluster-scoped object, so it could never be # garbage collected. Refused before anything is created. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: bound-cluster-scoped @@ -98,7 +98,7 @@ spec: --- # Weft owns what it creates and writes the reference itself. Letting a program # set one would let it hand its output to an owner that outlives the Weave. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: bound-owner @@ -122,7 +122,7 @@ spec: }) --- # Declaring one object twice cannot mean two objects. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: bound-declared-twice @@ -137,7 +137,7 @@ spec: # A field that is not set reads as None, but reaching through one does not: a # path that assumed an object where there is nothing fails where it happened, # rather than producing a resource with a blank field. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: bound-typo diff --git a/docs/migrating.md b/docs/migrating.md new file mode 100644 index 0000000..5d9c82b --- /dev/null +++ b/docs/migrating.md @@ -0,0 +1,156 @@ +# Moving from weft.run/v1alpha1 + +Weft was first deployed under the API group `weft.run`, at version `v1alpha1`. +The current API is `weft.alethic.solutions/v1`. This page is for installations +that have `weft.run` Weaves. + +If you have never had a `weft.run` Weave, none of this applies, and you can +install with `--set legacyAPI.enabled=false` to leave the old group out +entirely. + +## What upgrading does + +Nothing changes for your existing Weaves. The controller serves both groups +side by side: + +- Both CRDs are installed: `weaves.weft.run` and + `weaves.weft.alethic.solutions`. +- A `weft.run` Weave is reconciled exactly as before. Everything it writes onto + what it manages still uses `weft.run` keys — the `weft.run/weave` label, the + `weft.run/weave-uid` annotation, the `weft.run/weave` and `weft.run/held-…` + finalizers, and the `weft.run` field manager — because those are what its + objects already carry. +- A v1 Weave uses `weft.alethic.solutions` keys for all of the same things, and + never writes `weft.run` onto anything. +- The leader election lease keeps its old name, so during a rolling upgrade the + old and new controller replicas contend for one lease instead of both acting. + +The two schemas are identical. Moving a Weave means changing its `apiVersion` +and the keys of any Weft annotations in its program. Nothing else changes. + +## Moving one Weave + +Kubernetes cannot convert an object from one API group to another, so moving +a Weave means deleting the old one and creating the new one. The step that +matters is deleting it **without** deleting what it made. Weft handles the +rest: a v1 Weave with the **same name** in the same namespace takes over each +object its predecessor made, in place, as its program declares it. Nothing is +recreated. + +### 1. Update the manifest + +```diff +-apiVersion: weft.run/v1alpha1 ++apiVersion: weft.alethic.solutions/v1 + kind: Weave + metadata: + name: app +``` + +In the program, rename any Weft annotation keys: + +```diff + "metadata": { + "name": "app-db", +- "annotations": {"weft.run/owned": "false"}, ++ "annotations": {"weft.alethic.solutions/owned": "false"}, + } +``` + +A v1 Weave whose program still says `weft.run/owned` is refused with +`Degraded`, and nothing is applied. The alternative would be worse: the +resource you meant to keep would be owned by the new Weave and deleted along +with it. + +If you hand objects over with the adoption annotation, rename that the same +way: `weft.run/adopt` becomes `weft.alethic.solutions/adopt`. + +### 2. Delete the old Weave, orphaning what it made + +```bash +kubectl -n my-namespace delete weaves.weft.run app --cascade=orphan +``` + +Use the full resource name `weaves.weft.run`. While both CRDs are installed, +plain `weave` (and the short name `wv`) matches both groups, and kubectl +resolves it to one of them — in practice `weft.alethic.solutions`, which sorts +first. Scripts and runbooks that say `kubectl get weave` will start showing v1 +Weaves after the upgrade; give them the full name while you have both. + +`--cascade=orphan` is what keeps everything standing. The controller releases +the Weave and any `hold=True` finalizers it placed, and the garbage collector +strips its owner references. The objects stay as they are, unmanaged for the +moment. + +### 3. Apply the v1 Weave + +```bash +kubectl apply -f app.yaml +kubectl -n my-namespace get weaves.weft.alethic.solutions app +``` + +For each object the program declares, the v1 Weave finds the old Weave's +provenance and takes the object over: + +- It moves server-side-apply field ownership from the `weft.run` field manager + to its own. The new Weave then prunes fields the program no longer sets + exactly as if it had always been the one writing them. That includes the old + `weft.run/weave` label and `weft.run/weave-uid` annotation, which disappear + on the first apply. +- It places its own owner reference and provenance. +- It records a `HandedOver` event on the Weave for each object. + +If the old Weave is still being deleted when the new one first reconciles, +the new one shows `Waiting` with reason `HandoverPending` and carries on once +the old one is gone. If the old Weave was not deleted at all, the new one is +`Degraded` and its message gives the exact `kubectl delete … --cascade=orphan` +command to run. + +### What is not handed over + +- **Objects the new program does not declare.** Handover happens as the program + declares each object, so anything the old Weave made that the new one never + declares stays orphaned. Delete it by hand, or declare it. +- **A Weave under a different name.** Handover matches on namespace and name. To + rename as well as move, move it first, then rename. Renaming is a delete and + a create for the objects too; see [ownership.md](ownership.md). + +## When every Weave has moved + +Check that nothing is left in the old group: + +```bash +kubectl get weaves.weft.run --all-namespaces +``` + +Then stop serving it: + +```bash +helm upgrade weft oci://ghcr.io/alethic/charts/weft --namespace weft-system \ + --reuse-values --set legacyAPI.enabled=false +``` + +That stops the controller reconciling `weft.run` and drops its grant on it. +It does not delete the `weaves.weft.run` CRD, because `crds.keep` leaves CRDs +in place on purpose. Delete the CRD yourself once the list above is empty: + +```bash +kubectl delete crd weaves.weft.run +``` + +Do not turn `legacyAPI.enabled` off while `weft.run` Weaves remain. Nothing +would reconcile them, and nothing would release their finalizers. They could +not be deleted, and neither could the objects they hold. + +## Metrics and dashboards + +Per-Weave series (`weft_weave_status`, `weft_weave_resources`, +`weft_evaluation_duration_seconds`) now carry a `group` label, because a +`weft.run` Weave and a v1 Weave can share a namespace and name during a move. +Queries that aggregate with `sum by (namespace, name)` or match on specific +labels keep working. A query that relies on the exact label set does not. + +Two controllers run: `weave` reconciles v1, and `weave-weft-run` reconciles +`weft.run`. controller-runtime's own metrics (`controller_runtime_*`) are +labelled with these names. They were previously labelled `weave` for +`weft.run` Weaves. diff --git a/docs/operating.md b/docs/operating.md index 6c73f00..e3e8101 100644 --- a/docs/operating.md +++ b/docs/operating.md @@ -100,6 +100,7 @@ pruned, and what it started declaring is applied. | `--max-reads` | `100` | distinct resources one evaluation may read | | `--max-selected` | `500` | objects one `select()` may match | | `--namespace` | *(all)* | restrict the controller to one namespace | +| `--serve-legacy-api` | `true` | also reconcile `weft.run/v1alpha1` Weaves; see [migrating.md](migrating.md) | | `--impersonate-groups` | `system:serviceaccounts,system:authenticated` | see [rbac.md](rbac.md) | `weft --help` lists the rest. Every flag is exposed as a chart value; see @@ -108,7 +109,8 @@ pruned, and what it started declaring is applied. ## Metrics `weft_weave_status` reports each `Weave` as ready, waiting or degraded, so -alerting on "degraded for more than N minutes" is a single expression. +alerting on "degraded for more than N minutes" is a single expression. Per-Weave +series are labelled with `group`, `namespace` and `name`. Alongside it: resources owned per `Weave`, evaluation duration and outcome, applies, prunes, permission denials by verb and resource, and active versus degraded watches. @@ -124,7 +126,7 @@ resources instead, use the verb Kubernetes already has for it: kubectl delete weave app --cascade=orphan ``` -Resources annotated `weft.run/owned: "false"` are never deleted with the +Resources annotated `weft.alethic.solutions/owned: "false"` are never deleted with the `Weave`. See [ownership.md](ownership.md). ## Uninstalling the controller diff --git a/examples/app.yaml b/examples/app.yaml index 634c3c3..b06e12a 100644 --- a/examples/app.yaml +++ b/examples/app.yaml @@ -12,7 +12,7 @@ # Note what is not here. The FederatedIdentityCredentials that sat alongside the # original template are static - they read no live status - so they stay in # Helm. Weft is for the parts that have to react. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: app diff --git a/examples/env.yaml b/examples/env.yaml index 7cbe12e..a93cf8c 100644 --- a/examples/env.yaml +++ b/examples/env.yaml @@ -5,7 +5,7 @@ # Structurally the same shape as examples/app.yaml - identity first, then what # consumes it - which is the point: once staging is expressible, most of these # compositions turn out to be the same composition. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: env diff --git a/examples/release-values.yaml b/examples/release-values.yaml index c271b71..9e86f3f 100644 --- a/examples/release-values.yaml +++ b/examples/release-values.yaml @@ -8,7 +8,7 @@ # It is the clearest case for to_yaml(). The original did this by hand in a Go # template with careful nindent counts, which is exactly the kind of thing that # breaks silently when someone adds a nesting level. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: release-values diff --git a/examples/sql.yaml b/examples/sql.yaml index 1e92303..42f0ea8 100644 --- a/examples/sql.yaml +++ b/examples/sql.yaml @@ -7,7 +7,7 @@ # forever and re-applied the same grants. With a real watch it collapses into a # single Job keyed off the identity's generation: it runs when the identity # actually changes, and not otherwise. -apiVersion: weft.alethic.solutions/v1alpha1 +apiVersion: weft.alethic.solutions/v1 kind: Weave metadata: name: sql diff --git a/internal/controller/adopt.go b/internal/controller/adopt.go index b5bff69..8758818 100644 --- a/internal/controller/adopt.go +++ b/internal/controller/adopt.go @@ -7,9 +7,13 @@ import ( "path" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/inventory" "github.com/alethic/weft/internal/kube" "github.com/alethic/weft/internal/naming" @@ -29,7 +33,7 @@ import ( func (r *WeaveReconciler) checkOwnership( ctx context.Context, c *kube.Client, - weave *v1alpha1.Weave, + weave *weftv1.Weave, item inventory.Item, ) error { gvk := item.GroupVersionKind() @@ -66,25 +70,39 @@ func (r *WeaveReconciler) checkOwnership( return fmt.Errorf("checking whether %s already exists: %w", item.Ref, err) } - owner := weftOwner(existing, weave) + api := r.Served.API + owner := weftOwner(existing, weave, api) switch { + case owner.otherWeave != "" && api.Predecessor != nil && + owner.otherAPI.Group == api.Predecessor.Group && owner.otherWeave == weave.Name: + // The same Weave in the old group, still alive. This is the first half + // of a move to this group, and the instruction is the second half. + return degradedf(ReasonNotOurs, "%s", handoverMessage(gvk.Kind, name, weave, *api.Predecessor)) + case owner.otherWeave != "": // Another Weave owns it and would keep re-applying it. Adoption cannot // resolve that; the two would simply fight over the object. + which := fmt.Sprintf("the Weave %q", owner.otherWeave) + if owner.otherAPI.Group != api.Group { + which = fmt.Sprintf("the %s Weave %q", owner.otherAPI.GroupVersion, owner.otherWeave) + } return degradedf(ReasonNotOurs, - "this program declares %s %q, which the Weave %q already owns. Two Weaves cannot manage one "+ + "this program declares %s %q, which %s already owns. Two Weaves cannot manage one "+ "object: they would apply over each other on every reconcile. Remove it from one of them.", - gvk.Kind, name, owner.otherWeave) + gvk.Kind, name, which) + + case owner.handover: + return r.handOver(ctx, c, weave, gvk, name) case !owner.ours && !owner.adoptableBy(weave.Name): - return degradedf(ReasonNotOurs, "%s", adoptionMessage(item, existing, owner, weave.Name)) + return degradedf(ReasonNotOurs, "%s", adoptionMessage(item, existing, owner, weave.Name, api)) case !owner.ours: // Consented to, so take it. Worth an event: ownership is a one-way door // in the sense that deleting the Weave will now delete this object. r.eventf(weave, "Normal", "Adopted", "took ownership of %s %q, which was annotated %s=%s. Deleting this Weave now deletes it.", - gvk.Kind, name, naming.AdoptAnnotation, owner.adopt) + gvk.Kind, name, api.AdoptAnnotation, owner.adopt) return nil case owner.reclaimed: @@ -123,8 +141,14 @@ type weftOwnership struct { // describe an object made by a different Weave that carries no owner // reference, which is what an unowned resource looks like. weave string - // otherWeave names a different Weave that owns it, when one does. + // otherWeave names a different Weave that owns it, when one does, and + // otherAPI is that Weave's group. otherWeave string + otherAPI naming.API + + // handover is true when the object was made by the Weave of the same name + // in the group this one supersedes, and nothing owns it now. + handover bool // manager is whichever field manager last wrote it, when nobody owns it. manager string // adopt is the value of the adopt annotation, when it carries one. @@ -159,11 +183,16 @@ func (o weftOwnership) adoptableBy(weave string) bool { // garbage collection acts on. Where there is not - every unowned resource, and // any owned one whose reference was stripped - the provenance annotation // decides it instead. The rest is read only to describe what was found. -func weftOwner(obj *unstructured.Unstructured, weave *v1alpha1.Weave) weftOwnership { +// +// Provenance is read under this Weave's own group's keys. A Weave in another +// group is recognised by its owner reference whichever group it is in, so two +// groups can never both believe they manage one object. +func weftOwner(obj *unstructured.Unstructured, weave *weftv1.Weave, api naming.API) weftOwnership { annotations := obj.GetAnnotations() + labels := obj.GetLabels() out := weftOwnership{ - adopt: annotations[naming.AdoptAnnotation], - weave: obj.GetLabels()[naming.WeaveLabel], + adopt: annotations[api.AdoptAnnotation], + weave: labels[api.WeaveLabel], } for _, ref := range obj.GetOwnerReferences() { @@ -171,9 +200,10 @@ func weftOwner(obj *unstructured.Unstructured, weave *v1alpha1.Weave) weftOwners out.ours = true return out } - if ref.Kind == naming.Kind && ref.APIVersion == naming.GroupVersion { + if other, ok := naming.IsWeaveOwnerRef(ref.APIVersion, ref.Kind); ok { // Another Weave's. Name it, since that is the useful thing to say. out.otherWeave = ref.Name + out.otherAPI = other return out } } @@ -188,12 +218,27 @@ func weftOwner(obj *unstructured.Unstructured, weave *v1alpha1.Weave) weftOwners // predecessor's resources, and would make the label - which anybody who can // write the object can set - into a way to hand Weft something it never // made. - if annotations[naming.WeaveUIDAnnotation] == string(weave.UID) { + if annotations[api.WeaveUIDAnnotation] == string(weave.UID) { out.ours = true out.reclaimed = true return out } + // Made by the same-named Weave in the group this one supersedes, and owned + // by nothing now: what a Weave moved to this group finds when its + // predecessor was deleted with --cascade=orphan. + // + // This matches on the name, which the UID rule above deliberately does not. + // That is safe here because it only ever applies across groups, where the + // UID cannot match by construction, and because a name is the whole of what + // a move preserves. Somebody who could forge the label could equally write + // the adopt annotation, so it grants nothing that was not already theirs to + // grant. + if p := api.Predecessor; p != nil && annotations[p.WeaveUIDAnnotation] != "" && labels[p.WeaveLabel] == weave.Name { + out.handover = true + return out + } + if managers := obj.GetManagedFields(); len(managers) > 0 { out.manager = managers[0].Manager } @@ -201,7 +246,7 @@ func weftOwner(obj *unstructured.Unstructured, weave *v1alpha1.Weave) weftOwners } // adoptionMessage explains the refusal and what to do about it. -func adoptionMessage(item inventory.Item, existing *unstructured.Unstructured, owner weftOwnership, ownerName string) string { +func adoptionMessage(item inventory.Item, existing *unstructured.Unstructured, owner weftOwnership, ownerName string, api naming.API) string { gvk := item.GroupVersionKind() msg := fmt.Sprintf("this program declares %s %q, which already exists and this Weave did not create.", gvk.Kind, item.Object.GetName()) @@ -211,7 +256,7 @@ func adoptionMessage(item inventory.Item, existing *unstructured.Unstructured, o } if owner.adopt != "" { msg += fmt.Sprintf("\n\nIt is annotated %s=%q, which does not match this Weave, %q.", - naming.AdoptAnnotation, owner.adopt, ownerName) + api.AdoptAnnotation, owner.adopt, ownerName) } msg += "\n\nWeft will not take over an object it did not create. Applying would add its owner " + @@ -220,7 +265,7 @@ func adoptionMessage(item inventory.Item, existing *unstructured.Unstructured, o "the resource, not to whoever wrote the program:\n" + fmt.Sprintf(" kubectl -n %s annotate %s %s %s=%s\n\n", existing.GetNamespace(), gvk.Kind, item.Object.GetName(), - naming.AdoptAnnotation, ownerName) + + api.AdoptAnnotation, ownerName) + "From then on this Weave manages it, and deleting the Weave deletes it. The value is a pattern, " + "so \"*\" consents to any Weave in this namespace - the form to reach for when onboarding a set " + "of objects at once.\n\nOtherwise change the name the program produces, or remove what is " + @@ -229,3 +274,89 @@ func adoptionMessage(item inventory.Item, existing *unstructured.Unstructured, o existing.GetNamespace(), gvk.Kind, item.Object.GetName()) return msg } + +// handOver takes over an object the same-named Weave in the predecessor group +// made, once that Weave is gone. +// +// Moving a Weave between groups is a delete and a create, because Kubernetes +// cannot convert an object from one group to another. Deleting the old Weave +// with --cascade=orphan leaves everything it made standing and unowned, and +// this is the other half: the new Weave finds each object as it declares it, +// recognises its predecessor's provenance, and takes it over in place. Nothing +// is recreated, which for a managed resource is the difference between a +// migration and an outage. +// +// It refuses while the predecessor still exists. Two live Weaves applying one +// object would fight over it on every reconcile, and the predecessor deleted +// without orphaning would be tearing the object down underneath this one. +func (r *WeaveReconciler) handOver( + ctx context.Context, + c *kube.Client, + weave *weftv1.Weave, + gvk schema.GroupVersionKind, + name string, +) error { + pred := *r.Served.API.Predecessor + + exists, deleting, err := r.weaveExists(ctx, pred, weave.Namespace, weave.Name) + if err != nil { + return fmt.Errorf("checking for the %s Weave %q: %w", pred.GroupVersion, weave.Name, err) + } + switch { + case deleting: + // Being deleted, with --cascade=orphan or not. Either way the answer is + // soon; until then taking the object would race whatever it is doing. + return waitingf(ReasonHandover, + "%s %q was made by the %s Weave %q, which is being deleted; it is taken over once that finishes", + gvk.Kind, name, pred.GroupVersion, weave.Name) + case exists: + return degradedf(ReasonNotOurs, "%s", handoverMessage(gvk.Kind, name, weave, pred)) + } + + // Its fields are owned by the predecessor's field manager. Moving them to + // ours is what lets the apply that follows prune the predecessor's + // provenance, and anything else the program no longer sets. + if _, err := c.TransferFieldOwnership(ctx, gvk, name, pred.FieldManager); err != nil { + var perm *kube.PermissionError + if errors.As(err, &perm) { + return degradedf(ReasonForbidden, + "taking over %s %q from the %s Weave of the same name:\n%s", + gvk.Kind, name, pred.GroupVersion, perm.Error()) + } + if apierrors.IsNotFound(err) { + // Gone in between. The apply creates it, which is fine. + return nil + } + return fmt.Errorf("taking over %s %q: %w", gvk.Kind, name, err) + } + + r.eventf(weave, "Normal", "HandedOver", + "took over %s %q from the %s Weave of the same name", gvk.Kind, name, pred.GroupVersion) + return nil +} + +// weaveExists reports whether a Weave of the given group exists, and whether it +// is being deleted. A group whose CRD is not installed has no Weaves. +func (r *WeaveReconciler) weaveExists(ctx context.Context, api naming.API, namespace, name string) (exists, deleting bool, err error) { + obj := &metav1.PartialObjectMetadata{} + obj.SetGroupVersionKind(schema.GroupVersionKind{Group: api.Group, Version: api.Version, Kind: naming.Kind}) + err = r.Client.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, obj) + switch { + case apierrors.IsNotFound(err), meta.IsNoMatchError(err): + return false, false, nil + case err != nil: + return false, false, err + } + return true, !obj.DeletionTimestamp.IsZero(), nil +} + +// handoverMessage explains how to finish moving a Weave from its predecessor +// group. +func handoverMessage(kind, name string, weave *weftv1.Weave, pred naming.API) string { + return fmt.Sprintf("this program declares %s %q, which the %s Weave %q still manages.\n\n"+ + "To move it to this Weave, delete that one without deleting what it made:\n"+ + " kubectl -n %s delete %s %s --cascade=orphan\n\n"+ + "Nothing is recreated: once it is gone this Weave takes over each object it declares, in place.", + kind, name, pred.GroupVersion, weave.Name, + weave.Namespace, pred.CRDName, weave.Name) +} diff --git a/internal/controller/adopt_test.go b/internal/controller/adopt_test.go index 3650f05..2769158 100644 --- a/internal/controller/adopt_test.go +++ b/internal/controller/adopt_test.go @@ -49,7 +49,7 @@ func TestAdoptableBy(t *testing.T) { func TestAdoptsOnAWildcard(t *testing.T) { h := newHarness(t, nil) cm := h.configMap("open-house", map[string]string{"owner": "somebody-else"}) - cm.Annotations = map[string]string{naming.AdoptAnnotation: "*"} + cm.Annotations = map[string]string{naming.V1.AdoptAnnotation: "*"} if err := testK8s.Update(h.ctx, cm); err != nil { t.Fatal(err) } @@ -85,7 +85,7 @@ def compose(variable): func TestWildcardThatDoesNotMatchIsRefused(t *testing.T) { h := newHarness(t, nil) cm := h.configMap("prod-only", nil) - cm.Annotations = map[string]string{naming.AdoptAnnotation: "prod-*"} + cm.Annotations = map[string]string{naming.V1.AdoptAnnotation: "prod-*"} if err := testK8s.Update(h.ctx, cm); err != nil { t.Fatal(err) } diff --git a/internal/controller/apply.go b/internal/controller/apply.go index c57c0cf..d1433f9 100644 --- a/internal/controller/apply.go +++ b/internal/controller/apply.go @@ -10,7 +10,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/inventory" "github.com/alethic/weft/internal/kube" "github.com/alethic/weft/internal/metrics" @@ -22,8 +22,8 @@ import ( // a partial apply is still recorded: an unrecorded resource is an orphan, and // orphans were the single worst property of the cron-and-template arrangement // this replaces. -func (r *WeaveReconciler) applyAll(ctx context.Context, c *kube.Client, weave *v1alpha1.Weave, items []inventory.Item) ([]v1alpha1.InventoryEntry, error) { - applied := make([]v1alpha1.InventoryEntry, 0, len(items)) +func (r *WeaveReconciler) applyAll(ctx context.Context, c *kube.Client, weave *weftv1.Weave, items []inventory.Item) ([]weftv1.InventoryEntry, error) { + applied := make([]weftv1.InventoryEntry, 0, len(items)) for _, wave := range inventory.ApplyWaves(items) { var waveErr error @@ -98,7 +98,7 @@ func applyError(item inventory.Item, err error) error { // that names a principal has to go before the identity that owns the principal, // or the provider is left reconciling an assignment whose subject no longer // exists. -func (r *WeaveReconciler) deleteWaves(ctx context.Context, c *kube.Client, weave *v1alpha1.Weave, entries []v1alpha1.InventoryEntry) ([]v1alpha1.InventoryEntry, error) { +func (r *WeaveReconciler) deleteWaves(ctx context.Context, c *kube.Client, weave *weftv1.Weave, entries []weftv1.InventoryEntry) ([]weftv1.InventoryEntry, error) { groups := inventory.Waves(entries) for i, group := range groups { @@ -110,7 +110,7 @@ func (r *WeaveReconciler) deleteWaves(ctx context.Context, c *kube.Client, weave continue } // This wave is still going. Everything below it waits. - remaining := append([]v1alpha1.InventoryEntry(nil), standing...) + remaining := append([]weftv1.InventoryEntry(nil), standing...) for _, lower := range groups[i+1:] { remaining = append(remaining, lower...) } @@ -122,8 +122,8 @@ func (r *WeaveReconciler) deleteWaves(ctx context.Context, c *kube.Client, weave // deleteGroup issues deletes for one wave and reports which of them have not // gone away yet. -func (r *WeaveReconciler) deleteGroup(ctx context.Context, c *kube.Client, weave *v1alpha1.Weave, group []v1alpha1.InventoryEntry) ([]v1alpha1.InventoryEntry, error) { - var standing []v1alpha1.InventoryEntry +func (r *WeaveReconciler) deleteGroup(ctx context.Context, c *kube.Client, weave *weftv1.Weave, group []weftv1.InventoryEntry) ([]weftv1.InventoryEntry, error) { + var standing []weftv1.InventoryEntry for _, e := range group { gvk, err := inventory.GroupVersionKind(e) @@ -176,7 +176,7 @@ func (r *WeaveReconciler) deleteGroup(ctx context.Context, c *kube.Client, weave } // ownedBy reports whether an object still carries this Weave's owner reference. -func ownedBy(obj *unstructured.Unstructured, weave *v1alpha1.Weave) bool { +func ownedBy(obj *unstructured.Unstructured, weave *weftv1.Weave) bool { for _, ref := range obj.GetOwnerReferences() { if ref.UID == weave.UID { return true @@ -186,7 +186,7 @@ func ownedBy(obj *unstructured.Unstructured, weave *v1alpha1.Weave) bool { } // describeEntries renders a short list for a condition message. -func describeEntries(entries []v1alpha1.InventoryEntry, limit int) string { +func describeEntries(entries []weftv1.InventoryEntry, limit int) string { if len(entries) == 0 { return "" } diff --git a/internal/controller/bootstrap_cluster_test.go b/internal/controller/bootstrap_cluster_test.go index 3e3b8b6..4d69e31 100644 --- a/internal/controller/bootstrap_cluster_test.go +++ b/internal/controller/bootstrap_cluster_test.go @@ -21,6 +21,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/yaml" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/api/v1alpha1" "github.com/alethic/weft/internal/naming" ) @@ -49,6 +50,7 @@ func TestMain(m *testing.M) { scheme := runtime.NewScheme() for _, add := range []func(*runtime.Scheme) error{ clientgoscheme.AddToScheme, + weftv1.AddToScheme, v1alpha1.AddToScheme, apiextensionsv1.AddToScheme, } { @@ -68,25 +70,32 @@ func TestMain(m *testing.M) { panic(err) } - installed, err := installCRD(context.Background()) - if err != nil { - fmt.Fprintf(os.Stderr, "installing the CRD: %v\n", err) - os.Exit(1) + var installed []naming.API + for _, api := range naming.APIs { + ok, err := installCRD(context.Background(), api) + if err != nil { + fmt.Fprintf(os.Stderr, "installing the %s CRD: %v\n", api.Group, err) + os.Exit(1) + } + if ok { + installed = append(installed, api) + } } code := m.Run() - // Only remove the CRD if this run installed it. Deleting one that was + // Only remove a CRD if this run installed it. Deleting one that was // already there would take every Weave in the cluster with it. - if installed { - removeCRD(context.Background()) + for _, api := range installed { + removeCRD(context.Background(), api) } os.Exit(code) } -// installCRD applies the generated CRD, reporting whether it had to create it. -func installCRD(ctx context.Context) (bool, error) { - raw, err := os.ReadFile(filepath.Join("..", "..", "config", "crd", naming.Group+"_"+naming.Plural+".yaml")) +// installCRD applies one group's generated CRD, reporting whether it had to +// create it. +func installCRD(ctx context.Context, api naming.API) (bool, error) { + raw, err := os.ReadFile(filepath.Join("..", "..", "config", "crd", api.Group+"_"+naming.Plural+".yaml")) if err != nil { return false, err } @@ -110,8 +119,13 @@ func installCRD(ctx context.Context) (bool, error) { // The API server needs a moment before the new type is servable. deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { - var list v1alpha1.WeaveList - if err := testK8s.List(ctx, &list); err == nil { + var err error + if api.Group == naming.Legacy.Group { + err = testK8s.List(ctx, &v1alpha1.WeaveList{}) + } else { + err = testK8s.List(ctx, &weftv1.WeaveList{}) + } + if err == nil { return true, nil } time.Sleep(250 * time.Millisecond) @@ -119,9 +133,9 @@ func installCRD(ctx context.Context) (bool, error) { return true, fmt.Errorf("the CRD did not become servable in time") } -func removeCRD(ctx context.Context) { +func removeCRD(ctx context.Context, api naming.API) { crd := &apiextensionsv1.CustomResourceDefinition{} - crd.Name = naming.CRDName + crd.Name = api.CRDName if err := testK8s.Delete(ctx, crd); err != nil && !apierrors.IsNotFound(err) { fmt.Fprintf(os.Stderr, "removing the CRD: %v\n", err) } diff --git a/internal/controller/bootstrap_envtest_test.go b/internal/controller/bootstrap_envtest_test.go index b62a5d2..e731a7b 100644 --- a/internal/controller/bootstrap_envtest_test.go +++ b/internal/controller/bootstrap_envtest_test.go @@ -16,6 +16,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/api/v1alpha1" ) @@ -46,6 +47,7 @@ func TestMain(m *testing.M) { scheme := runtime.NewScheme() for _, add := range []func(*runtime.Scheme) error{ clientgoscheme.AddToScheme, + weftv1.AddToScheme, v1alpha1.AddToScheme, apiextensionsv1.AddToScheme, } { diff --git a/internal/controller/conditions.go b/internal/controller/conditions.go index b8df9d7..34221fa 100644 --- a/internal/controller/conditions.go +++ b/internal/controller/conditions.go @@ -4,7 +4,7 @@ import ( apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/naming" ) @@ -24,6 +24,7 @@ const ( ReasonWatchDegraded = "WatchDegraded" ReasonNotOurs = "ResourceNotOurs" ReasonVariableMissing = "VariableMissing" + ReasonHandover = "HandoverPending" ) // maxMessage keeps a condition inside the API server's limit with room to @@ -40,7 +41,7 @@ func truncate(s string) string { // setCondition writes one condition, leaving LastTransitionTime alone when // nothing changed so that a steady state does not churn the object. -func setCondition(w *v1alpha1.Weave, condType string, status metav1.ConditionStatus, reason, message string) { +func setCondition(w *weftv1.Weave, condType string, status metav1.ConditionStatus, reason, message string) { apimeta.SetStatusCondition(&w.Status.Conditions, metav1.Condition{ Type: condType, Status: status, @@ -51,7 +52,7 @@ func setCondition(w *v1alpha1.Weave, condType string, status metav1.ConditionSta } // markReady records that the returned set is fully applied. -func markReady(w *v1alpha1.Weave, message string) { +func markReady(w *weftv1.Weave, message string) { setCondition(w, naming.ConditionReady, metav1.ConditionTrue, ReasonApplied, message) setCondition(w, naming.ConditionWaiting, metav1.ConditionFalse, ReasonApplied, "nothing outstanding") setCondition(w, naming.ConditionDegraded, metav1.ConditionFalse, ReasonApplied, "no errors") @@ -62,14 +63,14 @@ func markReady(w *v1alpha1.Weave, message string) { // This is the normal steady state for a composition that spans several // provisioning steps, not an error, and the message names the specific thing // that has not resolved. -func markWaiting(w *v1alpha1.Weave, reason, message string) { +func markWaiting(w *weftv1.Weave, reason, message string) { setCondition(w, naming.ConditionReady, metav1.ConditionFalse, reason, message) setCondition(w, naming.ConditionWaiting, metav1.ConditionTrue, reason, message) setCondition(w, naming.ConditionDegraded, metav1.ConditionFalse, ReasonApplied, "no errors") } // markDegraded records something that will not resolve without a change. -func markDegraded(w *v1alpha1.Weave, reason, message string) { +func markDegraded(w *weftv1.Weave, reason, message string) { setCondition(w, naming.ConditionReady, metav1.ConditionFalse, reason, message) setCondition(w, naming.ConditionWaiting, metav1.ConditionFalse, reason, "blocked by an error") setCondition(w, naming.ConditionDegraded, metav1.ConditionTrue, reason, message) diff --git a/internal/controller/edit_test.go b/internal/controller/edit_test.go index 68c295f..1d0cc9c 100644 --- a/internal/controller/edit_test.go +++ b/internal/controller/edit_test.go @@ -10,7 +10,7 @@ import ( k8stypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/naming" ) @@ -85,7 +85,7 @@ def compose(variable): if c.Reason != ReasonNotOurs { t.Errorf("reason = %q, want %q", c.Reason, ReasonNotOurs) } - if !strings.Contains(c.Message, naming.AdoptAnnotation) { + if !strings.Contains(c.Message, naming.V1.AdoptAnnotation) { t.Error("the refusal should say how to hand the object over deliberately") } @@ -109,7 +109,7 @@ def compose(variable): func TestAdoptsWhenTheObjectConsents(t *testing.T) { h := newHarness(t, nil) cm := h.configMap("onboarded", map[string]string{"owner": "somebody-else"}) - cm.Annotations = map[string]string{naming.AdoptAnnotation: "adopter"} + cm.Annotations = map[string]string{naming.V1.AdoptAnnotation: "adopter"} if err := testK8s.Update(h.ctx, cm); err != nil { t.Fatal(err) } @@ -146,7 +146,7 @@ def compose(variable): func TestAdoptionAnnotationIsSpecific(t *testing.T) { h := newHarness(t, nil) cm := h.configMap("spoken-for", nil) - cm.Annotations = map[string]string{naming.AdoptAnnotation: "some-other-weave"} + cm.Annotations = map[string]string{naming.V1.AdoptAnnotation: "some-other-weave"} if err := testK8s.Update(h.ctx, cm); err != nil { t.Fatal(err) } @@ -213,7 +213,7 @@ def compose(variable): // collection once it has stripped the owner references, on a schedule of // its own - asserting on that would be testing Kubernetes rather than this // controller, and it is slow enough on a small cluster to be flaky. - var released v1alpha1.Weave + var released weftv1.Weave err = testK8s.Get(h.ctx, k8stypes.NamespacedName{Namespace: h.namespace, Name: "orphaning"}, &released) switch { case apierrors.IsNotFound(err): @@ -222,7 +222,7 @@ def compose(variable): t.Fatal(err) default: for _, f := range released.Finalizers { - if f == naming.WeaveFinalizer { + if f == naming.V1.WeaveFinalizer { t.Errorf("Weft is still holding the Weave: %v", released.Finalizers) } } diff --git a/internal/controller/finalizers.go b/internal/controller/finalizers.go index bc3fe7a..1095481 100644 --- a/internal/controller/finalizers.go +++ b/internal/controller/finalizers.go @@ -10,9 +10,8 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" logf "sigs.k8s.io/controller-runtime/pkg/log" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/kube" - "github.com/alethic/weft/internal/naming" ) // Holds are the opt-in finalizers a program asks for with @@ -39,7 +38,7 @@ import ( // it and re-apply everything that was just torn down. That recreated output // would then outlive the resource entirely, orphaned, which is precisely the // failure this feature exists to prevent. -func (r *WeaveReconciler) checkHolds(ctx context.Context, c *kube.Client, rd *weaveReader, weave *v1alpha1.Weave) error { +func (r *WeaveReconciler) checkHolds(ctx context.Context, c *kube.Client, rd *weaveReader, weave *weftv1.Weave) error { for i := range weave.Status.Held { held := weave.Status.Held[i] gvk, err := parseGVK(held.APIVersion, held.Kind) @@ -68,7 +67,7 @@ func (r *WeaveReconciler) checkHolds(ctx context.Context, c *kube.Client, rd *we // reconcileHolds settles status.held against what the program asked for on this // pass: places what is new, releases what is no longer wanted, and stops the // pass if anything it asked to hold is already deleting. -func (r *WeaveReconciler) reconcileHolds(ctx context.Context, c *kube.Client, weave *v1alpha1.Weave, requested, deleting []v1alpha1.HeldResource) error { +func (r *WeaveReconciler) reconcileHolds(ctx context.Context, c *kube.Client, weave *weftv1.Weave, requested, deleting []weftv1.HeldResource) error { log := logf.FromContext(ctx) // Release first. A program that moved a hold from one object to another @@ -83,7 +82,7 @@ func (r *WeaveReconciler) reconcileHolds(ctx context.Context, c *kube.Client, we log.Info("released hold", "kind", existing.Kind, "name", existing.Name) } - kept := make([]v1alpha1.HeldResource, 0, len(requested)) + kept := make([]weftv1.HeldResource, 0, len(requested)) for _, want := range requested { gvk, err := parseGVK(want.APIVersion, want.Kind) if err != nil { @@ -110,7 +109,7 @@ func (r *WeaveReconciler) reconcileHolds(ctx context.Context, c *kube.Client, we gvk.Kind, want.Name, perm.Summary(), perm.Fix()) } - added, err := c.MutateFinalizers(ctx, gvk, want.Name, addFinalizer(naming.HeldFinalizer(weave.Namespace, weave.Name))) + added, err := c.MutateFinalizers(ctx, gvk, want.Name, addFinalizer(r.Served.API.HeldFinalizer(weave.Namespace, weave.Name))) if err != nil { return holdError(gvk, want.Name, err) } @@ -141,8 +140,8 @@ func (r *WeaveReconciler) reconcileHolds(ctx context.Context, c *kube.Client, we func (r *WeaveReconciler) tearDownForHold( ctx context.Context, c *kube.Client, - weave *v1alpha1.Weave, - held *v1alpha1.HeldResource, + weave *weftv1.Weave, + held *weftv1.HeldResource, gvk schema.GroupVersionKind, ) error { log := logf.FromContext(ctx) @@ -177,7 +176,7 @@ func (r *WeaveReconciler) tearDownForHold( } released := *held - if _, err := c.MutateFinalizers(ctx, gvk, held.Name, removeFinalizer(naming.HeldFinalizer(weave.Namespace, weave.Name))); err != nil { + if _, err := c.MutateFinalizers(ctx, gvk, held.Name, removeFinalizer(r.Served.API.HeldFinalizer(weave.Namespace, weave.Name))); err != nil { if !apierrors.IsNotFound(err) { return holdError(gvk, held.Name, err) } @@ -192,7 +191,7 @@ func (r *WeaveReconciler) tearDownForHold( // releaseAllHolds lifts every finalizer this Weave placed. Used when the Weave // itself is going away. -func (r *WeaveReconciler) releaseAllHolds(ctx context.Context, c *kube.Client, weave *v1alpha1.Weave) error { +func (r *WeaveReconciler) releaseAllHolds(ctx context.Context, c *kube.Client, weave *weftv1.Weave) error { for _, held := range weave.Status.Held { if err := r.releaseHold(ctx, c, weave, held); err != nil { return err @@ -202,13 +201,13 @@ func (r *WeaveReconciler) releaseAllHolds(ctx context.Context, c *kube.Client, w return nil } -func (r *WeaveReconciler) releaseHold(ctx context.Context, c *kube.Client, weave *v1alpha1.Weave, held v1alpha1.HeldResource) error { +func (r *WeaveReconciler) releaseHold(ctx context.Context, c *kube.Client, weave *weftv1.Weave, held weftv1.HeldResource) error { gvk, err := parseGVK(held.APIVersion, held.Kind) if err != nil { // An unparseable reference cannot have been finalized in the first place. return nil } - _, err = c.MutateFinalizers(ctx, gvk, held.Name, removeFinalizer(naming.HeldFinalizer(weave.Namespace, weave.Name))) + _, err = c.MutateFinalizers(ctx, gvk, held.Name, removeFinalizer(r.Served.API.HeldFinalizer(weave.Namespace, weave.Name))) switch { case err == nil, apierrors.IsNotFound(err): return nil @@ -234,7 +233,7 @@ func holdError(gvk schema.GroupVersionKind, name string, err error) error { return fmt.Errorf("updating finalizers on %s %q: %w", gvk.Kind, name, err) } -func containsHold(list []v1alpha1.HeldResource, want v1alpha1.HeldResource) bool { +func containsHold(list []weftv1.HeldResource, want weftv1.HeldResource) bool { for _, h := range list { if h.APIVersion == want.APIVersion && h.Kind == want.Kind && h.Name == want.Name { return true @@ -243,8 +242,8 @@ func containsHold(list []v1alpha1.HeldResource, want v1alpha1.HeldResource) bool return false } -func withoutHold(list []v1alpha1.HeldResource, drop v1alpha1.HeldResource) []v1alpha1.HeldResource { - out := make([]v1alpha1.HeldResource, 0, len(list)) +func withoutHold(list []weftv1.HeldResource, drop weftv1.HeldResource) []weftv1.HeldResource { + out := make([]weftv1.HeldResource, 0, len(list)) for _, h := range list { if h.APIVersion == drop.APIVersion && h.Kind == drop.Kind && h.Name == drop.Name { continue diff --git a/internal/controller/handover_test.go b/internal/controller/handover_test.go new file mode 100644 index 0000000..12a664d --- /dev/null +++ b/internal/controller/handover_test.go @@ -0,0 +1,374 @@ +package controller + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + weftv1 "github.com/alethic/weft/api/v1" + "github.com/alethic/weft/api/v1alpha1" + "github.com/alethic/weft/internal/naming" +) + +// reconcileLegacy runs one weft.run pass. +func (h *harness) reconcileLegacy(name string) { + h.t.Helper() + if _, err := h.legacy.Reconcile(h.ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: h.namespace, Name: name}, + }); err != nil { + h.t.Fatalf("reconcile weft.run: %v", err) + } +} + +func (h *harness) createLegacy(name, program string) { + h.t.Helper() + w := &v1alpha1.Weave{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: h.namespace}, + Spec: weftv1.WeaveSpec{ServiceAccountName: "composer", Program: program}, + } + if err := testK8s.Create(h.ctx, w); err != nil { + h.t.Fatalf("creating weft.run weave: %v", err) + } + h.reconcileLegacy(name) +} + +func (h *harness) legacyWeave(name string) *v1alpha1.Weave { + h.t.Helper() + var w v1alpha1.Weave + if err := testK8s.Get(h.ctx, types.NamespacedName{Namespace: h.namespace, Name: name}, &w); err != nil { + h.t.Fatalf("getting weft.run weave: %v", err) + } + return &w +} + +// orphanLegacy does what kubectl delete --cascade=orphan and the garbage +// collector do together. The test control plane runs no garbage collector, so +// its half - stripping the owner references and then the orphan finalizer - is +// done here, after the controller has done its own. +func (h *harness) orphanLegacy(name string, outputs ...string) { + h.t.Helper() + w := h.legacyWeave(name) + orphan := metav1.DeletePropagationOrphan + if err := testK8s.Delete(h.ctx, w, &client.DeleteOptions{PropagationPolicy: &orphan}); err != nil { + h.t.Fatalf("deleting weft.run weave: %v", err) + } + h.reconcileLegacy(name) + + for _, n := range outputs { + cm, err := h.getConfigMap(n) + if err != nil { + continue + } + var kept []metav1.OwnerReference + for _, ref := range cm.OwnerReferences { + if ref.UID != w.UID { + kept = append(kept, ref) + } + } + cm.OwnerReferences = kept + if err := testK8s.Update(h.ctx, cm); err != nil { + h.t.Fatalf("stripping owner reference: %v", err) + } + } + + w = h.legacyWeave(name) + if controllerutil.ContainsFinalizer(w, naming.Legacy.WeaveFinalizer) { + h.t.Fatalf("weft.run controller kept its finalizer through an orphaning delete: %v", w.Finalizers) + } + controllerutil.RemoveFinalizer(w, metav1.FinalizerOrphanDependents) + if err := testK8s.Update(h.ctx, w); err != nil { + h.t.Fatalf("releasing the orphan finalizer: %v", err) + } +} + +func managers(cm *corev1.ConfigMap) map[string]bool { + out := map[string]bool{} + for _, e := range cm.ManagedFields { + if e.Operation == metav1.ManagedFieldsOperationApply { + out[e.Manager] = true + } + } + return out +} + +const legacyProgram = ` +def compose(variable): + resource({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": "settings"}, + "data": {"tenantId": "abc"}, + }) + resource({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": "keeper", "annotations": {"weft.run/owned": "false"}}, + "data": {"keep": "me"}, + }) +` + +// The same program, as it reads once moved: only the owned annotation's key +// changes. +const movedProgram = ` +def compose(variable): + resource({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": "settings"}, + "data": {"tenantId": "abc"}, + }) + resource({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": "keeper", "annotations": {"weft.alethic.solutions/owned": "false"}}, + "data": {"keep": "me"}, + }) +` + +// A weft.run Weave behaves exactly as it did before v1 existed: every key it +// writes is a weft.run key, because those are what the objects in clusters +// that predate v1 already carry. +func TestLegacyWeaveWritesLegacyKeys(t *testing.T) { + h := newHarness(t, nil) + h.createLegacy("app", legacyProgram) + h.reconcileLegacy("app") + + w := h.legacyWeave("app") + if !controllerutil.ContainsFinalizer(w, "weft.run/weave") { + t.Errorf("finalizers = %v, want weft.run/weave", w.Finalizers) + } + + cm, err := h.getConfigMap("settings") + if err != nil { + t.Fatalf("output not created: %v", err) + } + if cm.Labels["weft.run/weave"] != "app" || cm.Annotations["weft.run/weave-uid"] != string(w.UID) { + t.Errorf("provenance = %v / %v, want weft.run keys", cm.Labels, cm.Annotations) + } + for k := range cm.Labels { + if strings.HasPrefix(k, naming.V1.Group) { + t.Errorf("a weft.run Weave wrote the v1 label %s", k) + } + } + if len(cm.OwnerReferences) != 1 || cm.OwnerReferences[0].APIVersion != "weft.run/v1alpha1" { + t.Errorf("owner references = %+v, want one to weft.run/v1alpha1", cm.OwnerReferences) + } + if m := managers(cm); !m["weft.run"] || m[naming.V1.FieldManager] { + t.Errorf("apply managers = %v, want weft.run only", m) + } + + keeper, err := h.getConfigMap("keeper") + if err != nil { + t.Fatalf("unowned output not created: %v", err) + } + if len(keeper.OwnerReferences) != 0 { + t.Error("weft.run/owned=false must still be honoured on a weft.run Weave") + } +} + +// Two Weaves of the same name in different groups are different objects. The +// v1 one must not mistake the weft.run one's objects for its own, and must say +// how to finish the move rather than just refusing. +func TestV1RefusesWhileLegacyWeaveLives(t *testing.T) { + h := newHarness(t, nil) + h.createLegacy("app", legacyProgram) + h.reconcileLegacy("app") + + h.create("app", movedProgram, "") + h.settle("app", 2) + + c := requireCondition(t, h.weave("app"), naming.ConditionDegraded, metav1.ConditionTrue) + if c.Reason != ReasonNotOurs { + t.Errorf("reason = %s, want %s", c.Reason, ReasonNotOurs) + } + for _, want := range []string{"weft.run/v1alpha1", "--cascade=orphan", "weaves.weft.run"} { + if !strings.Contains(c.Message, want) { + t.Errorf("message does not mention %q:\n%s", want, c.Message) + } + } + + // And nothing changed hands. + cm, _ := h.getConfigMap("settings") + if len(cm.OwnerReferences) != 1 || cm.OwnerReferences[0].APIVersion != "weft.run/v1alpha1" { + t.Errorf("owner references = %+v; the v1 Weave took something it was refused", cm.OwnerReferences) + } + if m := managers(cm); m[naming.V1.FieldManager] { + t.Error("the v1 Weave applied to an object it was refused") + } +} + +// The move itself: the weft.run Weave is deleted with --cascade=orphan, and +// the v1 Weave of the same name takes over what it made, in place. +func TestHandoverFromLegacy(t *testing.T) { + h := newHarness(t, nil) + h.createLegacy("app", legacyProgram) + h.reconcileLegacy("app") + + before := map[string]types.UID{} + for _, n := range []string{"settings", "keeper"} { + cm, err := h.getConfigMap(n) + if err != nil { + t.Fatalf("%s not created: %v", n, err) + } + before[n] = cm.UID + } + + h.orphanLegacy("app", "settings", "keeper") + + h.create("app", movedProgram, "") + h.settle("app", 3) + + w := h.weave("app") + requireCondition(t, w, naming.ConditionReady, metav1.ConditionTrue) + if !h.recordedEvent("HandedOver", "weft.run/v1alpha1") { + t.Error("no HandedOver event") + } + + for _, n := range []string{"settings", "keeper"} { + cm, err := h.getConfigMap(n) + if err != nil { + t.Fatalf("%s is gone: %v", n, err) + } + if cm.UID != before[n] { + t.Errorf("%s was recreated; a handover must take it over in place", n) + } + + // The old provenance goes, pruned by the apply now that the v1 manager + // owns the fields it was written into. + for k := range cm.Labels { + if strings.HasPrefix(k, "weft.run/") { + t.Errorf("%s still carries %s", n, k) + } + } + for k := range cm.Annotations { + if strings.HasPrefix(k, "weft.run/") { + t.Errorf("%s still carries %s", n, k) + } + } + if cm.Labels[naming.V1.WeaveLabel] != "app" || cm.Annotations[naming.V1.WeaveUIDAnnotation] != string(w.UID) { + t.Errorf("%s provenance = %v / %v, want the v1 Weave's", n, cm.Labels, cm.Annotations) + } + if m := managers(cm); m["weft.run"] || !m[naming.V1.FieldManager] { + t.Errorf("%s apply managers = %v, want the v1 manager only", n, m) + } + } + + settings, _ := h.getConfigMap("settings") + if len(settings.OwnerReferences) != 1 || settings.OwnerReferences[0].UID != w.UID { + t.Errorf("settings owner references = %+v, want the v1 Weave", settings.OwnerReferences) + } + keeper, _ := h.getConfigMap("keeper") + if len(keeper.OwnerReferences) != 0 { + t.Error("keeper is unowned in both programs and must stay unowned") + } + + // Steady from here: the handover is a one-time step, not something every + // pass repeats. + h.recordedEvent("", "") // drain + h.settle("app", 2) + if h.recordedEvent("HandedOver", "") { + t.Error("handover repeated on a steady-state pass") + } +} + +// Between the orphaning delete and the garbage collector finishing, the old +// Weave still exists. That is not a refusal, just not yet. +func TestHandoverWaitsForPredecessorDeletion(t *testing.T) { + h := newHarness(t, nil) + h.createLegacy("app", legacyProgram) + h.reconcileLegacy("app") + + // Something other than the controller holds the old Weave, so it stays in + // deletion while the v1 Weave reconciles. + w := h.legacyWeave("app") + controllerutil.AddFinalizer(w, "test.example/hold") + if err := testK8s.Update(h.ctx, w); err != nil { + t.Fatal(err) + } + orphan := metav1.DeletePropagationOrphan + if err := testK8s.Delete(h.ctx, w, &client.DeleteOptions{PropagationPolicy: &orphan}); err != nil { + t.Fatal(err) + } + h.reconcileLegacy("app") + + // keeper never had an owner reference, so it needs nothing from garbage + // collection and is what a v1 Weave reaches first. + + h.create("app", ` +def compose(variable): + resource({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": "keeper", "annotations": {"weft.alethic.solutions/owned": "false"}}, + "data": {"keep": "me"}, + }) +`, "") + h.settle("app", 2) + + c := requireCondition(t, h.weave("app"), naming.ConditionWaiting, metav1.ConditionTrue) + if c.Reason != ReasonHandover { + t.Errorf("reason = %s (%s), want %s", c.Reason, c.Message, ReasonHandover) + } + + w = h.legacyWeave("app") + w.Finalizers = nil + if err := testK8s.Update(h.ctx, w); err != nil { + t.Fatal(err) + } + h.settle("app", 2) + requireCondition(t, h.weave("app"), naming.ConditionReady, metav1.ConditionTrue) +} + +// A program moved to v1 that still says weft.run/owned: "false" would have +// everything it meant to keep owned by the new Weave, and deleted with it. +func TestV1RejectsLegacyOwnedAnnotation(t *testing.T) { + h := newHarness(t, nil) + h.create("app", legacyProgram, "") + h.settle("app", 2) + + c := requireCondition(t, h.weave("app"), naming.ConditionDegraded, metav1.ConditionTrue) + if !strings.Contains(c.Message, "weft.run/owned") || !strings.Contains(c.Message, "weft.alethic.solutions/owned") { + t.Errorf("message should name both annotations:\n%s", c.Message) + } + if h.exists("keeper") { + t.Error("keeper was created owned despite the program asking for it not to be") + } +} + +// A weft.run Weave and a v1 Weave are independent even with one name: each is +// reconciled by its own controller, and neither one's teardown touches the +// other's objects. +func TestSameNameAcrossGroupsIsIndependent(t *testing.T) { + h := newHarness(t, nil) + h.createLegacy("app", ` +def compose(variable): + resource({"apiVersion": "v1", "kind": "ConfigMap", "metadata": {"name": "old"}, "data": {}}) +`) + h.reconcileLegacy("app") + h.create("app", ` +def compose(variable): + resource({"apiVersion": "v1", "kind": "ConfigMap", "metadata": {"name": "new"}, "data": {}}) +`, "") + h.settle("app", 2) + requireCondition(t, h.weave("app"), naming.ConditionReady, metav1.ConditionTrue) + + if err := testK8s.Delete(h.ctx, h.weave("app")); err != nil { + t.Fatal(err) + } + h.settle("app", 3) + if h.exists("new") { + t.Error("the v1 Weave's output survived its deletion") + } + if !h.exists("old") { + t.Error("deleting the v1 Weave tore down the weft.run Weave's output") + } + if !controllerutil.ContainsFinalizer(h.legacyWeave("app"), naming.Legacy.WeaveFinalizer) { + t.Error("the weft.run Weave lost its finalizer") + } +} diff --git a/internal/controller/observed.go b/internal/controller/observed.go index 228c814..7127d8e 100644 --- a/internal/controller/observed.go +++ b/internal/controller/observed.go @@ -9,7 +9,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/eval" "github.com/alethic/weft/internal/inventory" "github.com/alethic/weft/internal/kube" @@ -21,7 +21,7 @@ import ( // Self-reference through these values is how a composition advances in stages, // so they are read live rather than remembered: a resource we applied minutes // ago is interesting precisely because its status has changed since. -func (r *WeaveReconciler) readObserved(ctx context.Context, c *kube.Client, weave *v1alpha1.Weave) (map[eval.Ref]map[string]any, error) { +func (r *WeaveReconciler) readObserved(ctx context.Context, c *kube.Client, weave *weftv1.Weave) (map[eval.Ref]map[string]any, error) { observed := make(map[eval.Ref]map[string]any, len(weave.Status.Inventory)) for _, e := range weave.Status.Inventory { @@ -69,7 +69,7 @@ func (r *WeaveReconciler) readObserved(ctx context.Context, c *kube.Client, weav // runs. Persisting it is what lets a controller restart re-establish the // watches for a Weave that is waiting and will not evaluate again until // something it cannot currently see changes. -func watchKeys(weave *v1alpha1.Weave) []watches.Key { +func watchKeys(weave *weftv1.Weave) []watches.Key { seen := make(map[watches.Key]bool) var keys []watches.Key diff --git a/internal/controller/owned_test.go b/internal/controller/owned_test.go index 98467f3..a344b61 100644 --- a/internal/controller/owned_test.go +++ b/internal/controller/owned_test.go @@ -6,7 +6,7 @@ import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/naming" ) @@ -50,7 +50,7 @@ func TestUnownedResourceGetsNoOwnerReference(t *testing.T) { } // Still findable as something this Weave made. - if keeper.Labels[naming.WeaveLabel] != "keeping" { + if keeper.Labels[naming.V1.WeaveLabel] != "keeping" { t.Errorf("labels = %v, want the Weave label kept", keeper.Labels) } @@ -93,7 +93,7 @@ func TestUnownedResourceSurvivesLeavingTheWeave(t *testing.T) { } w := h.weave("dropping") - w.Spec.Variables = []v1alpha1.Variable{ + w.Spec.Variables = []weftv1.Variable{ {Values: &apiextensionsv1.JSON{Raw: []byte(`{"dropped": true}`)}}, } if err := testK8s.Update(h.ctx, w); err != nil { diff --git a/internal/controller/read_test.go b/internal/controller/read_test.go index 9370c4e..5527aed 100644 --- a/internal/controller/read_test.go +++ b/internal/controller/read_test.go @@ -6,7 +6,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/naming" ) @@ -136,9 +136,9 @@ func TestReadIsDeniedWithoutPermission(t *testing.T) { h := newHarness(t, nil) h.configMap("classified", map[string]string{"secret": "yes"}) - w := &v1alpha1.Weave{ + w := &weftv1.Weave{ ObjectMeta: metav1.ObjectMeta{Name: "nosy", Namespace: h.namespace}, - Spec: v1alpha1.WeaveSpec{ + Spec: weftv1.WeaveSpec{ ServiceAccountName: "powerless", Program: ` def compose(variable): @@ -225,7 +225,7 @@ def compose(variable): if err != nil { t.Fatal(err) } - want := naming.HeldFinalizer(h.namespace, "holder") + want := naming.V1.HeldFinalizer(h.namespace, "holder") if !hasFinalizer(upstream.Finalizers, want) { t.Errorf("finalizers = %v, want %q", upstream.Finalizers, want) } @@ -265,12 +265,12 @@ def compose(variable): if err != nil { t.Fatal(err) } - if hasFinalizer(upstream.Finalizers, naming.HeldFinalizer(h.namespace, "relaxing")) { + if hasFinalizer(upstream.Finalizers, naming.V1.HeldFinalizer(h.namespace, "relaxing")) { t.Errorf("finalizer still on the object: %v", upstream.Finalizers) } } -func hasRead(reads []v1alpha1.ReadRef, apiVersion, kind string) bool { +func hasRead(reads []weftv1.ReadRef, apiVersion, kind string) bool { for _, r := range reads { if r.APIVersion == apiVersion && r.Kind == kind { return true diff --git a/internal/controller/reader.go b/internal/controller/reader.go index 2ef89a2..0c9698e 100644 --- a/internal/controller/reader.go +++ b/internal/controller/reader.go @@ -10,7 +10,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/kube" "github.com/alethic/weft/internal/metrics" ) @@ -29,7 +29,7 @@ import ( // the same value each time, so an evaluation cannot contradict itself. type weaveReader struct { c *kube.Client - weave *v1alpha1.Weave + weave *weftv1.Weave objects map[string]*unstructured.Unstructured lists map[string][]unstructured.Unstructured @@ -40,20 +40,20 @@ type weaveReader struct { kinds map[schema.GroupVersionKind]bool // holds is every resource the program asked to hold, by identity. - holds map[string]v1alpha1.HeldResource + holds map[string]weftv1.HeldResource // deleting names the held resources found mid-deletion during this pass. - deleting []v1alpha1.HeldResource + deleting []weftv1.HeldResource } -func newWeaveReader(c *kube.Client, weave *v1alpha1.Weave) *weaveReader { +func newWeaveReader(c *kube.Client, weave *weftv1.Weave) *weaveReader { return &weaveReader{ c: c, weave: weave, objects: map[string]*unstructured.Unstructured{}, lists: map[string][]unstructured.Unstructured{}, kinds: map[schema.GroupVersionKind]bool{}, - holds: map[string]v1alpha1.HeldResource{}, + holds: map[string]weftv1.HeldResource{}, } } @@ -78,7 +78,7 @@ func (r *weaveReader) Read(ctx context.Context, apiVersion, kind, name string, h } if hold && obj != nil { - held := v1alpha1.HeldResource{APIVersion: apiVersion, Kind: kind, Name: name} + held := weftv1.HeldResource{APIVersion: apiVersion, Kind: kind, Name: name} r.holds[refKey(apiVersion, kind, name)] = held if obj.GetDeletionTimestamp() != nil { r.deleting = append(r.deleting, held) @@ -152,10 +152,10 @@ func (r *weaveReader) Select(ctx context.Context, apiVersion, kind string, label } // reads returns the recorded read set for the status, in a stable order. -func (r *weaveReader) reads() []v1alpha1.ReadRef { - out := make([]v1alpha1.ReadRef, 0, len(r.kinds)) +func (r *weaveReader) reads() []weftv1.ReadRef { + out := make([]weftv1.ReadRef, 0, len(r.kinds)) for gvk := range r.kinds { - out = append(out, v1alpha1.ReadRef{ + out = append(out, weftv1.ReadRef{ APIVersion: gvk.GroupVersion().String(), Kind: gvk.Kind, }) @@ -171,8 +171,8 @@ func (r *weaveReader) reads() []v1alpha1.ReadRef { // requestedHolds returns the hold requests made this pass, in a stable // order. -func (r *weaveReader) requestedHolds() []v1alpha1.HeldResource { - out := make([]v1alpha1.HeldResource, 0, len(r.holds)) +func (r *weaveReader) requestedHolds() []weftv1.HeldResource { + out := make([]weftv1.HeldResource, 0, len(r.holds)) for _, h := range r.holds { out = append(out, h) } diff --git a/internal/controller/reclaim_test.go b/internal/controller/reclaim_test.go index 14b4453..2b5d2e4 100644 --- a/internal/controller/reclaim_test.go +++ b/internal/controller/reclaim_test.go @@ -28,11 +28,11 @@ def compose(variable): } w := h.weave("stamper") - if got := cm.Labels[naming.WeaveLabel]; got != "stamper" { - t.Errorf("%s = %q", naming.WeaveLabel, got) + if got := cm.Labels[naming.V1.WeaveLabel]; got != "stamper" { + t.Errorf("%s = %q", naming.V1.WeaveLabel, got) } - if got := cm.Annotations[naming.WeaveUIDAnnotation]; got != string(w.UID) { - t.Errorf("%s = %q, want the Weave's UID %q", naming.WeaveUIDAnnotation, got, w.UID) + if got := cm.Annotations[naming.V1.WeaveUIDAnnotation]; got != string(w.UID) { + t.Errorf("%s = %q, want the Weave's UID %q", naming.V1.WeaveUIDAnnotation, got, w.UID) } } @@ -124,10 +124,10 @@ def compose(variable): func TestProvenanceFromAnotherWeaveIsNotReclaimed(t *testing.T) { h := newHarness(t, nil) cm := h.configMap("impostor", map[string]string{"owner": "somebody-else"}) - cm.Labels = map[string]string{naming.WeaveLabel: "claimant"} + cm.Labels = map[string]string{naming.V1.WeaveLabel: "claimant"} cm.Annotations = map[string]string{ - naming.WeaveUIDAnnotation: "thing", - naming.WeaveUIDAnnotation: "11111111-1111-1111-1111-111111111111", + naming.V1.WeaveUIDAnnotation: "thing", + naming.V1.WeaveUIDAnnotation: "11111111-1111-1111-1111-111111111111", } if err := testK8s.Update(h.ctx, cm); err != nil { t.Fatal(err) diff --git a/internal/controller/release.go b/internal/controller/release.go index 334b823..f111c83 100644 --- a/internal/controller/release.go +++ b/internal/controller/release.go @@ -8,10 +8,9 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" logf "sigs.k8s.io/controller-runtime/pkg/log" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/inventory" "github.com/alethic/weft/internal/kube" - "github.com/alethic/weft/internal/naming" ) // releaseAll drops the record of every unowned entry that is no longer @@ -23,9 +22,9 @@ import ( // takes our reference back off when we stop setting it, and this catches an // object that was already dropped from the returned set in the same pass. // -// What stays is the weft.alethic.solutions/weave label and the weft.alethic.solutions/key annotation, so a -// released object is still findable as something this Weave once made. -func (r *WeaveReconciler) releaseAll(ctx context.Context, c *kube.Client, weave *v1alpha1.Weave, entries []v1alpha1.InventoryEntry) error { +// What stays is the provenance label and annotation, so a released object is +// still findable as something this Weave once made. +func (r *WeaveReconciler) releaseAll(ctx context.Context, c *kube.Client, weave *weftv1.Weave, entries []weftv1.InventoryEntry) error { log := logf.FromContext(ctx) for _, e := range entries { @@ -73,7 +72,7 @@ func (r *WeaveReconciler) releaseAll(ctx context.Context, c *kube.Client, weave r.eventf(weave, "Normal", "Released", "let go of %s %q, which was applied %s=false. It is no longer tracked by this Weave and "+ "will not be deleted with it.", - e.Kind, e.Name, naming.OwnedAnnotation) + e.Kind, e.Name, r.Served.API.OwnedAnnotation) } return nil @@ -81,7 +80,7 @@ func (r *WeaveReconciler) releaseAll(ctx context.Context, c *kube.Client, weave // partitionOwned splits an inventory into what this Weave may delete and what // it only ever tracked. -func partitionOwned(entries []v1alpha1.InventoryEntry) (owned, unowned []v1alpha1.InventoryEntry) { +func partitionOwned(entries []weftv1.InventoryEntry) (owned, unowned []weftv1.InventoryEntry) { for _, e := range entries { if e.Owned { owned = append(owned, e) diff --git a/internal/controller/served.go b/internal/controller/served.go new file mode 100644 index 0000000..dd0f3f6 --- /dev/null +++ b/internal/controller/served.go @@ -0,0 +1,72 @@ +package controller + +import ( + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + + weftv1 "github.com/alethic/weft/api/v1" + "github.com/alethic/weft/api/v1alpha1" + "github.com/alethic/weft/internal/naming" +) + +// Served is one API group's Weave type, and how to move between it and the +// v1 form the reconciler works on. +// +// The two groups share their spec and status types, so the conversion is a +// change of envelope and nothing else. Reconciling everything as v1 is what +// keeps the reconciler single: the only things that differ between groups are +// the object the API server is sent and the keys written onto what a Weave +// manages, and both come from here. +type Served struct { + // API is the group and every identifier derived from it. + API naming.API + + // New returns an empty object of this group's type. + New func() client.Object + + // ToV1 views an object of this group's type as a v1 Weave. The result + // shares nothing mutable with its argument. + ToV1 func(client.Object) *weftv1.Weave + + // FromV1 builds an object of this group's type from a v1 Weave, for + // writing back. + FromV1 func(*weftv1.Weave) client.Object +} + +// ServedV1 is weft.alethic.solutions/v1. +var ServedV1 = Served{ + API: naming.V1, + New: func() client.Object { return &weftv1.Weave{} }, + ToV1: func(o client.Object) *weftv1.Weave { + w, ok := o.(*weftv1.Weave) + if !ok { + panic(fmt.Sprintf("v1 reconciler handed a %T", o)) + } + return w.DeepCopy() + }, + FromV1: func(w *weftv1.Weave) client.Object { + return w.DeepCopy() + }, +} + +// ServedLegacy is weft.run/v1alpha1. +var ServedLegacy = Served{ + API: naming.Legacy, + New: func() client.Object { return &v1alpha1.Weave{} }, + ToV1: func(o client.Object) *weftv1.Weave { + legacy, ok := o.(*v1alpha1.Weave) + if !ok { + panic(fmt.Sprintf("weft.run reconciler handed a %T", o)) + } + w := legacy.DeepCopy() + return &weftv1.Weave{ObjectMeta: w.ObjectMeta, Spec: w.Spec, Status: w.Status} + }, + FromV1: func(w *weftv1.Weave) client.Object { + c := w.DeepCopy() + return &v1alpha1.Weave{ObjectMeta: c.ObjectMeta, Spec: c.Spec, Status: c.Status} + }, +} + +// AllServed lists every group the controller reconciles, current first. +var AllServed = []Served{ServedV1, ServedLegacy} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 374ad2b..feaf217 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -20,6 +20,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/apiutil" logf "sigs.k8s.io/controller-runtime/pkg/log" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/api/v1alpha1" "github.com/alethic/weft/internal/eval" "github.com/alethic/weft/internal/kube" @@ -39,12 +40,15 @@ var ( testK8s client.Client ) -// harness is one reconciler wired to a fresh namespace. +// harness is the reconcilers for both served groups, wired to a fresh +// namespace. reconciler is v1's; legacy is weft.run's, sharing everything else +// the way the two do in the binary. type harness struct { t *testing.T ctx context.Context namespace string reconciler *WeaveReconciler + legacy *WeaveReconciler registry *watches.Registry events *record.FakeRecorder cancel context.CancelFunc @@ -107,7 +111,7 @@ func newHarness(t *testing.T, tune func(*Options)) *harness { // reconciler that has already stopped. t.Cleanup(func() { cleanup := context.Background() - var weaves v1alpha1.WeaveList + var weaves weftv1.WeaveList if err := testK8s.List(cleanup, &weaves, client.InNamespace(ns)); err == nil { for i := range weaves.Items { w := &weaves.Items[i] @@ -118,6 +122,17 @@ func newHarness(t *testing.T, tune func(*Options)) *harness { _ = testK8s.Update(cleanup, w) } } + var legacy v1alpha1.WeaveList + if err := testK8s.List(cleanup, &legacy, client.InNamespace(ns)); err == nil { + for i := range legacy.Items { + w := &legacy.Items[i] + if len(w.Finalizers) == 0 { + continue + } + w.Finalizers = nil + _ = testK8s.Update(cleanup, w) + } + } _ = testK8s.Delete(cleanup, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}) }) @@ -151,6 +166,7 @@ func newHarness(t *testing.T, tune func(*Options)) *harness { events := record.NewFakeRecorder(256) r := &WeaveReconciler{ + Served: ServedV1, Client: testK8s, Scheme: testK8s.Scheme(), Factory: factory, @@ -161,9 +177,12 @@ func newHarness(t *testing.T, tune func(*Options)) *harness { } r.Opts.applyDefaults() + legacy := *r + legacy.Served = ServedLegacy + return &harness{ t: t, ctx: ctx, namespace: ns, - reconciler: r, registry: registry, events: events, cancel: cancel, + reconciler: r, legacy: &legacy, registry: registry, events: events, cancel: cancel, } } @@ -205,26 +224,26 @@ func (h *harness) settle(name string, passes int) { } } -func (h *harness) weave(name string) *v1alpha1.Weave { +func (h *harness) weave(name string) *weftv1.Weave { h.t.Helper() - var w v1alpha1.Weave + var w weftv1.Weave if err := testK8s.Get(h.ctx, types.NamespacedName{Namespace: h.namespace, Name: name}, &w); err != nil { h.t.Fatalf("getting weave: %v", err) } return &w } -func (h *harness) create(name, program, values string) *v1alpha1.Weave { +func (h *harness) create(name, program, values string) *weftv1.Weave { h.t.Helper() - w := &v1alpha1.Weave{ + w := &weftv1.Weave{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: h.namespace}, - Spec: v1alpha1.WeaveSpec{ + Spec: weftv1.WeaveSpec{ ServiceAccountName: "composer", Program: program, }, } if values != "" { - w.Spec.Variables = []v1alpha1.Variable{ + w.Spec.Variables = []weftv1.Variable{ {Values: &apiextensionsv1.JSON{Raw: []byte(values)}}, } } @@ -262,7 +281,7 @@ func (h *harness) exists(name string) bool { return err == nil } -func condition(w *v1alpha1.Weave, condType string) *metav1.Condition { +func condition(w *weftv1.Weave, condType string) *metav1.Condition { for i := range w.Status.Conditions { if w.Status.Conditions[i].Type == condType { return &w.Status.Conditions[i] @@ -271,7 +290,7 @@ func condition(w *v1alpha1.Weave, condType string) *metav1.Condition { return nil } -func requireCondition(t *testing.T, w *v1alpha1.Weave, condType string, status metav1.ConditionStatus) *metav1.Condition { +func requireCondition(t *testing.T, w *weftv1.Weave, condType string, status metav1.ConditionStatus) *metav1.Condition { t.Helper() c := condition(w, condType) if c == nil { @@ -316,7 +335,7 @@ def compose(variable): if len(refs) != 1 || refs[0].Kind != naming.Kind || refs[0].UID != w.UID { t.Errorf("output is not owned by the Weave: %+v", refs) } - if cm.Labels[naming.WeaveLabel] != "app" { + if cm.Labels[naming.V1.WeaveLabel] != "app" { t.Errorf("labels = %v", cm.Labels) } if len(w.Status.Inventory) != 1 || w.Status.Inventory[0].Name != "settings" { @@ -648,7 +667,7 @@ def compose(variable): if h.exists("base") || h.exists("middle") { t.Error("everything should be gone once the hold is released") } - var gone v1alpha1.Weave + var gone weftv1.Weave err = testK8s.Get(h.ctx, types.NamespacedName{Namespace: h.namespace, Name: "stack"}, &gone) if !apierrors.IsNotFound(err) { t.Errorf("the Weave should have been released, got %v", err) @@ -824,12 +843,12 @@ def compose(variable): found := false for _, f := range w.Finalizers { - if f == naming.WeaveFinalizer { + if f == naming.V1.WeaveFinalizer { found = true } } if !found { - t.Errorf("finalizers = %v, want %q", w.Finalizers, naming.WeaveFinalizer) + t.Errorf("finalizers = %v, want %q", w.Finalizers, naming.V1.WeaveFinalizer) } } diff --git a/internal/controller/variables.go b/internal/controller/variables.go index 8d03c19..c49929b 100644 --- a/internal/controller/variables.go +++ b/internal/controller/variables.go @@ -9,7 +9,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" yaml "sigs.k8s.io/yaml" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/jsonutil" "github.com/alethic/weft/internal/variables" ) @@ -28,7 +28,7 @@ var ( // // Every read is impersonated, like every other read: a Weave can take // configuration only from objects its ServiceAccount could read directly. -func (r *WeaveReconciler) resolveVariables(ctx context.Context, rd *weaveReader, weave *v1alpha1.Weave) (map[string]any, error) { +func (r *WeaveReconciler) resolveVariables(ctx context.Context, rd *weaveReader, weave *weftv1.Weave) (map[string]any, error) { out := map[string]any{} for i, entry := range weave.Spec.Variables { @@ -41,7 +41,7 @@ func (r *WeaveReconciler) resolveVariables(ctx context.Context, rd *weaveReader, return out, nil } -func (r *WeaveReconciler) resolveVariable(ctx context.Context, rd *weaveReader, entry v1alpha1.Variable, index int) (map[string]any, error) { +func (r *WeaveReconciler) resolveVariable(ctx context.Context, rd *weaveReader, entry weftv1.Variable, index int) (map[string]any, error) { switch { case entry.Values != nil: return jsonutil.DecodeObject(entry.Values), nil @@ -64,7 +64,7 @@ func (r *WeaveReconciler) readVariableObject( ctx context.Context, rd *weaveReader, gvk schema.GroupVersionKind, - ref v1alpha1.VariableRef, + ref weftv1.VariableRef, index int, encoded bool, ) (map[string]any, error) { diff --git a/internal/controller/variables_test.go b/internal/controller/variables_test.go index c7b2b0e..2a9599f 100644 --- a/internal/controller/variables_test.go +++ b/internal/controller/variables_test.go @@ -8,7 +8,7 @@ import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/naming" ) @@ -27,11 +27,11 @@ def compose(variable): }) ` -func (h *harness) createWithVariables(name string, entries []v1alpha1.Variable) *v1alpha1.Weave { +func (h *harness) createWithVariables(name string, entries []weftv1.Variable) *weftv1.Weave { h.t.Helper() - w := &v1alpha1.Weave{ + w := &weftv1.Weave{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: h.namespace}, - Spec: v1alpha1.WeaveSpec{ + Spec: weftv1.WeaveSpec{ ServiceAccountName: "composer", Variables: entries, Program: echoVariables, @@ -44,8 +44,8 @@ func (h *harness) createWithVariables(name string, entries []v1alpha1.Variable) return w } -func inlineValues(raw string) v1alpha1.Variable { - return v1alpha1.Variable{Values: &apiextensionsv1.JSON{Raw: []byte(raw)}} +func inlineValues(raw string) weftv1.Variable { + return weftv1.Variable{Values: &apiextensionsv1.JSON{Raw: []byte(raw)}} } // A ConfigMap under a Weave's control, and one written inline over it. A @@ -58,8 +58,8 @@ func TestVariablesOverlayConfigMapWithInlineValues(t *testing.T) { "tier": "standard", }) - h.createWithVariables("layered", []v1alpha1.Variable{ - {ConfigMap: &v1alpha1.VariableRef{Name: "platform"}}, + h.createWithVariables("layered", []weftv1.Variable{ + {ConfigMap: &weftv1.VariableRef{Name: "platform"}}, inlineValues(`{"tier": "premium", "extra": "inline"}`), }) h.settle("layered", 2) @@ -91,8 +91,8 @@ func TestVariablesFromSecretAreDecoded(t *testing.T) { t.Fatal(err) } - h.createWithVariables("fromsecret", []v1alpha1.Variable{ - {Secret: &v1alpha1.VariableRef{Name: "creds"}}, + h.createWithVariables("fromsecret", []weftv1.Variable{ + {Secret: &weftv1.VariableRef{Name: "creds"}}, }) h.settle("fromsecret", 2) @@ -113,8 +113,8 @@ func TestVariablesFromAKeyParsedAsYAML(t *testing.T) { "values.yaml": "image:\n repository: base\n tag: v1\nreplicas: 2\n", }) - h.createWithVariables("document", []v1alpha1.Variable{ - {ConfigMap: &v1alpha1.VariableRef{Name: "values", Key: "values.yaml"}}, + h.createWithVariables("document", []weftv1.Variable{ + {ConfigMap: &weftv1.VariableRef{Name: "values", Key: "values.yaml"}}, inlineValues(`{"image": {"tag": "v2"}}`), }) h.settle("document", 2) @@ -139,8 +139,8 @@ func TestVariablesFromAKeyParsedAsYAML(t *testing.T) { func TestVariablesWaitForAMissingObject(t *testing.T) { h := newHarness(t, nil) - h.createWithVariables("waiting", []v1alpha1.Variable{ - {ConfigMap: &v1alpha1.VariableRef{Name: "not-there"}}, + h.createWithVariables("waiting", []weftv1.Variable{ + {ConfigMap: &weftv1.VariableRef{Name: "not-there"}}, }) h.settle("waiting", 2) @@ -170,9 +170,9 @@ func TestVariablesWaitForAMissingObject(t *testing.T) { func TestVariablesSkipAnOptionalMissingObject(t *testing.T) { h := newHarness(t, nil) - h.createWithVariables("optional", []v1alpha1.Variable{ + h.createWithVariables("optional", []weftv1.Variable{ inlineValues(`{"base": "yes"}`), - {ConfigMap: &v1alpha1.VariableRef{Name: "absent", Optional: true}}, + {ConfigMap: &weftv1.VariableRef{Name: "absent", Optional: true}}, }) h.settle("optional", 2) @@ -192,8 +192,8 @@ func TestVariablesReactToAConfigMapChange(t *testing.T) { h := newHarness(t, nil) h.configMap("settings", map[string]string{"mode": "before"}) - h.createWithVariables("reactive", []v1alpha1.Variable{ - {ConfigMap: &v1alpha1.VariableRef{Name: "settings"}}, + h.createWithVariables("reactive", []weftv1.Variable{ + {ConfigMap: &weftv1.VariableRef{Name: "settings"}}, }) h.settle("reactive", 2) @@ -224,13 +224,13 @@ func TestVariablesAreReadUnderImpersonation(t *testing.T) { h := newHarness(t, nil) h.configMap("secrets-of-state", map[string]string{"classified": "yes"}) - w := &v1alpha1.Weave{ + w := &weftv1.Weave{ ObjectMeta: metav1.ObjectMeta{Name: "nosy", Namespace: h.namespace}, - Spec: v1alpha1.WeaveSpec{ + Spec: weftv1.WeaveSpec{ // Granted nothing at all. ServiceAccountName: "powerless", - Variables: []v1alpha1.Variable{ - {ConfigMap: &v1alpha1.VariableRef{Name: "secrets-of-state"}}, + Variables: []weftv1.Variable{ + {ConfigMap: &weftv1.VariableRef{Name: "secrets-of-state"}}, }, Program: echoVariables, }, diff --git a/internal/controller/weave_controller.go b/internal/controller/weave_controller.go index 84a685e..b965bf2 100644 --- a/internal/controller/weave_controller.go +++ b/internal/controller/weave_controller.go @@ -13,13 +13,13 @@ import ( "context" "errors" "fmt" + "strings" "time" "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -28,7 +28,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/source" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/eval" "github.com/alethic/weft/internal/inventory" "github.com/alethic/weft/internal/kube" @@ -113,8 +113,15 @@ func (o *Options) applyDefaults() { } } -// WeaveReconciler reconciles Weave objects. +// WeaveReconciler reconciles the Weave objects of one API group. +// +// One is registered per served group. They share the Factory, the evaluator +// and the watch registry, and differ only in Served: which type they read and +// write, and which keys they put on what they manage. type WeaveReconciler struct { + // Served is the group this reconciler handles. Zero means v1. + Served Served + // Client reads and writes Weave objects with the controller's own // identity. It is used for nothing else: every resource a composition // touches goes through the impersonated Factory instead. @@ -131,17 +138,26 @@ type WeaveReconciler struct { // SetupWithManager registers the controller. func (r *WeaveReconciler) SetupWithManager(mgr ctrl.Manager) error { r.Opts.applyDefaults() + if r.Served.New == nil { + r.Served = ServedV1 + } if r.Evaluator == nil { r.Evaluator = eval.NewStarlark(eval.Options{}) } + // Controller names must be unique within a manager, and they label + // controller-runtime's own metrics. The current group has the plain name. + name := "weave" + if r.Served.API.Group != ServedV1.API.Group { + name = "weave-" + strings.ReplaceAll(r.Served.API.Group, ".", "-") + } return ctrl.NewControllerManagedBy(mgr). - Named("weave"). - For(&v1alpha1.Weave{}). + Named(name). + For(r.Served.New()). // Everything a composition observes arrives through the registry's // channel rather than through a statically declared watch, because the // set of types to watch is whatever the Weaves in the cluster happen to // declare, and it changes as they are edited. - WatchesRawSource(source.Channel(r.Watches.Events(), &handler.EnqueueRequestForObject{})). + WatchesRawSource(source.Channel(r.Watches.Events(r.Served.API.Group), &handler.EnqueueRequestForObject{})). Complete(r) } @@ -162,37 +178,43 @@ func (r *WeaveReconciler) SetupWithManager(mgr ctrl.Manager) error { // impersonate on groups is pinned to two names. Granting it unrestricted would // let anyone who compromised the controller impersonate system:masters. // +// Both served groups need the same three rules. +// // +kubebuilder:rbac:groups=weft.alethic.solutions,resources=weaves,verbs=get;list;watch;update;patch // +kubebuilder:rbac:groups=weft.alethic.solutions,resources=weaves/status,verbs=get;update;patch // +kubebuilder:rbac:groups=weft.alethic.solutions,resources=weaves/finalizers,verbs=update +// +kubebuilder:rbac:groups=weft.run,resources=weaves,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=weft.run,resources=weaves/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=weft.run,resources=weaves/finalizers,verbs=update // +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=impersonate // +kubebuilder:rbac:groups="",resources=groups,verbs=impersonate,resourceNames=system:serviceaccounts;system:authenticated // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch // Reconcile brings one Weave's resources in line with its program. func (r *WeaveReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - var weave v1alpha1.Weave - if err := r.Client.Get(ctx, req.NamespacedName, &weave); err != nil { + obj := r.Served.New() + if err := r.Client.Get(ctx, req.NamespacedName, obj); err != nil { if apierrors.IsNotFound(err) { - r.Watches.Release(req.NamespacedName) + r.Watches.Release(r.watchRef(req.Namespace, req.Name)) // Drop the gauges too, or they keep reporting a Weave that no // longer exists and the cardinality only ever grows. - metrics.ForgetWeave(req.Namespace, req.Name) + metrics.ForgetWeave(r.Served.API.Group, req.Namespace, req.Name) return ctrl.Result{}, nil } return ctrl.Result{}, err } + weave := r.Served.ToV1(obj) if !weave.DeletionTimestamp.IsZero() { - return r.reconcileDeletion(ctx, &weave) + return r.reconcileDeletion(ctx, weave) } - if !controllerutil.ContainsFinalizer(&weave, naming.WeaveFinalizer) { + if !controllerutil.ContainsFinalizer(weave, r.Served.API.WeaveFinalizer) { // The finalizer is what buys the chance to tear resources down in // order. Without it, deleting a Weave hands everything to cascading // collection at once. - controllerutil.AddFinalizer(&weave, naming.WeaveFinalizer) - if err := r.Client.Update(ctx, &weave); err != nil { + controllerutil.AddFinalizer(weave, r.Served.API.WeaveFinalizer) + if err := r.update(ctx, weave); err != nil { return ctrl.Result{}, err } // The update re-triggers this reconcile; nothing more to do here. @@ -200,12 +222,12 @@ func (r *WeaveReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl } before := weave.DeepCopy() - result, err := r.reconcileActive(ctx, &weave) + result, err := r.reconcileActive(ctx, weave) // Status is written on every path. A Weave that cannot be reconciled has // to say why on itself, because the alternative is what this replaces: // errors that land in a Job log and on no object at all. - if serr := r.writeStatus(ctx, before, &weave); serr != nil { + if serr := r.writeStatus(ctx, before, weave); serr != nil { if err == nil { return ctrl.Result{}, serr } @@ -215,8 +237,8 @@ func (r *WeaveReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl } // reconcileActive is the steady-state path. -func (r *WeaveReconciler) reconcileActive(ctx context.Context, weave *v1alpha1.Weave) (ctrl.Result, error) { - c, err := r.Factory.For(weave.Namespace, weave.Spec.ServiceAccountName) +func (r *WeaveReconciler) reconcileActive(ctx context.Context, weave *weftv1.Weave) (ctrl.Result, error) { + c, err := r.Factory.For(r.Served.API, weave.Namespace, weave.Spec.ServiceAccountName) if err != nil { markDegraded(weave, ReasonInvalidSpec, err.Error()) return ctrl.Result{RequeueAfter: r.Opts.DegradedRetry}, nil @@ -224,8 +246,7 @@ func (r *WeaveReconciler) reconcileActive(ctx context.Context, weave *v1alpha1.W // Register watches before doing any work, so that a Weave which is about to // stop with "the ResourceGroup does not exist" still wakes up when it does. - report, werr := r.Watches.Ensure( - types.NamespacedName{Namespace: weave.Namespace, Name: weave.Name}, c, watchKeys(weave)) + report, werr := r.Watches.Ensure(r.watchRef(weave.Namespace, weave.Name), c, watchKeys(weave)) if werr != nil && !errors.Is(werr, watches.ErrNotStarted) { return ctrl.Result{}, werr } @@ -244,11 +265,11 @@ func (r *WeaveReconciler) reconcileActive(ctx context.Context, weave *v1alpha1.W switch h.kind { case haltWaiting: markWaiting(weave, h.reason, h.message) - metrics.SetWeaveState(weave.Namespace, weave.Name, metrics.StateWaiting) + metrics.SetWeaveState(r.Served.API.Group, weave.Namespace, weave.Name, metrics.StateWaiting) return ctrl.Result{RequeueAfter: requeue}, nil case haltDegraded: markDegraded(weave, h.reason, h.message) - metrics.SetWeaveState(weave.Namespace, weave.Name, metrics.StateDegraded) + metrics.SetWeaveState(r.Served.API.Group, weave.Namespace, weave.Name, metrics.StateDegraded) r.eventf(weave, "Warning", h.reason, "%s", firstLine(h.message)) return ctrl.Result{RequeueAfter: r.Opts.DegradedRetry}, nil } @@ -266,12 +287,12 @@ func (r *WeaveReconciler) reconcileActive(ctx context.Context, weave *v1alpha1.W // Resources were applied, but the program said it is not finished. Ready // would be a lie here: the point of staging is that half the composition // does not exist yet. - metrics.WeaveResources.WithLabelValues(weave.Namespace, weave.Name).Set(float64(len(weave.Status.Inventory))) + metrics.WeaveResources.WithLabelValues(r.Served.API.Group, weave.Namespace, weave.Name).Set(float64(len(weave.Status.Inventory))) if len(pass.pending) > 0 { markWaiting(weave, ReasonFieldUnresolved, pass.summary+"; still waiting for "+joinWithAnd(pass.pending)) - metrics.SetWeaveState(weave.Namespace, weave.Name, metrics.StateWaiting) + metrics.SetWeaveState(r.Served.API.Group, weave.Namespace, weave.Name, metrics.StateWaiting) if report.Degraded() { setCondition(weave, naming.ConditionDegraded, metav1.ConditionTrue, ReasonWatchDegraded, watchFailureMessage(report)) @@ -284,12 +305,12 @@ func (r *WeaveReconciler) reconcileActive(ctx context.Context, weave *v1alpha1.W setCondition(weave, naming.ConditionWaiting, metav1.ConditionFalse, ReasonApplied, "nothing outstanding") setCondition(weave, naming.ConditionDegraded, metav1.ConditionTrue, ReasonWatchDegraded, watchFailureMessage(report)) - metrics.SetWeaveState(weave.Namespace, weave.Name, metrics.StateDegraded) + metrics.SetWeaveState(r.Served.API.Group, weave.Namespace, weave.Name, metrics.StateDegraded) return ctrl.Result{RequeueAfter: requeue}, nil } markReady(weave, pass.summary) - metrics.SetWeaveState(weave.Namespace, weave.Name, metrics.StateReady) + metrics.SetWeaveState(r.Served.API.Group, weave.Namespace, weave.Name, metrics.StateReady) return ctrl.Result{RequeueAfter: requeue}, nil } @@ -306,7 +327,7 @@ type pass struct { } // compose runs one full pass. -func (r *WeaveReconciler) compose(ctx context.Context, c *kube.Client, weave *v1alpha1.Weave) (pass, error) { +func (r *WeaveReconciler) compose(ctx context.Context, c *kube.Client, weave *weftv1.Weave) (pass, error) { // One reader for the whole pass. Every read the controller and the program // make goes through it, so they share a cache and the recording covers both. rd := newWeaveReader(c, weave) @@ -339,7 +360,7 @@ func (r *WeaveReconciler) compose(ctx context.Context, c *kube.Client, weave *v1 Reader: rd, Observed: observed, }) - metrics.EvaluationDuration.WithLabelValues(weave.Namespace, weave.Name). + metrics.EvaluationDuration.WithLabelValues(r.Served.API.Group, weave.Namespace, weave.Name). Observe(time.Since(started).Seconds()) if err != nil { metrics.EvaluationsTotal.WithLabelValues(metrics.OutcomeFailed).Inc() @@ -366,10 +387,9 @@ func (r *WeaveReconciler) compose(ctx context.Context, c *kube.Client, weave *v1 metrics.EvaluationsTotal.WithLabelValues(metrics.OutcomeResolved).Inc() owner := kube.Owner{ - APIVersion: naming.GroupVersion, - Kind: naming.Kind, - Name: weave.Name, - UID: weave.UID, + API: r.Served.API, + Name: weave.Name, + UID: weave.UID, } items, err := inventory.Build(res.Resources, weave.Namespace, owner) if err != nil { @@ -420,16 +440,16 @@ func (r *WeaveReconciler) compose(ctx context.Context, c *kube.Client, weave *v1 // reconcileDeletion tears resources down in reverse dependency order before // letting the Weave go. -func (r *WeaveReconciler) reconcileDeletion(ctx context.Context, weave *v1alpha1.Weave) (ctrl.Result, error) { +func (r *WeaveReconciler) reconcileDeletion(ctx context.Context, weave *weftv1.Weave) (ctrl.Result, error) { log := logf.FromContext(ctx) - key := types.NamespacedName{Namespace: weave.Namespace, Name: weave.Name} + key := r.watchRef(weave.Namespace, weave.Name) - if !controllerutil.ContainsFinalizer(weave, naming.WeaveFinalizer) { + if !controllerutil.ContainsFinalizer(weave, r.Served.API.WeaveFinalizer) { r.Watches.Release(key) return ctrl.Result{}, nil } - c, err := r.Factory.For(weave.Namespace, weave.Spec.ServiceAccountName) + c, err := r.Factory.For(r.Served.API, weave.Namespace, weave.Spec.ServiceAccountName) if err != nil { // Nothing can be torn down in order without a client. Releasing is // better than wedging: cascading collection still removes everything, @@ -520,27 +540,43 @@ func (r *WeaveReconciler) reconcileDeletion(ctx context.Context, weave *v1alpha1 return r.finishDeletion(ctx, weave, key) } -func (r *WeaveReconciler) finishDeletion(ctx context.Context, weave *v1alpha1.Weave, key types.NamespacedName) (ctrl.Result, error) { - controllerutil.RemoveFinalizer(weave, naming.WeaveFinalizer) - if err := r.Client.Update(ctx, weave); err != nil { +func (r *WeaveReconciler) finishDeletion(ctx context.Context, weave *weftv1.Weave, key watches.Weave) (ctrl.Result, error) { + controllerutil.RemoveFinalizer(weave, r.Served.API.WeaveFinalizer) + if err := r.update(ctx, weave); err != nil { if apierrors.IsNotFound(err) || apierrors.IsConflict(err) { return ctrl.Result{}, nil } return ctrl.Result{}, err } r.Watches.Release(key) - metrics.ForgetWeave(weave.Namespace, weave.Name) + metrics.ForgetWeave(r.Served.API.Group, weave.Namespace, weave.Name) return ctrl.Result{}, nil } +// update writes a Weave's metadata and spec back in its own group's type, and +// takes up whatever the API server returned. +func (r *WeaveReconciler) update(ctx context.Context, weave *weftv1.Weave) error { + obj := r.Served.FromV1(weave) + if err := r.Client.Update(ctx, obj); err != nil { + return err + } + weave.ObjectMeta = r.Served.ToV1(obj).ObjectMeta + return nil +} + +// watchRef identifies this group's Weave to the watch registry. +func (r *WeaveReconciler) watchRef(namespace, name string) watches.Weave { + return watches.Weave{Group: r.Served.API.Group, Namespace: namespace, Name: name} +} + // writeStatus persists status, skipping the write when nothing changed so a // steady state does not generate update traffic. -func (r *WeaveReconciler) writeStatus(ctx context.Context, before, weave *v1alpha1.Weave) error { +func (r *WeaveReconciler) writeStatus(ctx context.Context, before, weave *weftv1.Weave) error { weave.Status.ObservedGeneration = weave.Generation if equality.Semantic.DeepEqual(before.Status, weave.Status) { return nil } - err := r.Client.Status().Patch(ctx, weave, client.MergeFrom(before)) + err := r.Client.Status().Patch(ctx, r.Served.FromV1(weave), client.MergeFrom(r.Served.FromV1(before))) if apierrors.IsNotFound(err) || apierrors.IsConflict(err) { // The object moved on. The next reconcile recomputes everything anyway. return nil @@ -548,11 +584,13 @@ func (r *WeaveReconciler) writeStatus(ctx context.Context, before, weave *v1alph return err } -func (r *WeaveReconciler) eventf(weave *v1alpha1.Weave, eventType, reason, format string, args ...any) { +func (r *WeaveReconciler) eventf(weave *weftv1.Weave, eventType, reason, format string, args ...any) { if r.Recorder == nil { return } - r.Recorder.Eventf(weave, eventType, reason, format, args...) + // Recorded against the object in its own group, which is the one kubectl + // describe will be looking at. + r.Recorder.Eventf(r.Served.FromV1(weave), eventType, reason, format, args...) } // programMessage renders a program failure with its backtrace, which is the @@ -607,7 +645,7 @@ func joinWithAnd(items []string) string { // requeueFor returns how long until the soonest retained entry becomes // prunable. Nothing else generates an event when a delay expires, so the Weave // has to schedule its own wake-up. -func requeueFor(retained []v1alpha1.InventoryEntry, delay time.Duration) time.Duration { +func requeueFor(retained []weftv1.InventoryEntry, delay time.Duration) time.Duration { soonest := time.Duration(0) for _, e := range retained { if e.MissingSince == nil { @@ -627,8 +665,8 @@ func requeueFor(retained []v1alpha1.InventoryEntry, delay time.Duration) time.Du return soonest } -func concat(a, b []v1alpha1.InventoryEntry) []v1alpha1.InventoryEntry { - out := make([]v1alpha1.InventoryEntry, 0, len(a)+len(b)) +func concat(a, b []weftv1.InventoryEntry) []weftv1.InventoryEntry { + out := make([]weftv1.InventoryEntry, 0, len(a)+len(b)) out = append(out, a...) return append(out, b...) } diff --git a/internal/examples/examples_test.go b/internal/examples/examples_test.go index 02fb2d8..eddc4d5 100644 --- a/internal/examples/examples_test.go +++ b/internal/examples/examples_test.go @@ -15,7 +15,7 @@ import ( "sigs.k8s.io/yaml" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/eval" "github.com/alethic/weft/internal/inventory" "github.com/alethic/weft/internal/kube" @@ -23,13 +23,13 @@ import ( "github.com/alethic/weft/internal/variables" ) -func load(t *testing.T, path string) *v1alpha1.Weave { +func load(t *testing.T, path string) *weftv1.Weave { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("reading %s: %v", path, err) } - var w v1alpha1.Weave + var w weftv1.Weave if err := yaml.Unmarshal(data, &w); err != nil { t.Fatalf("parsing %s: %v", path, err) } @@ -102,8 +102,8 @@ func TestExamplesAreWellFormed(t *testing.T) { t.Run(filepath.Base(path), func(t *testing.T) { w := load(t, path) - if w.APIVersion != naming.GroupVersion { - t.Errorf("apiVersion = %q, want %q", w.APIVersion, naming.GroupVersion) + if w.APIVersion != naming.V1.GroupVersion { + t.Errorf("apiVersion = %q, want %q", w.APIVersion, naming.V1.GroupVersion) } if w.Kind != naming.Kind { t.Errorf("kind = %q, want %q", w.Kind, naming.Kind) @@ -190,10 +190,9 @@ func TestExamplesProduceTheirFirstWave(t *testing.T) { // Whatever it produced must survive normalisation, which is where // namespace, ownership and wave rules are enforced. owner := kube.Owner{ - APIVersion: naming.GroupVersion, - Kind: naming.Kind, - Name: w.Name, - UID: "test-uid", + API: naming.V1, + Name: w.Name, + UID: "test-uid", } items, err := inventory.Build(res.Resources, w.Namespace, owner) if err != nil { diff --git a/internal/inventory/inventory.go b/internal/inventory/inventory.go index 78d6b2d..1ec90f0 100644 --- a/internal/inventory/inventory.go +++ b/internal/inventory/inventory.go @@ -19,7 +19,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/eval" "github.com/alethic/weft/internal/kube" "github.com/alethic/weft/internal/naming" @@ -39,9 +39,10 @@ type Item struct { // deleted in descending wave. Wave int32 - // Owned is false when the resource carries weft.alethic.solutions/owned=false. An unowned - // resource is applied and kept current, but no owner reference is placed on - // it and it is never deleted by this Weave. + // Owned is false when the resource carries its Weave's group's owned + // annotation set to "false". An unowned resource is applied and kept + // current, but no owner reference is placed on it and it is never deleted + // by this Weave. Owned bool } @@ -72,7 +73,7 @@ func Build(resources []eval.Resource, namespace string, owner kube.Owner) ([]Ite needs := make(map[eval.Ref][]eval.Ref, len(resources)) for i, r := range resources { - owned, err := ownedFrom(r.Object, r.Ref) + owned, err := ownedFrom(r.Object, r.Ref, owner.API) if err != nil { return nil, err } @@ -190,10 +191,24 @@ func describeCycle(path []eval.Ref) string { // A typo is rejected rather than ignored. "owned: no" quietly meaning "owned" // is precisely the failure this annotation exists to prevent, and it would only // be discovered by the object being deleted. -func ownedFrom(obj map[string]any, ref eval.Ref) (bool, error) { +// +// So is another group's annotation. A program moved from a weft.run Weave to a +// v1 one, still saying weft.run/owned: "false", would otherwise have every +// resource it meant to keep owned by the new Weave - and deleted with it. +func ownedFrom(obj map[string]any, ref eval.Ref, api naming.API) (bool, error) { md, _ := obj["metadata"].(map[string]any) annotations, _ := md["annotations"].(map[string]any) - raw, ok := annotations[naming.OwnedAnnotation] + for _, other := range naming.APIs { + if other.Group == api.Group { + continue + } + if raw, ok := annotations[other.OwnedAnnotation]; ok { + return false, fmt.Errorf("%s has %s=%v, which is the annotation for %s Weaves; this Weave is %s "+ + "and reads %s instead", ref, other.OwnedAnnotation, raw, other.GroupVersion, api.GroupVersion, + api.OwnedAnnotation) + } + } + raw, ok := annotations[api.OwnedAnnotation] if !ok { return true, nil } @@ -204,7 +219,7 @@ func ownedFrom(obj map[string]any, ref eval.Ref) (bool, error) { return false, nil default: return false, fmt.Errorf("%s has %s=%v, which must be \"true\" or \"false\"", - ref, naming.OwnedAnnotation, raw) + ref, api.OwnedAnnotation, raw) } } @@ -215,10 +230,10 @@ type Diff struct { // Retained is inventory that was not returned this time but has not been // absent long enough to act on. Its MissingCount has been incremented. - Retained []v1alpha1.InventoryEntry + Retained []weftv1.InventoryEntry // Prune is inventory absent for long enough to delete, in descending wave. - Prune []v1alpha1.InventoryEntry + Prune []weftv1.InventoryEntry // Released is inventory that is no longer described and was applied // unowned, so the record is dropped and the object left standing. @@ -226,11 +241,11 @@ type Diff struct { // No hysteresis applies. Waiting exists to avoid destroying something over // a transient absence, and nothing here is destroyed - a release that turns // out to be premature is undone by the next pass recording it again. - Released []v1alpha1.InventoryEntry + Released []weftv1.InventoryEntry } // refOfEntry is what an inventory entry addresses. -func refOfEntry(e v1alpha1.InventoryEntry) eval.Ref { +func refOfEntry(e weftv1.InventoryEntry) eval.Ref { return eval.Ref{APIVersion: e.APIVersion, Kind: e.Kind, Name: e.Name} } @@ -250,7 +265,7 @@ func refOfEntry(e v1alpha1.InventoryEntry) eval.Ref { // Callers must only apply this to a successful evaluation. One that waited or // failed says nothing about what should exist, and counting it would make a // provider outage look like a deletion. -func Compute(current []v1alpha1.InventoryEntry, desired []Item, threshold int32, delay time.Duration, now time.Time) Diff { +func Compute(current []weftv1.InventoryEntry, desired []Item, threshold int32, delay time.Duration, now time.Time) Diff { if threshold < 1 { threshold = 1 } @@ -295,14 +310,14 @@ func Compute(current []v1alpha1.InventoryEntry, desired []Item, threshold int32, return d } -func sortDescending(entries []v1alpha1.InventoryEntry) { +func sortDescending(entries []weftv1.InventoryEntry) { sort.SliceStable(entries, func(i, j int) bool { return entries[i].Wave > entries[j].Wave }) } // Entry records an applied item in the inventory. -func Entry(item Item, applied *unstructured.Unstructured) v1alpha1.InventoryEntry { +func Entry(item Item, applied *unstructured.Unstructured) weftv1.InventoryEntry { gvk := item.GroupVersionKind() - e := v1alpha1.InventoryEntry{ + e := weftv1.InventoryEntry{ APIVersion: gvk.GroupVersion().String(), Kind: gvk.Kind, Name: item.Object.GetName(), @@ -321,8 +336,8 @@ func Entry(item Item, applied *unstructured.Unstructured) v1alpha1.InventoryEntr // Merge produces the inventory to record: the entries just applied, plus the // ones being retained through a transient absence, in a stable order. -func Merge(applied []v1alpha1.InventoryEntry, retained []v1alpha1.InventoryEntry) []v1alpha1.InventoryEntry { - out := make([]v1alpha1.InventoryEntry, 0, len(applied)+len(retained)) +func Merge(applied []weftv1.InventoryEntry, retained []weftv1.InventoryEntry) []weftv1.InventoryEntry { + out := make([]weftv1.InventoryEntry, 0, len(applied)+len(retained)) out = append(out, applied...) out = append(out, retained...) sort.SliceStable(out, func(i, j int) bool { @@ -341,11 +356,11 @@ func Merge(applied []v1alpha1.InventoryEntry, retained []v1alpha1.InventoryEntry // recorded. Entries within a wave have no dependency on each other and are // deleted together, which is what keeps a fan-out of seven role assignments // from becoming seven sequential round trips against a cloud API. -func Waves(entries []v1alpha1.InventoryEntry) [][]v1alpha1.InventoryEntry { +func Waves(entries []weftv1.InventoryEntry) [][]weftv1.InventoryEntry { if len(entries) == 0 { return nil } - byWave := map[int32][]v1alpha1.InventoryEntry{} + byWave := map[int32][]weftv1.InventoryEntry{} for _, e := range entries { byWave[e.Wave] = append(byWave[e.Wave], e) } @@ -355,7 +370,7 @@ func Waves(entries []v1alpha1.InventoryEntry) [][]v1alpha1.InventoryEntry { } sort.Slice(waves, func(i, j int) bool { return waves[i] > waves[j] }) - out := make([][]v1alpha1.InventoryEntry, 0, len(waves)) + out := make([][]weftv1.InventoryEntry, 0, len(waves)) for _, w := range waves { group := byWave[w] sort.SliceStable(group, func(i, j int) bool { @@ -393,7 +408,7 @@ func ApplyWaves(items []Item) [][]Item { } // GroupVersionKind parses an inventory entry's type. -func GroupVersionKind(e v1alpha1.InventoryEntry) (schema.GroupVersionKind, error) { +func GroupVersionKind(e weftv1.InventoryEntry) (schema.GroupVersionKind, error) { gv, err := schema.ParseGroupVersion(e.APIVersion) if err != nil { return schema.GroupVersionKind{}, fmt.Errorf( diff --git a/internal/inventory/inventory_test.go b/internal/inventory/inventory_test.go index 34f7c6f..05b401c 100644 --- a/internal/inventory/inventory_test.go +++ b/internal/inventory/inventory_test.go @@ -8,7 +8,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/eval" "github.com/alethic/weft/internal/kube" "github.com/alethic/weft/internal/naming" @@ -16,10 +16,9 @@ import ( func owner() kube.Owner { return kube.Owner{ - APIVersion: naming.GroupVersion, - Kind: naming.Kind, - Name: "app", - UID: "weave-uid", + API: naming.V1, + Name: "app", + UID: "weave-uid", } } @@ -178,8 +177,8 @@ func dependent(name string, deps ...string) eval.Resource { return eval.Resource{Ref: ref(name), Object: res(name), Needs: refs, NeedsDeclared: true} } -func entriesOf(items []Item) []v1alpha1.InventoryEntry { - out := make([]v1alpha1.InventoryEntry, 0, len(items)) +func entriesOf(items []Item) []weftv1.InventoryEntry { + out := make([]weftv1.InventoryEntry, 0, len(items)) for _, it := range items { out = append(out, Entry(it, nil)) } @@ -209,10 +208,10 @@ func TestBuildOwnsAndLabels(t *testing.T) { if refs[0].BlockOwnerDeletion == nil || !*refs[0].BlockOwnerDeletion { t.Error("owner reference should block owner deletion") } - if obj.GetLabels()[naming.WeaveLabel] != "app" { + if obj.GetLabels()[naming.V1.WeaveLabel] != "app" { t.Errorf("labels = %v", obj.GetLabels()) } - if obj.GetAnnotations()[naming.WeaveUIDAnnotation] != "weave-uid" { + if obj.GetAnnotations()[naming.V1.WeaveUIDAnnotation] != "weave-uid" { t.Errorf("annotations = %v", obj.GetAnnotations()) } } @@ -275,8 +274,8 @@ func TestBuildStripsDerivedMetadata(t *testing.T) { } } -func inventoryEntry(name string, wave int32, missing int32) v1alpha1.InventoryEntry { - return v1alpha1.InventoryEntry{ +func inventoryEntry(name string, wave int32, missing int32) weftv1.InventoryEntry { + return weftv1.InventoryEntry{ APIVersion: "v1", Kind: "ConfigMap", Name: name, Wave: wave, MissingCount: missing, Owned: true, } @@ -289,7 +288,7 @@ const testDelay = 2 * time.Minute // legitimately stops returning what depends on it. func TestComputeHysteresis(t *testing.T) { t0 := time.Now() - current := []v1alpha1.InventoryEntry{inventoryEntry("gone", 1, 0), inventoryEntry("kept", 0, 0)} + current := []weftv1.InventoryEntry{inventoryEntry("gone", 1, 0), inventoryEntry("kept", 0, 0)} desired, err := Build(evaluated("kept"), "ns", owner()) if err != nil { t.Fatal(err) @@ -311,7 +310,7 @@ func TestComputeHysteresis(t *testing.T) { // own. The delay is what actually holds the resource. next := d.Retained[0] for i := 2; i <= 6; i++ { - d = Compute([]v1alpha1.InventoryEntry{next, inventoryEntry("kept", 0, 0)}, desired, 3, testDelay, + d = Compute([]weftv1.InventoryEntry{next, inventoryEntry("kept", 0, 0)}, desired, 3, testDelay, t0.Add(time.Duration(i)*time.Second)) if len(d.Prune) != 0 { t.Fatalf("pass %d pruned after %d seconds; the delay is %s", i, i, testDelay) @@ -323,7 +322,7 @@ func TestComputeHysteresis(t *testing.T) { } // Once the delay has elapsed, it goes. - d = Compute([]v1alpha1.InventoryEntry{next, inventoryEntry("kept", 0, 0)}, desired, 3, testDelay, + d = Compute([]weftv1.InventoryEntry{next, inventoryEntry("kept", 0, 0)}, desired, 3, testDelay, t0.Add(testDelay+time.Second)) if len(d.Prune) != 1 || d.Prune[0].Name != "gone" { t.Fatalf("should prune once the delay has elapsed, got %v", d.Prune) @@ -346,7 +345,7 @@ func TestComputeCountAloneDoesNotPrune(t *testing.T) { t.Fatal(err) } - d := Compute([]v1alpha1.InventoryEntry{entry}, desired, 3, testDelay, t0.Add(time.Second)) + d := Compute([]weftv1.InventoryEntry{entry}, desired, 3, testDelay, t0.Add(time.Second)) if len(d.Prune) != 0 { t.Errorf("a high count with no elapsed time pruned anyway: %v", d.Prune) } @@ -363,7 +362,7 @@ func TestComputeDelayAloneDoesNotPrune(t *testing.T) { // First absence, observed long after the entry was created. The clock // starts now, not retroactively. - d := Compute([]v1alpha1.InventoryEntry{inventoryEntry("gone", 0, 0)}, desired, 3, testDelay, t0) + d := Compute([]weftv1.InventoryEntry{inventoryEntry("gone", 0, 0)}, desired, 3, testDelay, t0) if len(d.Prune) != 0 { t.Errorf("first absence pruned immediately: %v", d.Prune) } @@ -379,7 +378,7 @@ func TestComputeReappearanceResetsTheHysteresis(t *testing.T) { t.Fatal(err) } - d := Compute([]v1alpha1.InventoryEntry{entry}, desired, 3, testDelay, time.Now()) + d := Compute([]weftv1.InventoryEntry{entry}, desired, 3, testDelay, time.Now()) if len(d.Prune) != 0 || len(d.Retained) != 0 { t.Fatalf("a returned resource is neither pruned nor retained: %+v", d) } @@ -393,7 +392,7 @@ func TestComputeReappearanceResetsTheHysteresis(t *testing.T) { } func TestWavesAreDescendingForTeardown(t *testing.T) { - groups := Waves([]v1alpha1.InventoryEntry{ + groups := Waves([]weftv1.InventoryEntry{ inventoryEntry("identity", 0, 0), inventoryEntry("ra-b", 2, 0), inventoryEntry("ra-a", 2, 0), @@ -431,8 +430,8 @@ func TestApplyWavesAreAscending(t *testing.T) { func TestMergeIsStable(t *testing.T) { out := Merge( - []v1alpha1.InventoryEntry{inventoryEntry("b", 1, 0), inventoryEntry("a", 0, 0)}, - []v1alpha1.InventoryEntry{inventoryEntry("z", 0, 1)}, + []weftv1.InventoryEntry{inventoryEntry("b", 1, 0), inventoryEntry("a", 0, 0)}, + []weftv1.InventoryEntry{inventoryEntry("z", 0, 1)}, ) want := []string{"a", "z", "b"} for i, w := range want { @@ -443,7 +442,7 @@ func TestMergeIsStable(t *testing.T) { } func TestGroupVersionKind(t *testing.T) { - gvk, err := GroupVersionKind(v1alpha1.InventoryEntry{ + gvk, err := GroupVersionKind(weftv1.InventoryEntry{ APIVersion: "azure.m.upbound.io/v1beta1", Kind: "ResourceGroup", }) if err != nil { @@ -453,7 +452,7 @@ func TestGroupVersionKind(t *testing.T) { t.Errorf("gvk = %v", gvk) } - if _, err := GroupVersionKind(v1alpha1.InventoryEntry{Name: "x", APIVersion: "a/b/c"}); err == nil { + if _, err := GroupVersionKind(weftv1.InventoryEntry{Name: "x", APIVersion: "a/b/c"}); err == nil { t.Error("an unparseable apiVersion should be an error") } } @@ -468,8 +467,8 @@ func TestSteadyStateInventoryIsIdentical(t *testing.T) { t.Fatal(err) } - pass := func() []v1alpha1.InventoryEntry { - applied := make([]v1alpha1.InventoryEntry, 0, len(desired)) + pass := func() []weftv1.InventoryEntry { + applied := make([]weftv1.InventoryEntry, 0, len(desired)) for _, item := range desired { applied = append(applied, Entry(item, nil)) } @@ -495,9 +494,9 @@ func TestMissingCountStopsAtTheThreshold(t *testing.T) { } entry := inventoryEntry("gone", 0, 0) - var last v1alpha1.InventoryEntry + var last weftv1.InventoryEntry for i := 0; i < 20; i++ { - d := Compute([]v1alpha1.InventoryEntry{entry}, desired, 3, time.Hour, t0.Add(time.Duration(i)*time.Second)) + d := Compute([]weftv1.InventoryEntry{entry}, desired, 3, time.Hour, t0.Add(time.Duration(i)*time.Second)) if len(d.Retained) != 1 { t.Fatalf("pass %d: retained %d entries", i, len(d.Retained)) } @@ -515,7 +514,7 @@ func TestMissingCountStopsAtTheThreshold(t *testing.T) { // that stopped being described, and the new one is created. func TestRenamingIsAPruneAndACreate(t *testing.T) { t0 := time.Now() - current := []v1alpha1.InventoryEntry{{ + current := []weftv1.InventoryEntry{{ APIVersion: "v1", Kind: "ConfigMap", Name: "thing-one", Wave: 0, Owned: true, MissingCount: 3, MissingSince: timePtr(t0.Add(-time.Hour)), }} @@ -537,7 +536,7 @@ func TestRenamingIsAPruneAndACreate(t *testing.T) { // Unchanged entries are left exactly alone, which is what keeps a settled Weave // from writing its own status on every pass. func TestComputeIgnoresUnchangedEntries(t *testing.T) { - current := []v1alpha1.InventoryEntry{{ + current := []weftv1.InventoryEntry{{ APIVersion: "v1", Kind: "ConfigMap", Name: "thing", Wave: 0, Owned: true, }} desired, err := Build([]eval.Resource{declared("thing")}, "ns", owner()) diff --git a/internal/kube/client.go b/internal/kube/client.go index ec19a62..71ab729 100644 --- a/internal/kube/client.go +++ b/internal/kube/client.go @@ -11,6 +11,7 @@ package kube import ( "context" + "encoding/json" "fmt" "strings" "sync" @@ -130,7 +131,7 @@ func NewFactory(base *rest.Config, mapper meta.RESTMapper, opts FactoryOptions) } agent := opts.UserAgent if agent == "" { - agent = naming.Group + agent = "weft" } size := opts.CacheSize if size <= 0 { @@ -149,8 +150,26 @@ func NewFactory(base *rest.Config, mapper meta.RESTMapper, opts FactoryOptions) // Mapper exposes the shared RESTMapper. func (f *Factory) Mapper() meta.RESTMapper { return f.mapper } -// For returns a client that acts as the given ServiceAccount. -func (f *Factory) For(namespace, serviceAccount string) (*Client, error) { +// For returns a client that acts as the given ServiceAccount, on behalf of a +// Weave in the given API group. +// +// The group decides the field manager every write is made under. Server-side +// apply attributes field ownership to that string, so a weft.run Weave keeps +// writing as the manager its objects already record, and a v1 Weave never +// appears as weft.run in anybody's managedFields. +func (f *Factory) For(api naming.API, namespace, serviceAccount string) (*Client, error) { + c, err := f.forIdentity(namespace, serviceAccount) + if err != nil { + return nil, err + } + scoped := *c + scoped.fieldManager = api.FieldManager + return &scoped, nil +} + +// forIdentity returns the cached client for a ServiceAccount. The transport is +// shared by every group; only the field manager differs. +func (f *Factory) forIdentity(namespace, serviceAccount string) (*Client, error) { if namespace == "" || serviceAccount == "" { return nil, fmt.Errorf("namespace and serviceAccountName are both required") } @@ -225,6 +244,7 @@ func expandGroups(groups []string, namespace string) []string { type Client struct { namespace string serviceAccount string + fieldManager string dyn dynamic.Interface typed kubernetes.Interface meta metadata.Interface @@ -315,7 +335,7 @@ func (c *Client) Apply(ctx context.Context, obj *unstructured.Unstructured) (*un } name := obj.GetName() applied, err := c.dyn.Resource(gvr).Namespace(c.namespace).Apply(ctx, name, obj, metav1.ApplyOptions{ - FieldManager: naming.FieldManager, + FieldManager: c.fieldManager, Force: true, }) if err != nil { @@ -373,7 +393,7 @@ func (c *Client) MutateFinalizers(ctx context.Context, gvk schema.GroupVersionKi return nil } obj.SetFinalizers(next) - if _, err := ri.Update(ctx, obj, metav1.UpdateOptions{FieldManager: naming.FieldManager}); err != nil { + if _, err := ri.Update(ctx, obj, metav1.UpdateOptions{FieldManager: c.fieldManager}); err != nil { if apierrors.IsConflict(err) { return err } @@ -420,7 +440,7 @@ func (c *Client) RemoveOwnerReference(ctx context.Context, gvk schema.GroupVersi } obj.SetOwnerReferences(kept) - if _, err := ri.Update(ctx, obj, metav1.UpdateOptions{FieldManager: naming.FieldManager}); err != nil { + if _, err := ri.Update(ctx, obj, metav1.UpdateOptions{FieldManager: c.fieldManager}); err != nil { if apierrors.IsConflict(err) { return err } @@ -430,6 +450,100 @@ func (c *Client) RemoveOwnerReference(ctx context.Context, gvk schema.GroupVersi }) } +// TransferFieldOwnership hands every field another manager owns through +// server-side apply over to this client's own manager, and reports whether +// anything changed. +// +// This is how a weft.run Weave's objects become a v1 Weave's. Applying over +// them would not be enough: the fields would then be co-owned, and a field the +// old manager still claimed would never be removed when the new program stopped +// setting it - including the old group's provenance label and annotation, which +// the new program never sets at all. Renaming the manager makes the new one the +// sole owner of everything the old one applied, so its next apply prunes +// exactly what it no longer declares, as if it had always been the one writing. +// +// Only Apply entries move. Update entries record the finalizer and owner +// reference edits made outside apply, which no later apply would release +// either way. +// +// Where this manager already holds an Apply entry for the same API version, +// the other one is dropped rather than merged. Its fields become unowned and +// stay as they are; the next apply claims whichever it still declares. +func (c *Client) TransferFieldOwnership(ctx context.Context, gvk schema.GroupVersionKind, name, from string) (bool, error) { + gvr, err := c.resourceFor(gvk) + if err != nil { + return false, err + } + ri := c.dyn.Resource(gvr).Namespace(c.namespace) + + changed := false + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + obj, err := ri.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return asPermissionError(err, c.namespace, c.serviceAccount, "get", gvr, gvk.Kind, name) + } + + entries := obj.GetManagedFields() + ours := map[string]bool{} + for _, e := range entries { + if e.Manager == c.fieldManager && e.Operation == metav1.ManagedFieldsOperationApply { + ours[e.APIVersion+"/"+e.Subresource] = true + } + } + next := make([]metav1.ManagedFieldsEntry, 0, len(entries)) + for _, e := range entries { + if e.Manager == from && e.Operation == metav1.ManagedFieldsOperationApply { + if ours[e.APIVersion+"/"+e.Subresource] { + continue + } + e.Manager = c.fieldManager + } + next = append(next, e) + } + if equalManagers(entries, next) { + changed = false + return nil + } + + // A merge patch replaces the list outright. The resourceVersion makes it + // conditional, so a write that raced this one is retried rather than + // overwritten. + patch, err := json.Marshal(map[string]any{ + "metadata": map[string]any{ + "resourceVersion": obj.GetResourceVersion(), + "managedFields": next, + }, + }) + if err != nil { + return err + } + if _, err := ri.Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{FieldManager: c.fieldManager}); err != nil { + if apierrors.IsConflict(err) { + return err + } + return asPermissionError(err, c.namespace, c.serviceAccount, "patch", gvr, gvk.Kind, name) + } + changed = true + return nil + }) + if err != nil { + return false, err + } + return changed, nil +} + +func equalManagers(a, b []metav1.ManagedFieldsEntry) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Manager != b[i].Manager { + return false + } + } + return true +} + // CanI asks the API server whether the impersonated subject may perform a verb, // without attempting it. // diff --git a/internal/kube/normalize.go b/internal/kube/normalize.go index ba4ea17..4ae2586 100644 --- a/internal/kube/normalize.go +++ b/internal/kube/normalize.go @@ -12,10 +12,11 @@ import ( // Owner is the object every generated resource is owned by. type Owner struct { - APIVersion string - Kind string - Name string - UID types.UID + // API is the Weave's group. It decides the owner reference's apiVersion + // and which keys the provenance is written under. + API naming.API + Name string + UID types.UID } // derivedMetadata is server-populated metadata a program will pick up if it @@ -85,8 +86,8 @@ func Normalize(obj map[string]any, namespace string, owner Owner, owned bool) (* blockOwnerDeletion := true controller := true u.SetOwnerReferences([]metav1.OwnerReference{{ - APIVersion: owner.APIVersion, - Kind: owner.Kind, + APIVersion: owner.API.GroupVersion, + Kind: naming.Kind, Name: owner.Name, UID: owner.UID, Controller: &controller, @@ -98,14 +99,14 @@ func Normalize(obj map[string]any, namespace string, owner Owner, owned bool) (* if labels == nil { labels = map[string]string{} } - labels[naming.WeaveLabel] = owner.Name + labels[owner.API.WeaveLabel] = owner.Name u.SetLabels(labels) annotations := u.GetAnnotations() if annotations == nil { annotations = map[string]string{} } - annotations[naming.WeaveUIDAnnotation] = string(owner.UID) + annotations[owner.API.WeaveUIDAnnotation] = string(owner.UID) u.SetAnnotations(annotations) return u, nil diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index e6953ea..d4a6be7 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -26,7 +26,7 @@ var ( Name: "weave_status", Help: "Current status of a Weave, as one series per state with a value of 0 or 1.", }, - []string{"namespace", "name", "state"}, + []string{"group", "namespace", "name", "state"}, ) // WeaveResources is how many resources each Weave currently owns. A count @@ -37,7 +37,7 @@ var ( Name: "weave_resources", Help: "Number of resources a Weave currently owns.", }, - []string{"namespace", "name"}, + []string{"group", "namespace", "name"}, ) // EvaluationDuration is how long compose() takes. Evaluation is @@ -49,7 +49,7 @@ var ( Help: "Time spent evaluating a composition program.", Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5}, }, - []string{"namespace", "name"}, + []string{"group", "namespace", "name"}, ) // EvaluationsTotal counts evaluations by outcome. The three outcomes are @@ -150,25 +150,30 @@ func init() { ) } +// Per-Weave series carry the Weave's API group. A weft.run Weave and a v1 Weave +// may share a namespace and name - that is exactly the state a handover passes +// through - and without the group one would overwrite the other's series and +// forgetting one would erase both. + // SetWeaveState records a Weave's condition, zeroing the states it is not in so // a Weave never appears to be in two at once. -func SetWeaveState(namespace, name, state string) { +func SetWeaveState(group, namespace, name, state string) { for _, s := range []string{StateReady, StateWaiting, StateDegraded} { v := 0.0 if s == state { v = 1.0 } - WeaveStatus.WithLabelValues(namespace, name, s).Set(v) + WeaveStatus.WithLabelValues(group, namespace, name, s).Set(v) } } // ForgetWeave drops every series for a deleted Weave. Without this the gauges // keep reporting objects that no longer exist, and the cardinality only ever // grows. -func ForgetWeave(namespace, name string) { +func ForgetWeave(group, namespace, name string) { for _, s := range []string{StateReady, StateWaiting, StateDegraded} { - WeaveStatus.DeleteLabelValues(namespace, name, s) + WeaveStatus.DeleteLabelValues(group, namespace, name, s) } - WeaveResources.DeleteLabelValues(namespace, name) - EvaluationDuration.DeleteLabelValues(namespace, name) + WeaveResources.DeleteLabelValues(group, namespace, name) + EvaluationDuration.DeleteLabelValues(group, namespace, name) } diff --git a/internal/naming/naming.go b/internal/naming/naming.go index b66e11f..3f94bb2 100644 --- a/internal/naming/naming.go +++ b/internal/naming/naming.go @@ -1,130 +1,199 @@ // Package naming is the single source of truth for every externally visible -// identifier Weft writes into a cluster. Renaming the API group must be one -// edit here, so nothing anywhere else may hardcode "weft.alethic.solutions". +// identifier Weft writes into a cluster. +// +// Weft serves two API groups. weft.alethic.solutions/v1 is the current one. +// weft.run/v1alpha1 is the group Weft was first deployed under, and is served +// alongside it so that installations built on it keep working and can move +// across one Weave at a time. +// +// Kubernetes cannot convert between groups the way it converts between +// versions of one group, so the two are separate CRDs and a Weave belongs to +// exactly one of them. Everything a Weave writes into the cluster - its +// finalizers, the provenance on what it applies, the field manager it applies +// with - carries its own group's keys. A weft.run Weave behaves exactly as it +// always has, and a v1 Weave never puts weft.run on anything. +// +// Nothing outside this package may hardcode either group. package naming import ( "crypto/sha256" "encoding/hex" "fmt" + "strings" ) const ( - // Group is the API group. Everything below derives from it. - Group = "weft.alethic.solutions" - // Version is the current (and only) API version. - Version = "v1alpha1" - // Kind is the singular kind name. + // Kind is the singular kind name, the same in every group. Kind = "Weave" // Plural is the lowercase plural, used for CRD names and RBAC rules. Plural = "weaves" ) -// GroupVersion is the "group/version" string used in apiVersion fields. -var GroupVersion = Group + "/" + Version +// API is one served API group and every identifier derived from it. +type API struct { + // Group is the API group. + Group string + // Version is the API version served in that group. + Version string -// CRDName is the metadata.name a CustomResourceDefinition must carry. -var CRDName = Plural + "." + Group + // GroupVersion is the "group/version" string used in apiVersion fields. + GroupVersion string -// FieldManager is the server-side-apply field manager for every write Weft -// makes. Ownership of a field is determined by this string, so it must be -// stable across releases. -var FieldManager = Group + // CRDName is the metadata.name a CustomResourceDefinition must carry. + CRDName string + + // FieldManager is the server-side-apply field manager for every write a + // Weave in this group makes. Ownership of a field is determined by this + // string, so it must be stable across releases. + FieldManager string -const ( // WeaveFinalizer is placed on a Weave so outputs can be torn down in // reverse dependency order before the object disappears. - weaveFinalizerPath = "weave" - // sourceFinalizerPrefix begins every finalizer Weft places on a resource - // it does not own. Fixed so a human can enumerate and strip them: - // kubectl get -o json | jq '.items[].metadata.finalizers' - heldFinalizerPath = "held-" -) + WeaveFinalizer string -// WeaveFinalizer is the finalizer Weft places on Weave objects. -var WeaveFinalizer = Group + "/" + weaveFinalizerPath - -// HeldFinalizerPrefix is the common prefix of every finalizer Weft places on a resource a program holds. Used -// both to recognise our own finalizers during reaping and to document how to -// strip them by hand. -var HeldFinalizerPrefix = Group + "/" + heldFinalizerPath - -// HeldFinalizer returns the finalizer a given Weave places on a resource it has -// asked to hold. -// -// A finalizer is a qualified name: the part after the slash is limited to 63 -// characters and a restricted alphabet, so the Weave's namespace and name are -// hashed rather than interpolated. The prefix stays literal so the finalizer is -// still recognisable and greppable. -func HeldFinalizer(namespace, name string) string { - sum := sha256.Sum256([]byte(namespace + "/" + name)) - return HeldFinalizerPrefix + hex.EncodeToString(sum[:])[:16] -} + // HeldFinalizerPrefix begins every finalizer a Weave places on a resource + // a program holds. Fixed so a human can enumerate and strip them: + // kubectl get -o json | jq '.items[].metadata.finalizers' + HeldFinalizerPrefix string -// IsHeldFinalizer reports whether a finalizer string was placed by Weft. -func IsHeldFinalizer(f string) bool { - return len(f) > len(HeldFinalizerPrefix) && f[:len(HeldFinalizerPrefix)] == HeldFinalizerPrefix -} + // Provenance stamped onto every resource a Weave applies. + // + // Together these are the record on the object itself of what made it, + // which Weave, and under what identity. The Weave's own status says the + // same thing, but a status can be lost - restored from a backup, wiped, or + // never written because a pass failed between the apply and the status + // update. An object that carries its own provenance can be reclaimed from + // that; one that does not has to be adopted by hand. + // + // It is deliberately only facts that do not change from pass to pass. A + // timestamp or a revision here would rewrite every object on every + // reconcile, and every one of those writes is a watch event that wakes the + // Weave that just made it. -// Provenance stamped onto every resource Weft applies. -// -// Together these are the record on the object itself of what made it, which -// Weave, and under what identity. The Weave's own status says the same thing, -// but a status can be lost - restored from a backup, wiped, or never written -// because a pass failed between the apply and the status update. An object that -// carries its own provenance can be reclaimed from that; one that does not has -// to be adopted by hand. -// -// It is deliberately only facts that do not change from pass to pass. A -// timestamp or a revision here would rewrite every object on every reconcile, -// and every one of those writes is a watch event that wakes the Weave that just -// made it. -var ( // WeaveLabel names the Weave that applied a resource. A label (not an // annotation) so outputs are selectable with kubectl. - WeaveLabel = Group + "/weave" + WeaveLabel string // WeaveUIDAnnotation records the UID of the Weave that applied it. // - // The name is not enough on its own. A Weave deleted and recreated with the - // same name is a different object with a different UID, and its + // The name is not enough on its own. A Weave deleted and recreated with + // the same name is a different object with a different UID, and its // predecessor's unowned resources are not automatically its to take back - // whereas a UID match is the same Weave, whatever happened to its status in // between. - WeaveUIDAnnotation = Group + "/weave-uid" + WeaveUIDAnnotation string // OwnedAnnotation decides whether Weft owns a resource it applies. Its // accepted values are "true" (the default) and "false". // // An unowned resource is still applied and still kept up to date, but no - // owner reference is placed on it: it is not deleted when the program stops - // returning it, and not collected when the Weave is deleted. It says the - // object outlives the composition that describes it, which is a thing no - // amount of pruning hysteresis can express - hysteresis is about how long - // to wait, and this is about never. + // owner reference is placed on it: it is not deleted when the program + // stops returning it, and not collected when the Weave is deleted. It says + // the object outlives the composition that describes it, which is a thing + // no amount of pruning hysteresis can express - hysteresis is about how + // long to wait, and this is about never. // - // The absence of the reference is what makes the guarantee real rather than - // conditional on this controller running at the moment somebody reaches for - // it. - OwnedAnnotation = Group + "/owned" - - // AdoptAnnotation opts an existing object into being taken over by a Weave. - // Its value is a name, or a pattern: "*" consents to any Weave in this - // namespace, and "app-*" to any whose name starts with app-. + // The absence of the reference is what makes the guarantee real rather + // than conditional on this controller running at the moment somebody + // reaches for it. + OwnedAnnotation string + + // AdoptAnnotation opts an existing object into being taken over by a + // Weave. Its value is a name, or a pattern: "*" consents to any Weave in + // this namespace, and "app-*" to any whose name starts with app-. // - // It lives on the object being adopted rather than in the Weave on purpose. - // Consent has to come from whoever holds the thing, or "adoption" is just a - // Weave author choosing a name and helping themselves to somebody else's - // resource - which is the whole reason the unannotated case is refused. + // It lives on the object being adopted rather than in the Weave on + // purpose. Consent has to come from whoever holds the thing, or "adoption" + // is just a Weave author choosing a name and helping themselves to + // somebody else's resource - which is the whole reason the unannotated case + // is refused. // // A pattern is still consent, and still namespaced: a Weave only ever acts // in its own namespace, so "*" grants no more than "anybody who can already // create a Weave here". It is the form to reach for when onboarding a set // of objects at once, where naming the Weave on each of them is busywork // that says nothing extra. - AdoptAnnotation = Group + "/adopt" + AdoptAnnotation string + + // Predecessor is the group this one supersedes, if any. A Weave in this + // group may take over what a Weave of the same name in the predecessor + // made, once that Weave is gone; see the controller's handover. + Predecessor *API +} + +func newAPI(group, version string, predecessor *API) API { + return API{ + Group: group, + Version: version, + GroupVersion: group + "/" + version, + CRDName: Plural + "." + group, + FieldManager: group, + WeaveFinalizer: group + "/weave", + HeldFinalizerPrefix: group + "/held-", + WeaveLabel: group + "/weave", + WeaveUIDAnnotation: group + "/weave-uid", + OwnedAnnotation: group + "/owned", + AdoptAnnotation: group + "/adopt", + Predecessor: predecessor, + } +} + +var ( + // Legacy is weft.run/v1alpha1, the group Weft was first deployed under. + // Its identifiers must never change: they are on objects in clusters that + // predate v1. + Legacy = newAPI("weft.run", "v1alpha1", nil) + + // V1 is weft.alethic.solutions/v1, the current API. + V1 = newAPI("weft.alethic.solutions", "v1", &Legacy) + + // APIs lists every served group, current first. + APIs = []API{V1, Legacy} ) +// ForGroup returns the API for a group name. +func ForGroup(group string) (API, bool) { + for _, a := range APIs { + if a.Group == group { + return a, true + } + } + return API{}, false +} + +// IsWeaveOwnerRef reports whether an owner reference names a Weave in any +// served group, and which. +func IsWeaveOwnerRef(apiVersion, kind string) (API, bool) { + if kind != Kind { + return API{}, false + } + for _, a := range APIs { + if a.GroupVersion == apiVersion { + return a, true + } + } + return API{}, false +} + +// HeldFinalizer returns the finalizer a given Weave places on a resource it has +// asked to hold. +// +// A finalizer is a qualified name: the part after the slash is limited to 63 +// characters and a restricted alphabet, so the Weave's namespace and name are +// hashed rather than interpolated. The prefix stays literal so the finalizer is +// still recognisable and greppable. +func (a API) HeldFinalizer(namespace, name string) string { + sum := sha256.Sum256([]byte(namespace + "/" + name)) + return a.HeldFinalizerPrefix + hex.EncodeToString(sum[:])[:16] +} + +// IsHeldFinalizer reports whether a finalizer string was placed by a Weave in +// this group. +func (a API) IsHeldFinalizer(f string) bool { + return len(f) > len(a.HeldFinalizerPrefix) && strings.HasPrefix(f, a.HeldFinalizerPrefix) +} + // ConditionType values published on Weave.status.conditions. const ( // ConditionReady is true when every resource compose() returned has been diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go index 574e4ec..47e7ad3 100644 --- a/internal/naming/naming_test.go +++ b/internal/naming/naming_test.go @@ -17,47 +17,63 @@ func TestSourceFinalizerIsAValidQualifiedName(t *testing.T) { {strings.Repeat("n", 63), strings.Repeat("w", 253)}, {"a", "b"}, } - for _, c := range cases { - f := HeldFinalizer(c[0], c[1]) - if errs := validation.IsQualifiedName(f); len(errs) > 0 { - t.Errorf("HeldFinalizer(%q, %q) = %q is not a qualified name: %v", c[0], c[1], f, errs) - } - if !IsHeldFinalizer(f) { - t.Errorf("%q is not recognised as one of ours", f) + for _, api := range APIs { + for _, c := range cases { + f := api.HeldFinalizer(c[0], c[1]) + if errs := validation.IsQualifiedName(f); len(errs) > 0 { + t.Errorf("HeldFinalizer(%q, %q) = %q is not a qualified name: %v", c[0], c[1], f, errs) + } + if !api.IsHeldFinalizer(f) { + t.Errorf("%q is not recognised as one of ours", f) + } } } } func TestSourceFinalizerIsStableAndDistinct(t *testing.T) { - a := HeldFinalizer("ns", "app") - if a != HeldFinalizer("ns", "app") { - t.Error("the same Weave must always produce the same finalizer") - } - if a == HeldFinalizer("ns", "other") { - t.Error("different Weaves must produce different finalizers") - } - // Namespace and name must not be able to trade characters and collide. - if HeldFinalizer("a", "bc") == HeldFinalizer("ab", "c") { - t.Error("namespace and name are not separated in the hash input") + for _, api := range APIs { + a := api.HeldFinalizer("ns", "app") + if a != api.HeldFinalizer("ns", "app") { + t.Error("the same Weave must always produce the same finalizer") + } + if a == api.HeldFinalizer("ns", "other") { + t.Error("different Weaves must produce different finalizers") + } + // Namespace and name must not be able to trade characters and collide. + if api.HeldFinalizer("a", "bc") == api.HeldFinalizer("ab", "c") { + t.Error("namespace and name are not separated in the hash input") + } } } func TestForeignFinalizersAreNotClaimed(t *testing.T) { - for _, f := range []string{ - "kubernetes.io/pv-protection", - "other.example/weft", - HeldFinalizerPrefix, // the bare prefix is not a finalizer we placed - WeaveFinalizer, - } { - if IsHeldFinalizer(f) { - t.Errorf("%q should not be recognised as a source finalizer", f) + for _, api := range APIs { + for _, f := range []string{ + "kubernetes.io/pv-protection", + "other.example/weft", + api.HeldFinalizerPrefix, // the bare prefix is not a finalizer we placed + api.WeaveFinalizer, + } { + if api.IsHeldFinalizer(f) { + t.Errorf("%q should not be recognised as a source finalizer", f) + } } } + // Each group recognises only its own: a v1 Weave releasing holds must not + // strip one a weft.run Weave of the same name still needs. + if V1.IsHeldFinalizer(Legacy.HeldFinalizer("ns", "app")) { + t.Error("v1 claims a weft.run finalizer") + } + if Legacy.IsHeldFinalizer(V1.HeldFinalizer("ns", "app")) { + t.Error("weft.run claims a v1 finalizer") + } } func TestWeaveFinalizerIsValid(t *testing.T) { - if errs := validation.IsQualifiedName(WeaveFinalizer); len(errs) > 0 { - t.Errorf("%q is not a qualified name: %v", WeaveFinalizer, errs) + for _, api := range APIs { + if errs := validation.IsQualifiedName(api.WeaveFinalizer); len(errs) > 0 { + t.Errorf("%q is not a qualified name: %v", api.WeaveFinalizer, errs) + } } } @@ -71,26 +87,73 @@ func TestUserFor(t *testing.T) { } } -// Everything externally visible derives from Group, so a rename is one edit. +// Everything externally visible derives from the group, so no Weave writes +// another group's keys. func TestIdentifiersDeriveFromGroup(t *testing.T) { - for name, value := range map[string]string{ - "GroupVersion": GroupVersion, - "CRDName": CRDName, - "FieldManager": FieldManager, - "WeaveFinalizer": WeaveFinalizer, - "HeldFinalizerPrefix": HeldFinalizerPrefix, - "WeaveLabel": WeaveLabel, + for _, api := range APIs { + for name, value := range map[string]string{ + "GroupVersion": api.GroupVersion, + "CRDName": api.CRDName, + "FieldManager": api.FieldManager, + "WeaveFinalizer": api.WeaveFinalizer, + "HeldFinalizerPrefix": api.HeldFinalizerPrefix, + "WeaveLabel": api.WeaveLabel, + "WeaveUIDAnnotation": api.WeaveUIDAnnotation, + "OwnedAnnotation": api.OwnedAnnotation, + "AdoptAnnotation": api.AdoptAnnotation, + } { + if !strings.HasPrefix(value, api.Group) && !strings.HasSuffix(value, api.Group) { + t.Errorf("%s %s = %q does not derive from the group", api.Group, name, value) + } + } + } +} + +// The weft.run identifiers are on objects in clusters that predate v1. Changing +// any of them strands those objects: finalizers nobody releases, provenance +// nothing recognises, fields owned by a manager that no longer writes. +func TestLegacyIdentifiersAreFrozen(t *testing.T) { + for got, want := range map[string]string{ + Legacy.GroupVersion: "weft.run/v1alpha1", + Legacy.CRDName: "weaves.weft.run", + Legacy.FieldManager: "weft.run", + Legacy.WeaveFinalizer: "weft.run/weave", + Legacy.HeldFinalizerPrefix: "weft.run/held-", + Legacy.WeaveLabel: "weft.run/weave", + Legacy.WeaveUIDAnnotation: "weft.run/weave-uid", + Legacy.OwnedAnnotation: "weft.run/owned", + Legacy.AdoptAnnotation: "weft.run/adopt", } { - if !strings.Contains(value, Group) { - t.Errorf("%s = %q does not derive from Group %q", name, value, Group) + if got != want { + t.Errorf("legacy identifier changed: %q, want %q", got, want) } } + if Legacy.HeldFinalizer("ns", "app") != "weft.run/held-"+V1.HeldFinalizer("ns", "app")[len(V1.HeldFinalizerPrefix):] { + t.Error("the held finalizer hash must not depend on the group") + } +} + +func TestV1SupersedesLegacy(t *testing.T) { + if V1.Predecessor == nil || V1.Predecessor.Group != Legacy.Group { + t.Error("v1 must name weft.run as its predecessor, or nothing can be handed over") + } + if Legacy.Predecessor != nil { + t.Error("weft.run supersedes nothing") + } + if a, ok := IsWeaveOwnerRef("weft.run/v1alpha1", "Weave"); !ok || a.Group != Legacy.Group { + t.Error("a weft.run owner reference is not recognised") + } + if _, ok := IsWeaveOwnerRef("weft.run/v1alpha1", "ConfigMap"); ok { + t.Error("an owner reference of another kind is not a Weave") + } } func TestLabelAndAnnotationKeysAreValid(t *testing.T) { - for _, k := range []string{WeaveLabel, WeaveUIDAnnotation, OwnedAnnotation} { - if errs := validation.IsQualifiedName(k); len(errs) > 0 { - t.Errorf("%q is not a qualified name: %v", k, errs) + for _, api := range APIs { + for _, k := range []string{api.WeaveLabel, api.WeaveUIDAnnotation, api.OwnedAnnotation, api.AdoptAnnotation} { + if errs := validation.IsQualifiedName(k); len(errs) > 0 { + t.Errorf("%q is not a qualified name: %v", k, errs) + } } } } diff --git a/internal/variables/variables.go b/internal/variables/variables.go index a9fbe61..75a039c 100644 --- a/internal/variables/variables.go +++ b/internal/variables/variables.go @@ -2,7 +2,7 @@ package variables import ( - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" "github.com/alethic/weft/internal/jsonutil" ) @@ -38,7 +38,7 @@ func Merge(base, over map[string]any) map[string]any { // It exists for anything that has to understand a composition without a cluster // to read the referenced objects from: tests over the shipped examples, and // tooling that wants to know what a program would see from its spec alone. -func Inline(entries []v1alpha1.Variable) map[string]any { +func Inline(entries []weftv1.Variable) map[string]any { out := map[string]any{} for _, entry := range entries { if entry.Values == nil { diff --git a/internal/variables/variables_test.go b/internal/variables/variables_test.go index 3455988..0b0695b 100644 --- a/internal/variables/variables_test.go +++ b/internal/variables/variables_test.go @@ -6,7 +6,7 @@ import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "github.com/alethic/weft/api/v1alpha1" + weftv1 "github.com/alethic/weft/api/v1" ) func TestMergeLaterEntryWins(t *testing.T) { @@ -91,14 +91,14 @@ func TestMergeWithNothingOver(t *testing.T) { } } -func values(raw string) v1alpha1.Variable { - return v1alpha1.Variable{Values: &apiextensionsv1.JSON{Raw: []byte(raw)}} +func values(raw string) weftv1.Variable { + return weftv1.Variable{Values: &apiextensionsv1.JSON{Raw: []byte(raw)}} } func TestInlineMergesOnlyWrittenEntries(t *testing.T) { - got := Inline([]v1alpha1.Variable{ + got := Inline([]weftv1.Variable{ values(`{"a": "first", "shared": "first"}`), - {ConfigMap: &v1alpha1.VariableRef{Name: "ignored-without-a-cluster"}}, + {ConfigMap: &weftv1.VariableRef{Name: "ignored-without-a-cluster"}}, values(`{"b": "second", "shared": "second"}`), }) @@ -111,7 +111,7 @@ func TestInlineMergesOnlyWrittenEntries(t *testing.T) { // Integers have to survive, or a replica count reaches a program as a float and // comes back out as one. func TestInlineKeepsTypes(t *testing.T) { - got := Inline([]v1alpha1.Variable{values(`{"replicas": 3, "debug": true}`)}) + got := Inline([]weftv1.Variable{values(`{"replicas": 3, "debug": true}`)}) if r, ok := got["replicas"].(int64); !ok || r != 3 { t.Errorf("replicas = %#v, want int64(3)", got["replicas"]) } diff --git a/internal/watches/registry.go b/internal/watches/registry.go index f27f493..dcb73e1 100644 --- a/internal/watches/registry.go +++ b/internal/watches/registry.go @@ -20,13 +20,11 @@ import ( "github.com/go-logr/logr" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/metadata/metadatainformer" toolscache "k8s.io/client-go/tools/cache" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/event" - "github.com/alethic/weft/api/v1alpha1" "github.com/alethic/weft/internal/kube" "github.com/alethic/weft/internal/metrics" ) @@ -51,6 +49,18 @@ func (k Key) String() string { return fmt.Sprintf("%s %s/%s as %s", k.GVK.Kind, k.GVK.GroupVersion(), k.Namespace, k.ServiceAccount) } +// Weave identifies the Weave holding a watch. +// +// The group is part of it because a weft.run Weave and a v1 Weave can share a +// namespace and a name, and each must be woken by its own controller. +type Weave struct { + Group string + Namespace string + Name string +} + +func (w Weave) String() string { return w.Group + " " + w.Namespace + "/" + w.Name } + // Failure records a watch that could not be established. type Failure struct { Key Key @@ -107,21 +117,21 @@ func (o *Options) applyDefaults() { // Registry owns every dynamic watch, reference-counted by Weave. type Registry struct { - opts Options - events chan event.GenericEvent + opts Options mu sync.Mutex started bool ctx context.Context + events map[string]chan event.GenericEvent entries map[Key]*entry - refs map[types.NamespacedName]map[Key]bool + refs map[Weave]map[Key]bool } // entry is one running informer and the Weaves that depend on it. type entry struct { key Key client *kube.Client - refs map[types.NamespacedName]bool + refs map[Weave]bool cancel context.CancelFunc mu sync.Mutex @@ -133,14 +143,29 @@ func NewRegistry(opts Options) *Registry { opts.applyDefaults() return &Registry{ opts: opts, - events: make(chan event.GenericEvent, opts.Buffer), + events: make(map[string]chan event.GenericEvent), entries: make(map[Key]*entry), - refs: make(map[types.NamespacedName]map[Key]bool), + refs: make(map[Weave]map[Key]bool), } } -// Events is the channel to wire into the controller as a source. -func (r *Registry) Events() <-chan event.GenericEvent { return r.events } +// Events is the channel to wire into one group's controller as a source. Each +// group's Weaves are enqueued on their own channel, so a change wakes the +// Weave that read it and not a same-named one in the other group. +func (r *Registry) Events(group string) <-chan event.GenericEvent { + r.mu.Lock() + defer r.mu.Unlock() + return r.channelLocked(group) +} + +func (r *Registry) channelLocked(group string) chan event.GenericEvent { + ch, ok := r.events[group] + if !ok { + ch = make(chan event.GenericEvent, r.opts.Buffer) + r.events[group] = ch + } + return ch +} // Start implements manager.Runnable. It holds the parent context for every // informer and tears them all down on shutdown. @@ -158,7 +183,7 @@ func (r *Registry) Start(ctx context.Context) error { e.cancel() delete(r.entries, k) } - r.refs = make(map[types.NamespacedName]map[Key]bool) + r.refs = make(map[Weave]map[Key]bool) return nil } @@ -168,7 +193,7 @@ func (r *Registry) NeedLeaderElection() bool { return true } // Ensure makes the registry's watch set for one Weave exactly the given keys, // starting what is missing and releasing what is no longer referenced. -func (r *Registry) Ensure(weave types.NamespacedName, c *kube.Client, want []Key) (Report, error) { +func (r *Registry) Ensure(weave Weave, c *kube.Client, want []Key) (Report, error) { r.mu.Lock() defer r.mu.Unlock() @@ -207,7 +232,7 @@ func (r *Registry) Ensure(weave types.NamespacedName, c *kube.Client, want []Key } // Release drops every watch held for a Weave. -func (r *Registry) Release(weave types.NamespacedName) { +func (r *Registry) Release(weave Weave) { r.mu.Lock() defer r.mu.Unlock() for k := range r.refs[weave] { @@ -240,18 +265,18 @@ func (r *Registry) publishLocked() { metrics.WatchesActive.WithLabelValues("degraded").Set(float64(degraded)) } -func (r *Registry) acquireLocked(weave types.NamespacedName, k Key, c *kube.Client) *entry { +func (r *Registry) acquireLocked(weave Weave, k Key, c *kube.Client) *entry { if e, ok := r.entries[k]; ok { e.refs[weave] = true return e } - e := &entry{key: k, client: c, refs: map[types.NamespacedName]bool{weave: true}} + e := &entry{key: k, client: c, refs: map[Weave]bool{weave: true}} r.entries[k] = e r.startLocked(e) return e } -func (r *Registry) releaseLocked(weave types.NamespacedName, k Key) { +func (r *Registry) releaseLocked(weave Weave, k Key) { e, ok := r.entries[k] if !ok { return @@ -348,25 +373,30 @@ func (r *Registry) expire(ctx context.Context, k Key) { r.startLocked(replacement) } -// notify enqueues every Weave that depends on a key. +// notify enqueues every Weave that depends on a key, each on its own group's +// channel. func (r *Registry) notify(k Key) { + type target struct { + ch chan event.GenericEvent + w Weave + } r.mu.Lock() e, ok := r.entries[k] if !ok { r.mu.Unlock() return } - weaves := make([]types.NamespacedName, 0, len(e.refs)) + targets := make([]target, 0, len(e.refs)) for w := range e.refs { - weaves = append(weaves, w) + targets = append(targets, target{ch: r.channelLocked(w.Group), w: w}) } ctx := r.ctx r.mu.Unlock() - for _, w := range weaves { - ev := event.GenericEvent{Object: weaveRef(w)} + for _, t := range targets { + ev := event.GenericEvent{Object: weaveRef(t.w)} select { - case r.events <- ev: + case t.ch <- ev: case <-ctx.Done(): return } @@ -374,9 +404,9 @@ func (r *Registry) notify(k Key) { } // weaveRef is the minimum object the enqueue handler needs: a name and a -// namespace. -func weaveRef(w types.NamespacedName) client.Object { - return &v1alpha1.Weave{ +// namespace. Which group it belongs to is said by the channel it arrives on. +func weaveRef(w Weave) client.Object { + return &metav1.PartialObjectMetadata{ ObjectMeta: metav1.ObjectMeta{Name: w.Name, Namespace: w.Namespace}, } } From 0dde9fb10c4b81600e8d4253897e4283bac1b7e5 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 11:21:48 -0500 Subject: [PATCH 3/9] Hand over instead of tearing down when a v1 successor exists Deploying tools move a Weave between groups by creating the new object and then deleting the old one. Helm, Flux's helm-controller and kustomize-controller all apply first and prune after, with background propagation, and none can orphan. The weft.run Weave being pruned would then tear down everything it made, only for its v1 successor to recreate it. A weft.run Weave deleted while a live v1 Weave of the same name exists now strips its own owner references, so garbage collection has nothing to collect, releases its holds, and lets go. The successor takes each object over through the existing handover. The existence check reads past the cache, since a stale answer here is the difference between a handover and a teardown, and an error is retried rather than treated as absence. The chart now renders when legacyAPI is missing from the values, as it is under helm upgrade --reuse-values from a release that predates it, and treats missing as enabled. --- charts/weft/templates/NOTES.txt | 2 +- charts/weft/templates/_helpers.tpl | 16 ++++- charts/weft/templates/crds.yaml | 2 +- charts/weft/templates/rbac.yaml | 2 +- cmd/weft/chart_test.go | 12 ++++ cmd/weft/main.go | 1 + docs/migrating.md | 33 ++++++++++ internal/controller/adopt.go | 2 +- internal/controller/handover_test.go | 84 ++++++++++++++++++++++++ internal/controller/suite_test.go | 1 + internal/controller/weave_controller.go | 86 +++++++++++++++++++++++++ internal/naming/naming.go | 10 +++ 12 files changed, 246 insertions(+), 5 deletions(-) diff --git a/charts/weft/templates/NOTES.txt b/charts/weft/templates/NOTES.txt index 43e1826..43e79bd 100644 --- a/charts/weft/templates/NOTES.txt +++ b/charts/weft/templates/NOTES.txt @@ -21,7 +21,7 @@ their Weave names does need permission to read what its program reads and to write its outputs. Weft tells them exactly what is missing, in commands they can paste: kubectl describe weave -{{- if .Values.legacyAPI.enabled }} +{{- if include "weft.legacyAPI" . }} weft.run/v1alpha1 Weaves, from before weft.alethic.solutions/v1, are served too. While both are installed, name the group to be sure which you get: diff --git a/charts/weft/templates/_helpers.tpl b/charts/weft/templates/_helpers.tpl index ecd920f..fe27c3d 100644 --- a/charts/weft/templates/_helpers.tpl +++ b/charts/weft/templates/_helpers.tpl @@ -116,6 +116,20 @@ Every numeric value is passed through int or int64 on the way out. YAML decodes a large integer as a float64, and Helm renders that as 2e+07, which the flag parser rejects and the container crash-loops on. */}} +{{- /* +Whether weft.run/v1alpha1 is served. Missing counts as on, not off: a release +upgraded with --reuse-values carries the values of a chart that predates this +key, and reading .Values.legacyAPI.enabled through a missing map fails the +render outright. Defaulting to on is also the safe direction - off would stop +reconciling every weft.run Weave in the cluster. + +Renders "true" or nothing, so it can be used directly in an if. +*/}} +{{- define "weft.legacyAPI" -}} +{{- $l := .Values.legacyAPI | default dict -}} +{{- if or (not (hasKey $l "enabled")) $l.enabled }}true{{ end -}} +{{- end -}} + {{- define "weft.args" -}} {{- $c := .Values.controller -}} - --health-probe-bind-address=:{{ int .Values.health.port }} @@ -131,7 +145,7 @@ parser rejects and the container crash-loops on. {{- if $c.watchNamespace }} - --namespace={{ $c.watchNamespace }} {{- end }} -- --serve-legacy-api={{ .Values.legacyAPI.enabled }} +- --serve-legacy-api={{ eq (include "weft.legacyAPI" .) "true" }} - --impersonate-groups={{ include "weft.impersonateGroups" . }} - --prune-delay={{ $c.pruneDelay }} - --prune-threshold={{ int $c.pruneThreshold }} diff --git a/charts/weft/templates/crds.yaml b/charts/weft/templates/crds.yaml index 54f12a7..cd735e3 100644 --- a/charts/weft/templates/crds.yaml +++ b/charts/weft/templates/crds.yaml @@ -10,7 +10,7 @@ while legacyAPI.enabled says it is still served. */}} {{- $root := . }} {{- range $path, $_ := .Files.Glob "files/crds/*.yaml" }} -{{- if or $root.Values.legacyAPI.enabled (not (hasSuffix "weft.run_weaves.yaml" $path)) }} +{{- if or (include "weft.legacyAPI" $root) (not (hasSuffix "weft.run_weaves.yaml" $path)) }} {{- $crd := $root.Files.Get $path | fromYaml }} {{- $meta := $crd.metadata }} {{- $_ := set $meta "labels" (merge (default (dict) $meta.labels) (include "weft.labels" $root | fromYaml)) }} diff --git a/charts/weft/templates/rbac.yaml b/charts/weft/templates/rbac.yaml index d62f223..cce8a51 100644 --- a/charts/weft/templates/rbac.yaml +++ b/charts/weft/templates/rbac.yaml @@ -27,7 +27,7 @@ rules: - apiGroups: ["weft.alethic.solutions"] resources: ["weaves/finalizers"] verbs: ["update"] - {{- if .Values.legacyAPI.enabled }} + {{- if include "weft.legacyAPI" . }} # The same for weft.run, the group Weft was first deployed under. - apiGroups: ["weft.run"] resources: ["weaves"] diff --git a/cmd/weft/chart_test.go b/cmd/weft/chart_test.go index ea1406c..ba448e4 100644 --- a/cmd/weft/chart_test.go +++ b/cmd/weft/chart_test.go @@ -431,6 +431,18 @@ func TestChartServesLegacyAPIUnlessDisabled(t *testing.T) { } } +// helm upgrade --reuse-values carries the previous release's values, which +// predate legacyAPI. The chart must render with the key missing altogether - +// and treat it as on, since off would stop reconciling every weft.run Weave. +func TestChartRendersWithoutLegacyAPIValue(t *testing.T) { + out := renderChart(t, "--set", "legacyAPI=null") + for _, want := range []string{"name: " + naming.Legacy.CRDName, "--serve-legacy-api=true"} { + if !strings.Contains(out, want) { + t.Errorf("with legacyAPI unset the render is missing %q", want) + } + } +} + // documentContaining returns the first rendered document containing a marker. func documentContaining(t *testing.T, rendered, marker string) string { t.Helper() diff --git a/cmd/weft/main.go b/cmd/weft/main.go index ae70bbe..3e617cc 100644 --- a/cmd/weft/main.go +++ b/cmd/weft/main.go @@ -201,6 +201,7 @@ func runManager(args []string) error { reconciler := &controller.WeaveReconciler{ Served: served, Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), Scheme: mgr.GetScheme(), Factory: factory, Evaluator: evaluator, diff --git a/docs/migrating.md b/docs/migrating.md index 5d9c82b..22c8229 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -106,6 +106,39 @@ the old one is gone. If the old Weave was not deleted at all, the new one is `Degraded` and its message gives the exact `kubectl delete … --cascade=orphan` command to run. +### With Helm, Flux or another deployment tool + +If your Weaves are deployed from a chart or a Kustomization, change the +`apiVersion` (and any annotation keys) in the manifest and roll it out as one +release. That is the whole procedure. + +Helm, Flux's helm-controller and kustomize-controller all apply a release the +same way: they create or update everything in the new release first, and only +then delete what is no longer in it. That deletion uses background propagation, +and none of them can be told to orphan. So by the time the old Weave is +deleted, its v1 successor already exists. A `weft.run` Weave that is deleted +while a live v1 Weave of the same name exists in its namespace does not tear +anything down. It removes its own owner references, so the garbage collector +has nothing to collect, records a `HandingOver` event, and lets go. The v1 +Weave then takes each object over as described above. + +This depends on the new Weave existing before the old one is deleted. Keep the +change in a single release: + +- **Do not** remove the old Weave in one release and add the new one in a later + one. +- **Do not** move it between two Kustomizations or two HelmReleases in the same + change. Each is reconciled on its own schedule, so the old one may be pruned + before the new one exists. + +If the ordering cannot be guaranteed, delete the old Weave by hand with +`--cascade=orphan` first, as above, and let the tool's prune find it already +gone. + +Upgrade Weft itself before rolling out any v1 Weave. The release that creates +one needs the `weaves.weft.alethic.solutions` CRD to exist. If it does not, the +release fails before it deletes anything, so the old Weave is left untouched. + ### What is not handed over - **Objects the new program does not declare.** Handover happens as the program diff --git a/internal/controller/adopt.go b/internal/controller/adopt.go index 8758818..8debb93 100644 --- a/internal/controller/adopt.go +++ b/internal/controller/adopt.go @@ -340,7 +340,7 @@ func (r *WeaveReconciler) handOver( func (r *WeaveReconciler) weaveExists(ctx context.Context, api naming.API, namespace, name string) (exists, deleting bool, err error) { obj := &metav1.PartialObjectMetadata{} obj.SetGroupVersionKind(schema.GroupVersionKind{Group: api.Group, Version: api.Version, Kind: naming.Kind}) - err = r.Client.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, obj) + err = r.reader().Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, obj) switch { case apierrors.IsNotFound(err), meta.IsNoMatchError(err): return false, false, nil diff --git a/internal/controller/handover_test.go b/internal/controller/handover_test.go index 12a664d..36c7e65 100644 --- a/internal/controller/handover_test.go +++ b/internal/controller/handover_test.go @@ -372,3 +372,87 @@ def compose(variable): t.Error("the weft.run Weave lost its finalizer") } } + +// What Helm, Flux and anything else that applies manifests do when an +// apiVersion changes: create the new object, then delete the old one with +// background propagation. None of them can pass --cascade=orphan, so the old +// Weave must recognise its successor and let go instead of tearing down. +func TestLegacyDeletedAfterSuccessorExistsHandsOver(t *testing.T) { + h := newHarness(t, nil) + h.createLegacy("app", legacyProgram) + h.reconcileLegacy("app") + + before := map[string]types.UID{} + for _, n := range []string{"settings", "keeper"} { + cm, err := h.getConfigMap(n) + if err != nil { + t.Fatalf("%s not created: %v", n, err) + } + before[n] = cm.UID + } + + // The new release's object first... + h.create("app", movedProgram, "") + h.settle("app", 1) + + // ...then the old one's removal, as a deploying tool issues it. + background := metav1.DeletePropagationBackground + if err := testK8s.Delete(h.ctx, h.legacyWeave("app"), &client.DeleteOptions{PropagationPolicy: &background}); err != nil { + t.Fatal(err) + } + h.reconcileLegacy("app") + + if !h.recordedEvent("HandingOver", "weft.alethic.solutions/v1") { + t.Error("no HandingOver event") + } + // The owner references are what the garbage collector acts on, and the + // test control plane runs none, so their absence is the assertion. + for _, n := range []string{"settings", "keeper"} { + cm, err := h.getConfigMap(n) + if err != nil { + t.Fatalf("%s was torn down: %v", n, err) + } + for _, ref := range cm.OwnerReferences { + if ref.APIVersion == naming.Legacy.GroupVersion { + t.Errorf("%s still carries the weft.run owner reference; garbage collection would delete it", n) + } + } + } + + h.settle("app", 3) + requireCondition(t, h.weave("app"), naming.ConditionReady, metav1.ConditionTrue) + for n, uid := range before { + cm, err := h.getConfigMap(n) + if err != nil { + t.Fatalf("%s is gone: %v", n, err) + } + if cm.UID != uid { + t.Errorf("%s was recreated", n) + } + } + settings, _ := h.getConfigMap("settings") + if len(settings.OwnerReferences) != 1 || settings.OwnerReferences[0].APIVersion != naming.V1.GroupVersion { + t.Errorf("settings owner references = %+v, want the v1 Weave only", settings.OwnerReferences) + } +} + +// Without a successor, deleting a weft.run Weave still means what it always +// meant. +func TestLegacyDeletedWithoutSuccessorTearsDown(t *testing.T) { + h := newHarness(t, nil) + h.createLegacy("app", legacyProgram) + h.reconcileLegacy("app") + + if err := testK8s.Delete(h.ctx, h.legacyWeave("app")); err != nil { + t.Fatal(err) + } + for i := 0; i < 4; i++ { + h.reconcileLegacy("app") + } + if h.exists("settings") { + t.Error("an owned resource survived its weft.run Weave's deletion") + } + if !h.exists("keeper") { + t.Error("an unowned resource was deleted with its weft.run Weave") + } +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index feaf217..7b66f95 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -168,6 +168,7 @@ func newHarness(t *testing.T, tune func(*Options)) *harness { r := &WeaveReconciler{ Served: ServedV1, Client: testK8s, + APIReader: testK8s, Scheme: testK8s.Scheme(), Factory: factory, Evaluator: eval.NewStarlark(eval.Options{}), diff --git a/internal/controller/weave_controller.go b/internal/controller/weave_controller.go index b965bf2..5bd3e45 100644 --- a/internal/controller/weave_controller.go +++ b/internal/controller/weave_controller.go @@ -127,6 +127,12 @@ type WeaveReconciler struct { // touches goes through the impersonated Factory instead. Client client.Client + // APIReader reads straight from the API server, bypassing the cache. It is + // used where a stale answer would be destructive: whether a Weave in + // another group exists decides between handing resources over and tearing + // them down. Nil means Client. + APIReader client.Reader + Scheme *runtime.Scheme Factory *kube.Factory Evaluator eval.Evaluator @@ -475,6 +481,15 @@ func (r *WeaveReconciler) reconcileDeletion(ctx context.Context, weave *weftv1.W return r.finishDeletion(ctx, weave, key) } + // The same Weave already exists in the group that supersedes this one. + // This is a move, not a removal - what deploying tools do when a manifest's + // apiVersion changes, since they create the new object and then delete the + // old one, and none of them can pass --cascade=orphan for it. Tearing down + // here would delete everything only for the successor to recreate it. + if handed, res, err := r.handOffToSuccessor(ctx, c, weave, key); handed { + return res, err + } + // An unowned resource outlives its composition, and the Weave being deleted // is the strongest form of that. It carries no owner reference, so nothing // would collect it anyway; dropping it from the inventory here keeps @@ -553,6 +568,77 @@ func (r *WeaveReconciler) finishDeletion(ctx context.Context, weave *weftv1.Weav return ctrl.Result{}, nil } +// handOffToSuccessor lets a deleting Weave go without tearing anything down, +// when a Weave of the same name exists in the group that supersedes this one. +// It reports whether it handled the deletion. +// +// Letting go means making sure nothing deletes what it made: the owner +// references it placed are removed first, so the garbage collector has nothing +// to collect once the finalizer is released. The successor then takes each +// object over as its program declares it; see handOver. +// +// A failure here never falls back to teardown. The successor exists, so +// teardown is the one outcome that is certainly wrong. +func (r *WeaveReconciler) handOffToSuccessor(ctx context.Context, c *kube.Client, weave *weftv1.Weave, key watches.Weave) (bool, ctrl.Result, error) { + succ, ok := r.Served.API.Successor() + if !ok { + return false, ctrl.Result{}, nil + } + exists, deleting, err := r.weaveExists(ctx, succ, weave.Namespace, weave.Name) + if err != nil { + // Unknown is not "absent". Retrying is cheap; guessing wrong is not. + return true, ctrl.Result{}, fmt.Errorf("checking for a %s Weave %q: %w", succ.GroupVersion, weave.Name, err) + } + if !exists || deleting { + return false, ctrl.Result{}, nil + } + + log := logf.FromContext(ctx) + for _, e := range weave.Status.Inventory { + if !e.Owned { + continue + } + gvk, err := inventory.GroupVersionKind(e) + if err != nil { + continue + } + if err := c.RemoveOwnerReference(ctx, gvk, e.Name, weave.UID); err != nil && !apierrors.IsNotFound(err) { + var perm *kube.PermissionError + if errors.As(err, &perm) { + before := weave.DeepCopy() + markDegraded(weave, ReasonForbidden, fmt.Sprintf( + "handing %s %q to the %s Weave of the same name needs permission to update it:\n%s", + e.Kind, e.Name, succ.GroupVersion, perm.Error())) + if serr := r.writeStatus(ctx, before, weave); serr != nil { + log.Error(serr, "writing status during handover") + } + return true, ctrl.Result{RequeueAfter: r.Opts.DegradedRetry}, nil + } + return true, ctrl.Result{}, fmt.Errorf("releasing %s %q for handover: %w", e.Kind, e.Name, err) + } + } + + if err := r.releaseAllHolds(ctx, c, weave); err != nil { + log.Error(err, "releasing source finalizers during handover") + } + + log.Info("handing resources to the successor rather than tearing them down", + "successor", succ.GroupVersion, "resources", len(weave.Status.Inventory)) + r.eventf(weave, "Normal", "HandingOver", + "left %d resources in place for the %s Weave %q, which takes them over", + len(weave.Status.Inventory), succ.GroupVersion, weave.Name) + res, err := r.finishDeletion(ctx, weave, key) + return true, res, err +} + +// reader is the uncached reader, falling back to the client. +func (r *WeaveReconciler) reader() client.Reader { + if r.APIReader != nil { + return r.APIReader + } + return r.Client +} + // update writes a Weave's metadata and spec back in its own group's type, and // takes up whatever the API server returned. func (r *WeaveReconciler) update(ctx context.Context, weave *weftv1.Weave) error { diff --git a/internal/naming/naming.go b/internal/naming/naming.go index 3f94bb2..1b15298 100644 --- a/internal/naming/naming.go +++ b/internal/naming/naming.go @@ -152,6 +152,16 @@ var ( APIs = []API{V1, Legacy} ) +// Successor returns the group that supersedes this one, if any. +func (a API) Successor() (API, bool) { + for _, s := range APIs { + if s.Predecessor != nil && s.Predecessor.Group == a.Group { + return s, true + } + } + return API{}, false +} + // ForGroup returns the API for a group name. func ForGroup(group string) (API, bool) { for _, a := range APIs { From e8cf041fb2292a419aa1a3a4da45779c647ba65c Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 11:34:45 -0500 Subject: [PATCH 4/9] Ship the published image digest in the chart, not its values helm upgrade --reuse-values carries the previous release's values in place of the new chart's defaults. The publish job wrote the image digest into values.yaml, so a reused upgrade kept the previous release's image under the new chart. Verified on kind: the 0.1.0-pre.37 binary exits on the new --serve-legacy-api flag, so the new pod crash-loops while the old one keeps serving. The digest now goes to files/image-digest, read by the image helper after anything the user set: image.digest, then image.tag, then the published digest, then the appVersion tag. A tag set by the user now takes effect on a published chart instead of being silently overridden. Prereleases already carry a digest in their stored values, so their first reused upgrade needs --set image.digest=; docs/migrating.md says so. --- .github/workflows/weft.yml | 4 ++- CONTRIBUTING.md | 6 ++-- charts/weft/README.md | 6 ++-- charts/weft/templates/_helpers.tpl | 23 +++++++++++++-- charts/weft/values.yaml | 3 ++ cmd/weft/chart_test.go | 47 ++++++++++++++++++++++++++++++ docs/migrating.md | 16 ++++++++++ 7 files changed, 97 insertions(+), 8 deletions(-) diff --git a/.github/workflows/weft.yml b/.github/workflows/weft.yml index 6427fce..bee3aad 100644 --- a/.github/workflows/weft.yml +++ b/.github/workflows/weft.yml @@ -336,7 +336,9 @@ jobs: run: | set -euo pipefail version='${{ steps.version.outputs.value }}' - sed -i "s|^ digest: .*| digest: \"${DIGEST}\"|" charts/weft/values.yaml + # In a file rather than values.yaml, so helm upgrade --reuse-values + # cannot carry a previous release's digest over this one. + printf '%s' "${DIGEST}" > charts/weft/files/image-digest helm package charts/weft --destination dist \ --version "$version" --app-version "$version" helm push dist/weft-*.tgz "oci://${CHARTS}" 2>&1 | tee push.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82a1537..374fdbf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,8 +56,10 @@ to make. The chart and the image always carry the same version. `Chart.yaml` holds a placeholder that is replaced at package time, and the chart's `appVersion` is what selects the image tag, so a packaged chart points at the image the same run -pushed. Releases additionally pin `image.digest`, so an installed release cannot -drift under a moved tag. +pushed. Every published chart additionally pins the image by digest, written to +`charts/weft/files/image-digest` rather than `values.yaml` so that +`helm upgrade --reuse-values` cannot carry an old digest forward, and an +installed release cannot drift under a moved tag. `latest` follows real releases only. A prerelease carries a label and never moves it. diff --git a/charts/weft/README.md b/charts/weft/README.md index 821d23e..abdb18a 100644 --- a/charts/weft/README.md +++ b/charts/weft/README.md @@ -25,7 +25,9 @@ helm show chart oci://ghcr.io/alethic/charts/weft The chart's `appVersion` selects the image tag, so a chart and the controller it installs always carry the same version. A released chart additionally pins -`image.digest`, so it cannot drift under a moved tag. +the image by digest, so it cannot drift under a moved tag. That digest ships in +the chart itself rather than in its values, so `helm upgrade --reuse-values` +still moves you to the new release's image. Or from a checkout: @@ -71,7 +73,7 @@ kubectl delete crd weaves.weft.run # if legacyAPI was enabled | `replicaCount` | `1` | Leader election means only one replica reconciles; a second buys faster handover, not throughput. | | `image.repository` | `ghcr.io/alethic/weft` | | | `image.tag` | `""` | Falls back to the chart's `appVersion`, which is the version the chart itself carries, so the two never disagree. | -| `image.digest` | `""` | Wins over `tag` when set. Pin this in production. | +| `image.digest` | `""` | Wins over `tag` when set. Pin this in production. Left empty, a published chart runs the digest it was published with. | | `image.pullPolicy` | `IfNotPresent` | | | `imagePullSecrets` | `[]` | | | `resources.requests.cpu` | `50m` | | diff --git a/charts/weft/templates/_helpers.tpl b/charts/weft/templates/_helpers.tpl index fe27c3d..c19e6cd 100644 --- a/charts/weft/templates/_helpers.tpl +++ b/charts/weft/templates/_helpers.tpl @@ -55,14 +55,31 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- end -}} {{/* -Container image. A digest, when given, wins over the tag: a tag can be moved, -and a controller that holds impersonation rights is a bad thing to let drift. +Container image, in order of precedence: + + 1. image.digest, when set. A tag can be moved, and a controller that holds + impersonation rights is a bad thing to let drift. + 2. image.tag, when set. + 3. The digest published with this chart, in files/image-digest. The publish + job writes it there; a chart from a checkout has none. + 4. The chart's appVersion as a tag. + +The published digest lives in a file rather than in values.yaml on purpose. +helm upgrade --reuse-values carries the previous release's values forward in +place of the new chart's defaults, so a digest in values.yaml would keep the +previous release's image running under the new chart - whose arguments it may +not understand. */}} {{- define "weft.image" -}} +{{- $published := .Files.Get "files/image-digest" | trim -}} {{- if .Values.image.digest -}} {{- printf "%s@%s" .Values.image.repository .Values.image.digest -}} +{{- else if .Values.image.tag -}} +{{- printf "%s:%s" .Values.image.repository .Values.image.tag -}} +{{- else if $published -}} +{{- printf "%s@%s" .Values.image.repository $published -}} {{- else -}} -{{- printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) -}} +{{- printf "%s:%s" .Values.image.repository .Chart.AppVersion -}} {{- end -}} {{- end -}} diff --git a/charts/weft/values.yaml b/charts/weft/values.yaml index 6d37954..1375fc7 100644 --- a/charts/weft/values.yaml +++ b/charts/weft/values.yaml @@ -12,6 +12,9 @@ image: # Defaults to the chart's appVersion when empty. tag: "" # A digest, when set, wins over tag. Pin this in production: a tag can move. + # + # Left empty, a published chart runs the image digest it was published with, + # and a chart from a checkout runs its appVersion tag. digest: "" pullPolicy: IfNotPresent diff --git a/cmd/weft/chart_test.go b/cmd/weft/chart_test.go index ba448e4..1a0f3aa 100644 --- a/cmd/weft/chart_test.go +++ b/cmd/weft/chart_test.go @@ -431,6 +431,53 @@ func TestChartServesLegacyAPIUnlessDisabled(t *testing.T) { } } +// The published chart pins its image by a digest written into +// files/image-digest. It must lose to anything the user set, and must not live +// in values.yaml, where helm upgrade --reuse-values would carry a previous +// release's digest - and so its binary - forward under a newer chart. +func TestChartImagePrecedence(t *testing.T) { + if _, err := exec.LookPath("helm"); err != nil { + t.Skip("helm is not installed") + } + const published = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + const user = "sha256:2222222222222222222222222222222222222222222222222222222222222222" + + dir := t.TempDir() + if err := os.CopyFS(dir, os.DirFS("../../charts/weft")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "files", "image-digest"), []byte(published+"\n"), 0o644); err != nil { + t.Fatal(err) + } + render := func(args ...string) string { + t.Helper() + full := append([]string{"template", "weft", dir, "--namespace", "weft-system"}, args...) + out, err := exec.Command("helm", full...).CombinedOutput() + if err != nil { + t.Fatalf("helm template: %v\n%s", err, out) + } + return string(out) + } + + for _, c := range []struct { + args []string + want string + }{ + {nil, "ghcr.io/alethic/weft@" + published}, + {[]string{"--set", "image.tag=custom"}, "ghcr.io/alethic/weft:custom"}, + {[]string{"--set", "image.digest=" + user}, "ghcr.io/alethic/weft@" + user}, + {[]string{"--set", "image.repository=mirror.example/weft"}, "mirror.example/weft@" + published}, + } { + if out := render(c.args...); !strings.Contains(out, "image: "+c.want) { + t.Errorf("with %v the image is not %s", c.args, c.want) + } + } + + if strings.Contains(readFile(t, "../../charts/weft/values.yaml"), "sha256:") { + t.Error("values.yaml carries a digest; --reuse-values would pin upgrades to it") + } +} + // helm upgrade --reuse-values carries the previous release's values, which // predate legacyAPI. The chart must render with the key missing altogether - // and treat it as on, since off would stop reconciling every weft.run Weave. diff --git a/docs/migrating.md b/docs/migrating.md index 22c8229..b3e036b 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -25,6 +25,22 @@ side by side: - The leader election lease keeps its old name, so during a rolling upgrade the old and new controller replicas contend for one lease instead of both acting. +### Upgrading from a 0.1.0 prerelease + +Prerelease charts (`0.1.0-pre.N`) stored the image digest in the release's +values. `helm upgrade --reuse-values` carries that digest forward, so the new +chart would run the old controller image. The old binary rejects the new +chart's `--serve-legacy-api` flag, so the new pod crash-loops while the old one +keeps running. Clear the digest on that first upgrade: + +```bash +helm upgrade weft oci://ghcr.io/alethic/charts/weft --version --namespace weft-system --reuse-values --set image.digest= +``` + +An upgrade without `--reuse-values` needs nothing extra. From the first release +onward the published digest ships inside the chart rather than its values, so +this does not recur. + The two schemas are identical. Moving a Weave means changing its `apiVersion` and the keys of any Weft annotations in its program. Nothing else changes. From e589bc55b22ace2c38e35b9bbd590b063ee20b03 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 11:52:05 -0500 Subject: [PATCH 5/9] Recommend --reset-then-reuse-values for the prerelease upgrade --reuse-values keeps the old chart's defaults, which for prereleases include the image digest. --reset-then-reuse-values starts from the new chart's defaults and re-applies only the user's overrides, which is what keeping one's settings across an upgrade should mean. Also restore a line continuation the previous edit lost. --- docs/migrating.md | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/migrating.md b/docs/migrating.md index b3e036b..ad41ebc 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -25,24 +25,31 @@ side by side: - The leader election lease keeps its old name, so during a rolling upgrade the old and new controller replicas contend for one lease instead of both acting. +The two schemas are identical. Moving a Weave means changing its `apiVersion` +and the keys of any Weft annotations in its program. Nothing else changes. + ### Upgrading from a 0.1.0 prerelease -Prerelease charts (`0.1.0-pre.N`) stored the image digest in the release's -values. `helm upgrade --reuse-values` carries that digest forward, so the new -chart would run the old controller image. The old binary rejects the new -chart's `--serve-legacy-api` flag, so the new pod crash-loops while the old one -keeps running. Clear the digest on that first upgrade: +To keep settings from your existing install, use `--reset-then-reuse-values`, +not `--reuse-values`: ```bash -helm upgrade weft oci://ghcr.io/alethic/charts/weft --version --namespace weft-system --reuse-values --set image.digest= +helm upgrade weft oci://ghcr.io/alethic/charts/weft --version \ + --namespace weft-system --reset-then-reuse-values ``` -An upgrade without `--reuse-values` needs nothing extra. From the first release -onward the published digest ships inside the chart rather than its values, so -this does not recur. +`--reset-then-reuse-values` starts from the new chart's defaults and re-applies +only what you overrode before. `--reuse-values` keeps the old chart's defaults +too, and that breaks this upgrade. Prerelease charts (`0.1.0-pre.N`) had the +image digest in their defaults, so `--reuse-values` would keep the old +controller image under the new chart. The old binary rejects the new chart's +`--serve-legacy-api` flag, so the new pod would crash-loop while the old one +kept running. If you do use `--reuse-values`, add `--set image.digest=` to clear +it. -The two schemas are identical. Moving a Weave means changing its `apiVersion` -and the keys of any Weft annotations in its program. Nothing else changes. +Upgrading with your own `-f values.yaml` and no reuse flag needs nothing extra. +From the first release onward the published digest ships inside the chart +rather than its values, so `--reuse-values` cannot pin it either. ## Moving one Weave @@ -176,7 +183,7 @@ Then stop serving it: ```bash helm upgrade weft oci://ghcr.io/alethic/charts/weft --namespace weft-system \ - --reuse-values --set legacyAPI.enabled=false + --reset-then-reuse-values --set legacyAPI.enabled=false ``` That stops the controller reconciling `weft.run` and drops its grant on it. From cdda4cacb164498b863464fc1ce09c09afad8886 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 12:05:03 -0500 Subject: [PATCH 6/9] Always serve both API groups The legacyAPI toggle let a cluster leave weft.run out, but turning it off while weft.run Weaves remained would strand them: nothing would reconcile them or release their finalizers. The saving - one unused CRD and a few RBAC rules - is not worth that. Both groups are now always installed, granted and reconciled, until a future release removes weft.run altogether. The --serve-legacy-api flag goes with it. With the flag gone, the --reuse-values digest problem no longer crash-loops the old binary; it silently keeps the old controller running, which serves no v1. The migration doc says so. --- .github/workflows/weft.yml | 1 - README.md | 4 +-- charts/weft/README.md | 8 +---- charts/weft/templates/NOTES.txt | 4 +-- charts/weft/templates/_helpers.tpl | 15 --------- charts/weft/templates/crds.yaml | 7 ++-- charts/weft/templates/rbac.yaml | 2 -- charts/weft/values.schema.json | 6 ---- charts/weft/values.yaml | 12 ------- cmd/weft/chart_test.go | 54 +++++++----------------------- cmd/weft/main.go | 6 ---- docs/migrating.md | 35 ++++++------------- docs/operating.md | 1 - 13 files changed, 30 insertions(+), 125 deletions(-) diff --git a/.github/workflows/weft.yml b/.github/workflows/weft.yml index bee3aad..f618c0a 100644 --- a/.github/workflows/weft.yml +++ b/.github/workflows/weft.yml @@ -146,7 +146,6 @@ jobs: render --set metrics.enabled=false render --set crds.install=false render --set crds.keep=false - render --set legacyAPI.enabled=false render --set controller.watchNamespace=team-a render --set rbac.impersonation.scope=namespaced --set 'rbac.impersonation.namespaces={a,b}' render --set replicaCount=2 --set podDisruptionBudget.enabled=true diff --git a/README.md b/README.md index 77fec0f..00a20cf 100644 --- a/README.md +++ b/README.md @@ -200,8 +200,8 @@ releases. Every such change is called out in the release notes. Weft was first deployed under the API group `weft.run/v1alpha1`. That group is still served alongside v1, with the same schema, so existing installations upgrade without changing anything and can move their Weaves to v1 one at a -time. [docs/migrating.md](docs/migrating.md) explains how. New installations -can leave it out with `--set legacyAPI.enabled=false`. +time. [docs/migrating.md](docs/migrating.md) explains how. Both groups are +served until a future release removes `weft.run`. CI runs the reconcile loop against a real API server on Kubernetes 1.27 (the minimum supported), 1.33 and 1.37, alongside unit tests of the evaluator and diff --git a/charts/weft/README.md b/charts/weft/README.md index abdb18a..adbaf1d 100644 --- a/charts/weft/README.md +++ b/charts/weft/README.md @@ -61,7 +61,7 @@ infrastructure. Remove them deliberately when you mean to: ```bash kubectl delete crd weaves.weft.alethic.solutions -kubectl delete crd weaves.weft.run # if legacyAPI was enabled +kubectl delete crd weaves.weft.run ``` ## Values @@ -91,12 +91,6 @@ kubectl delete crd weaves.weft.run # if legacyAPI was enabled | `crds.install` | `true` | Turn off when CRDs are managed separately. | | `crds.keep` | `true` | Keep the CRDs on uninstall. Read the warning above before changing this. | -### Legacy API - -| key | default | | -|---|---|---| -| `legacyAPI.enabled` | `true` | Also serve `weft.run/v1alpha1`, the group Weft was first deployed under: install its CRD, grant the controller access to it, and reconcile its Weaves. A cluster that has never had one can turn this off. Turning it off while `weft.run` Weaves remain leaves them unreconciled and holding finalizers. See [docs/migrating.md](../../docs/migrating.md). | - ### RBAC | key | default | | diff --git a/charts/weft/templates/NOTES.txt b/charts/weft/templates/NOTES.txt index 43e79bd..d516bfb 100644 --- a/charts/weft/templates/NOTES.txt +++ b/charts/weft/templates/NOTES.txt @@ -21,13 +21,11 @@ their Weave names does need permission to read what its program reads and to write its outputs. Weft tells them exactly what is missing, in commands they can paste: kubectl describe weave -{{- if include "weft.legacyAPI" . }} weft.run/v1alpha1 Weaves, from before weft.alethic.solutions/v1, are served too. -While both are installed, name the group to be sure which you get: +With both installed, name the group to be sure which you get: weaves.weft.alethic.solutions or weaves.weft.run. See docs/migrating.md for moving them across. -{{- end }} To try it: diff --git a/charts/weft/templates/_helpers.tpl b/charts/weft/templates/_helpers.tpl index c19e6cd..de70a1b 100644 --- a/charts/weft/templates/_helpers.tpl +++ b/charts/weft/templates/_helpers.tpl @@ -133,20 +133,6 @@ Every numeric value is passed through int or int64 on the way out. YAML decodes a large integer as a float64, and Helm renders that as 2e+07, which the flag parser rejects and the container crash-loops on. */}} -{{- /* -Whether weft.run/v1alpha1 is served. Missing counts as on, not off: a release -upgraded with --reuse-values carries the values of a chart that predates this -key, and reading .Values.legacyAPI.enabled through a missing map fails the -render outright. Defaulting to on is also the safe direction - off would stop -reconciling every weft.run Weave in the cluster. - -Renders "true" or nothing, so it can be used directly in an if. -*/}} -{{- define "weft.legacyAPI" -}} -{{- $l := .Values.legacyAPI | default dict -}} -{{- if or (not (hasKey $l "enabled")) $l.enabled }}true{{ end -}} -{{- end -}} - {{- define "weft.args" -}} {{- $c := .Values.controller -}} - --health-probe-bind-address=:{{ int .Values.health.port }} @@ -162,7 +148,6 @@ Renders "true" or nothing, so it can be used directly in an if. {{- if $c.watchNamespace }} - --namespace={{ $c.watchNamespace }} {{- end }} -- --serve-legacy-api={{ eq (include "weft.legacyAPI" .) "true" }} - --impersonate-groups={{ include "weft.impersonateGroups" . }} - --prune-delay={{ $c.pruneDelay }} - --prune-threshold={{ int $c.pruneThreshold }} diff --git a/charts/weft/templates/crds.yaml b/charts/weft/templates/crds.yaml index cd735e3..2542d5f 100644 --- a/charts/weft/templates/crds.yaml +++ b/charts/weft/templates/crds.yaml @@ -5,12 +5,12 @@ generate", so the chart cannot drift from the Go types. Each is parsed here only to attach the chart's labels and, when asked, the annotation that keeps it through an uninstall. -weft.run is the group Weft was first deployed under, and is installed only -while legacyAPI.enabled says it is still served. +Both served groups are always installed: weft.alethic.solutions, and weft.run, +the group Weft was first deployed under. The controller reconciles both and +will not start without either. */}} {{- $root := . }} {{- range $path, $_ := .Files.Glob "files/crds/*.yaml" }} -{{- if or (include "weft.legacyAPI" $root) (not (hasSuffix "weft.run_weaves.yaml" $path)) }} {{- $crd := $root.Files.Get $path | fromYaml }} {{- $meta := $crd.metadata }} {{- $_ := set $meta "labels" (merge (default (dict) $meta.labels) (include "weft.labels" $root | fromYaml)) }} @@ -27,4 +27,3 @@ be able to destroy infrastructure. {{ toYaml $crd }} {{- end }} {{- end }} -{{- end }} diff --git a/charts/weft/templates/rbac.yaml b/charts/weft/templates/rbac.yaml index cce8a51..8f1a060 100644 --- a/charts/weft/templates/rbac.yaml +++ b/charts/weft/templates/rbac.yaml @@ -27,7 +27,6 @@ rules: - apiGroups: ["weft.alethic.solutions"] resources: ["weaves/finalizers"] verbs: ["update"] - {{- if include "weft.legacyAPI" . }} # The same for weft.run, the group Weft was first deployed under. - apiGroups: ["weft.run"] resources: ["weaves"] @@ -38,7 +37,6 @@ rules: - apiGroups: ["weft.run"] resources: ["weaves/finalizers"] verbs: ["update"] - {{- end }} - apiGroups: [""] resources: ["events"] diff --git a/charts/weft/values.schema.json b/charts/weft/values.schema.json index 95a0ea3..1d52809 100644 --- a/charts/weft/values.schema.json +++ b/charts/weft/values.schema.json @@ -43,12 +43,6 @@ "keep": { "type": "boolean" } } }, - "legacyAPI": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" } - } - }, "rbac": { "type": "object", "properties": { diff --git a/charts/weft/values.yaml b/charts/weft/values.yaml index 1375fc7..d6e6f5d 100644 --- a/charts/weft/values.yaml +++ b/charts/weft/values.yaml @@ -46,18 +46,6 @@ crds: # be able to destroy infrastructure. keep: true -# weft.run/v1alpha1 is the API group Weft was first deployed under. It is -# served alongside weft.alethic.solutions/v1 so that Weaves written against it -# keep working and can be moved to v1 one at a time; see docs/migrating.md. -# -# A cluster that has never had a weft.run Weave can turn this off: its CRD is -# not installed, the controller does not reconcile it, and the controller is -# not granted access to it. Turning it off while weft.run Weaves remain leaves -# them unreconciled, holding finalizers nothing will release - move or reap -# them first. -legacyAPI: - enabled: true - rbac: create: true diff --git a/cmd/weft/chart_test.go b/cmd/weft/chart_test.go index 1a0f3aa..3716f35 100644 --- a/cmd/weft/chart_test.go +++ b/cmd/weft/chart_test.go @@ -397,38 +397,22 @@ func TestChartCRDMatchesGenerated(t *testing.T) { } } -// Both groups are served by default, so an upgrade from a release that only -// knew weft.run keeps every existing Weave reconciled. Turning the legacy group -// off removes all of it - CRD, grant and controller - together, since any one -// without the others is a controller that cannot start or a CRD nothing serves. -func TestChartServesLegacyAPIUnlessDisabled(t *testing.T) { +// Both groups are always served, so an upgrade from a release that only knew +// weft.run keeps every existing Weave reconciled. The controller registers a +// reconciler for each and will not start if either CRD is missing, so the +// chart installs and grants both, unconditionally. +func TestChartServesBothAPIs(t *testing.T) { out := renderChart(t) - for _, want := range []string{ - "name: " + naming.V1.CRDName, - "name: " + naming.Legacy.CRDName, - `apiGroups: ["` + naming.Legacy.Group + `"]`, - "--serve-legacy-api=true", - } { - if !strings.Contains(out, want) { - t.Errorf("default render is missing %q", want) - } - } - - out = renderChart(t, "--set", "legacyAPI.enabled=false") - if !strings.Contains(out, "name: "+naming.V1.CRDName) { - t.Error("disabling the legacy API removed the v1 CRD") - } - for _, unwanted := range []string{ - "name: " + naming.Legacy.CRDName, - `apiGroups: ["` + naming.Legacy.Group + `"]`, - } { - if strings.Contains(out, unwanted) { - t.Errorf("legacyAPI.enabled=false still renders %q", unwanted) + for _, api := range naming.APIs { + for _, want := range []string{ + "name: " + api.CRDName, + `apiGroups: ["` + api.Group + `"]`, + } { + if !strings.Contains(out, want) { + t.Errorf("render is missing %q", want) + } } } - if !strings.Contains(out, "--serve-legacy-api=false") { - t.Error("legacyAPI.enabled=false must tell the controller not to serve it") - } } // The published chart pins its image by a digest written into @@ -478,18 +462,6 @@ func TestChartImagePrecedence(t *testing.T) { } } -// helm upgrade --reuse-values carries the previous release's values, which -// predate legacyAPI. The chart must render with the key missing altogether - -// and treat it as on, since off would stop reconciling every weft.run Weave. -func TestChartRendersWithoutLegacyAPIValue(t *testing.T) { - out := renderChart(t, "--set", "legacyAPI=null") - for _, want := range []string{"name: " + naming.Legacy.CRDName, "--serve-legacy-api=true"} { - if !strings.Contains(out, want) { - t.Errorf("with legacyAPI unset the render is missing %q", want) - } - } -} - // documentContaining returns the first rendered document containing a marker. func documentContaining(t *testing.T, rendered, marker string) string { t.Helper() diff --git a/cmd/weft/main.go b/cmd/weft/main.go index 3e617cc..8a6eb75 100644 --- a/cmd/weft/main.go +++ b/cmd/weft/main.go @@ -69,7 +69,6 @@ type config struct { leaderElectID string groups string watchNamespace string - serveLegacy bool // pruneThreshold is bound separately because the flag package has no // int32 variant. @@ -91,8 +90,6 @@ func bindFlags(fs *flag.FlagSet, c *config) { // both lead at once for the length of the rollout. fs.StringVar(&c.leaderElectID, "leader-election-id", naming.Legacy.Group, "Name of the leader election lease.") fs.StringVar(&c.watchNamespace, "namespace", "", "Restrict to a single namespace. Empty watches all of them.") - fs.BoolVar(&c.serveLegacy, "serve-legacy-api", true, - "Also reconcile weft.run/v1alpha1 Weaves, the group Weft was first deployed under. Needs its CRD installed.") fs.StringVar(&c.groups, "impersonate-groups", strings.Join(kube.DefaultGroups, ","), @@ -195,9 +192,6 @@ func runManager(args []string) error { //nolint:staticcheck // SA1019: deliberate; see above. recorder := mgr.GetEventRecorderFor("weft") for _, served := range controller.AllServed { - if served.API.Group == naming.Legacy.Group && !cfg.serveLegacy { - continue - } reconciler := &controller.WeaveReconciler{ Served: served, Client: mgr.GetClient(), diff --git a/docs/migrating.md b/docs/migrating.md index ad41ebc..86ea3c6 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -4,9 +4,8 @@ Weft was first deployed under the API group `weft.run`, at version `v1alpha1`. The current API is `weft.alethic.solutions/v1`. This page is for installations that have `weft.run` Weaves. -If you have never had a `weft.run` Weave, none of this applies, and you can -install with `--set legacyAPI.enabled=false` to leave the old group out -entirely. +If you have never had a `weft.run` Weave, none of this applies. The old group +is still served, but nothing uses it. ## What upgrading does @@ -42,10 +41,9 @@ helm upgrade weft oci://ghcr.io/alethic/charts/weft --version \ only what you overrode before. `--reuse-values` keeps the old chart's defaults too, and that breaks this upgrade. Prerelease charts (`0.1.0-pre.N`) had the image digest in their defaults, so `--reuse-values` would keep the old -controller image under the new chart. The old binary rejects the new chart's -`--serve-legacy-api` flag, so the new pod would crash-loop while the old one -kept running. If you do use `--reuse-values`, add `--set image.digest=` to clear -it. +controller image under the new chart: the upgrade reports success, but the old +controller keeps running, and it does not serve v1 at all. If you do use +`--reuse-values`, add `--set image.digest=` to clear it. Upgrading with your own `-f values.yaml` and no reuse flag needs nothing extra. From the first release onward the published digest ships inside the chart @@ -179,24 +177,11 @@ Check that nothing is left in the old group: kubectl get weaves.weft.run --all-namespaces ``` -Then stop serving it: - -```bash -helm upgrade weft oci://ghcr.io/alethic/charts/weft --namespace weft-system \ - --reset-then-reuse-values --set legacyAPI.enabled=false -``` - -That stops the controller reconciling `weft.run` and drops its grant on it. -It does not delete the `weaves.weft.run` CRD, because `crds.keep` leaves CRDs -in place on purpose. Delete the CRD yourself once the list above is empty: - -```bash -kubectl delete crd weaves.weft.run -``` - -Do not turn `legacyAPI.enabled` off while `weft.run` Weaves remain. Nothing -would reconcile them, and nothing would release their finalizers. They could -not be deleted, and neither could the objects they hold. +There is nothing to switch off. Every Weft release serves both groups until a +future release removes `weft.run` altogether, and that release's notes will say +so. Before upgrading to it, this list must be empty. A `weft.run` Weave left +behind would have nothing reconciling it and nothing releasing its finalizers, +so neither it nor the objects it holds could be deleted. ## Metrics and dashboards diff --git a/docs/operating.md b/docs/operating.md index e3e8101..73eacbd 100644 --- a/docs/operating.md +++ b/docs/operating.md @@ -100,7 +100,6 @@ pruned, and what it started declaring is applied. | `--max-reads` | `100` | distinct resources one evaluation may read | | `--max-selected` | `500` | objects one `select()` may match | | `--namespace` | *(all)* | restrict the controller to one namespace | -| `--serve-legacy-api` | `true` | also reconcile `weft.run/v1alpha1` Weaves; see [migrating.md](migrating.md) | | `--impersonate-groups` | `system:serviceaccounts,system:authenticated` | see [rbac.md](rbac.md) | `weft --help` lists the rest. Every flag is exposed as a chart value; see From a9c34dbcd3de0759f5e57a787275ab738794d818 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 12:05:17 -0500 Subject: [PATCH 7/9] Add CLAUDE.md: no AI attribution in commits, PRs or files --- CLAUDE.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0a6dc85 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# CLAUDE.md + +## Attribution + +Do not add AI attribution anywhere in this repository: no `Co-Authored-By` +trailers naming an AI, no "Generated with" lines in commit messages, pull +request descriptions, issues, code comments or documentation. Commits and pull +requests are written as the author's own. From 736b0bacb72076901647ea6b5d5aa8e711c97231 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 12:28:20 -0500 Subject: [PATCH 8/9] Fix the Makefile's VERSION line The line stripped carriage returns from GitVersion's output with a literal CR byte between quotes. An earlier edit of the Makefile normalised line endings and turned that byte into a newline, splitting the line, so make refused to parse the file and every CI job that runs make failed. Write it as the escape '\r', which tr understands and which no editor will reinterpret. --- Makefile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 870fd2e..097fe11 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,7 @@ IMG ?= ghcr.io/alethic/weft:latest # GitVersion owns the version. It is derived from the branch and the history, # so nothing is tagged by hand and a build knows what it is without being told. # git describe is the fallback for a checkout without the tool installed. -VERSION ?= $(shell dotnet-gitversion -showvariable SemVer 2>/dev/null | tr -d ' -' || true) +VERSION ?= $(shell dotnet-gitversion -showvariable SemVer 2>/dev/null | tr -d '\r' || true) ifeq ($(strip $(VERSION)),) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) endif From add7cb7f48bd68fc4b2cc88a8d29f062ecc955b5 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 12:56:19 -0500 Subject: [PATCH 9/9] Update Go to 1.27.2 and refresh dependencies govulncheck now reports nine vulnerabilities reachable from this code, in net/http, crypto/tls, net/textproto and the HTTP/2 implementation in both the standard library and golang.org/x/net. All are fixed in Go 1.27.2 and x/net v0.60.0, and CI's vulncheck fails until they are. main pins the same versions, so it fails this check too. Also brought forward while here: - prometheus/client_golang 1.24.1 -> 1.25.0 - go.starlark.net to the latest commit - golang.org/x/* to current releases (x/net 0.61.0) - controller-gen 0.22.0 -> 0.23.0 (generated CRDs change only in the version annotation) - setup-envtest 0.25.0 -> 0.25.2 - golangci-lint 2.13.2 -> 2.15.0 (no new findings) The Kubernetes libraries and controller-runtime were already current. Their own indirect dependencies (kube-openapi, structured-merge-diff, k8s.io/utils, sigs.k8s.io/json) stay where Kubernetes 0.37.1 pins them: the newer kube-openapi needs structured-merge-diff v7, which apimachinery 0.37.1 does not build against. --- Makefile | 2 +- .../crds/weft.alethic.solutions_weaves.yaml | 2 +- charts/weft/files/crds/weft.run_weaves.yaml | 2 +- config/crd/weft.alethic.solutions_weaves.yaml | 2 +- config/crd/weft.run_weaves.yaml | 2 +- go.mod | 34 ++++---- go.sum | 80 +++++++++---------- 7 files changed, 62 insertions(+), 62 deletions(-) diff --git a/Makefile b/Makefile index 097fe11..7cb236f 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ CONTROLLER_GEN ?= go tool controller-gen SETUP_ENVTEST ?= go tool setup-envtest # Pinned so a local run and CI disagree about nothing. Bumping it here is the # only place it changes. -GOLANGCI_LINT_VERSION ?= v2.13.2 +GOLANGCI_LINT_VERSION ?= v2.15.0 GOLANGCI_LINT ?= $(shell command -v golangci-lint 2>/dev/null || echo $(shell go env GOPATH)/bin/golangci-lint) ENVTEST_K8S_VERSION ?= 1.34.x ENVTEST_DIR := $(CURDIR)/bin/envtest diff --git a/charts/weft/files/crds/weft.alethic.solutions_weaves.yaml b/charts/weft/files/crds/weft.alethic.solutions_weaves.yaml index 95a9855..277464c 100644 --- a/charts/weft/files/crds/weft.alethic.solutions_weaves.yaml +++ b/charts/weft/files/crds/weft.alethic.solutions_weaves.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.22.0 + controller-gen.kubebuilder.io/version: v0.23.0 name: weaves.weft.alethic.solutions spec: group: weft.alethic.solutions diff --git a/charts/weft/files/crds/weft.run_weaves.yaml b/charts/weft/files/crds/weft.run_weaves.yaml index e546ecd..adfd9f4 100644 --- a/charts/weft/files/crds/weft.run_weaves.yaml +++ b/charts/weft/files/crds/weft.run_weaves.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.22.0 + controller-gen.kubebuilder.io/version: v0.23.0 name: weaves.weft.run spec: group: weft.run diff --git a/config/crd/weft.alethic.solutions_weaves.yaml b/config/crd/weft.alethic.solutions_weaves.yaml index 95a9855..277464c 100644 --- a/config/crd/weft.alethic.solutions_weaves.yaml +++ b/config/crd/weft.alethic.solutions_weaves.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.22.0 + controller-gen.kubebuilder.io/version: v0.23.0 name: weaves.weft.alethic.solutions spec: group: weft.alethic.solutions diff --git a/config/crd/weft.run_weaves.yaml b/config/crd/weft.run_weaves.yaml index e546ecd..adfd9f4 100644 --- a/config/crd/weft.run_weaves.yaml +++ b/config/crd/weft.run_weaves.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.22.0 + controller-gen.kubebuilder.io/version: v0.23.0 name: weaves.weft.run spec: group: weft.run diff --git a/go.mod b/go.mod index 282b17e..4830491 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/alethic/weft -go 1.27.1 +go 1.27.2 require ( github.com/go-logr/logr v1.4.4 - github.com/prometheus/client_golang v1.24.1 - go.starlark.net v0.0.0-20260908191801-89a6a09411d5 + github.com/prometheus/client_golang v1.25.0 + go.starlark.net v0.0.0-20261016124120-4811d7e34ea3 go.yaml.in/yaml/v3 v3.0.5 k8s.io/api v0.37.1 k8s.io/apiextensions-apiserver v0.37.1 @@ -50,9 +50,9 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.70.1 // indirect - github.com/prometheus/procfs v0.21.1 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.72.0 // indirect + github.com/prometheus/procfs v0.22.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect @@ -60,17 +60,17 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/mod v0.39.0 // indirect - golang.org/x/net v0.58.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/term v0.45.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.61.0 // indirect + golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/term v0.47.0 // indirect + golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.49.0 // indirect + golang.org/x/tools v0.51.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/code-generator v0.37.1 // indirect @@ -78,8 +78,8 @@ require ( k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect - sigs.k8s.io/controller-runtime/tools/setup-envtest v0.25.0 // indirect - sigs.k8s.io/controller-tools v0.22.0 // indirect + sigs.k8s.io/controller-runtime/tools/setup-envtest v0.25.2 // indirect + sigs.k8s.io/controller-tools v0.23.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect diff --git a/go.sum b/go.sum index ec303e3..f557dda 100644 --- a/go.sum +++ b/go.sum @@ -94,8 +94,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -110,23 +110,23 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw= -github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= -github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/onsi/ginkgo/v2 v2.33.0 h1:C8gBA6Uc2ZEubiV+SXiu5tZnMTwEmXHgkJwGozKtZf8= +github.com/onsi/ginkgo/v2 v2.33.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.44.0 h1:eAiGl3Pw5jz5GQdDff0BcxYpAX1JxW8xD7mFUuwNfZQ= +github.com/onsi/gomega v1.44.0/go.mod h1:e/C2HwaZ1DhvjzXXuFhcR7hY7Sh9pl7MmoWKEjzwcdA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= -github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= -github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= -github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= -github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0= +github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4= +github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= @@ -144,8 +144,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -166,8 +166,8 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= -go.starlark.net v0.0.0-20260908191801-89a6a09411d5 h1:X8HyonnLxrmAbdeMIEGEJVZ/yg6WykLZyAZmpCLSfMA= -go.starlark.net v0.0.0-20260908191801-89a6a09411d5/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= +go.starlark.net v0.0.0-20261016124120-4811d7e34ea3 h1:ud4x+QbEBQbM2T6e0C3VuNwyap/+vUU7W9PFhIxmqDM= +go.starlark.net v0.0.0-20261016124120-4811d7e34ea3/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -181,25 +181,25 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= -golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= -golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -213,8 +213,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= -google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= -google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= @@ -249,10 +249,10 @@ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0 h1:/YpDJ4vReG7Zm sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0/go.mod h1:tJo1aepTXyR+8Xs3sUsGBDk4Ub2AM5dPAPKJx0mpm5c= sigs.k8s.io/controller-runtime v0.25.2 h1:bEkK3PVOIVK9X8QWLGVhJgmFc++47vfT6wakSzAOLsQ= sigs.k8s.io/controller-runtime v0.25.2/go.mod h1:4QqLdT6z/L6Olj8JJCtvztid4/fnIiYsfaTFScegctc= -sigs.k8s.io/controller-runtime/tools/setup-envtest v0.25.0 h1:P/fGuSapw13MrCE45zaTc3aC3BQbkk66u7slHxApd1k= -sigs.k8s.io/controller-runtime/tools/setup-envtest v0.25.0/go.mod h1:q4i4kLTq6J6o29RUAQieHH8LS5knNkpgalnBhvrLp/8= -sigs.k8s.io/controller-tools v0.22.0 h1:eG3FAVja/KnlXKIWg95udIFz1cMyAtMjP11cqBh3t+k= -sigs.k8s.io/controller-tools v0.22.0/go.mod h1:VizwUStoZK7rReCj704czGGrB7mLxXTiJSJt7wN5ilI= +sigs.k8s.io/controller-runtime/tools/setup-envtest v0.25.2 h1:53Y05LgEZ7qAr1bsdNTIcwgzEHQzHYUZkCDa3TqLdVg= +sigs.k8s.io/controller-runtime/tools/setup-envtest v0.25.2/go.mod h1:q4i4kLTq6J6o29RUAQieHH8LS5knNkpgalnBhvrLp/8= +sigs.k8s.io/controller-tools v0.23.0 h1:GFmPTaHSyje/kZv/4XOdR1v3rWz0/OJixwpPnGyV4Sw= +sigs.k8s.io/controller-tools v0.23.0/go.mod h1:JJRl1h3BoHJWtCVgwGsITk+dusn8LatINcIBhO8aP9A= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=