diff --git a/.github/workflows/weft.yml b/.github/workflows/weft.yml
index 320d1ec..f618c0a 100644
--- a/.github/workflows/weft.yml
+++ b/.github/workflows/weft.yml
@@ -335,7 +335,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/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.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 991a294..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.
@@ -151,9 +153,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 0cbde04..7cb236f 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +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
@@ -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
@@ -27,12 +27,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.run_weaves.yaml $(CHART)/files/weaves-crd.yaml
+ mkdir -p $(CHART)/files/crds
+ cp config/crd/*.yaml $(CHART)/files/crds/
.PHONY: fmt
fmt:
@@ -77,7 +78,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/
.PHONY: deploy
deploy: ## Install the controller with Helm.
diff --git a/README.md b/README.md
index 6fd8a4b..00a20cf 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.
+
+
+
+
+
-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/v1
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,150 @@ 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.
-
-Alerting on `Degraded` is useful. Alerting on `Waiting` is not: it is the steady
-state of any composition that spans several provisioning steps.
-
-### Reading the cluster
-
-```python
-rg = read("azure.m.upbound.io/v1beta1", "ResourceGroup", "sweep-env")
-tenants = select("v1", "ConfigMap", labels={"role": "tenant"})
-```
-
-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.
+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.
-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`.
+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.
-### Staging happens through `observed`
+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.
-`observed` holds what this `Weave` previously created, read back live. A
-composition advances in phases by returning only what it can:
+## Quick start
-```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"},
-}
+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
```
-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)
-```
-
-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/v1
+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.
+
+## Documentation
-`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.
+| | |
+|---|---|
+| [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 |
+| [Moving from weft.run](docs/migrating.md) | for installations that predate `weft.alethic.solutions/v1` |
+| [Security](SECURITY.md) | threat model, hardening, reporting a vulnerability |
-## What it will not do
+## Project status
-- 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.
+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.
-## Status
+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. Both groups are
+served until a future release removes `weft.run`.
-`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/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 1120b33..10c74cf 100644
--- a/api/v1alpha1/groupversion_info.go
+++ b/api/v1alpha1/groupversion_info.go
@@ -1,10 +1,15 @@
-// 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.run
@@ -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 7ef830a..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.run/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 f9a733d..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
- name: weaves.weft.run
+ 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.run/v1alpha1
+ - apiVersion: weft.alethic.solutions/v1
kind: Weave
metadata:
name: app
diff --git a/charts/weft/README.md b/charts/weft/README.md
index f79e710..adbaf1d 100644
--- a/charts/weft/README.md
+++ b/charts/weft/README.md
@@ -10,22 +10,24 @@ 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
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:
@@ -52,12 +54,13 @@ 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
```
@@ -70,7 +73,7 @@ kubectl delete crd weaves.weft.run
| `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` | |
@@ -86,7 +89,7 @@ kubectl delete crd weaves.weft.run
| 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. |
### RBAC
@@ -127,7 +130,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/crds/weft.alethic.solutions_weaves.yaml
similarity index 98%
rename from charts/weft/files/weaves-crd.yaml
rename to charts/weft/files/crds/weft.alethic.solutions_weaves.yaml
index 9bdaac5..277464c 100644
--- a/charts/weft/files/weaves-crd.yaml
+++ b/charts/weft/files/crds/weft.alethic.solutions_weaves.yaml
@@ -3,10 +3,10 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
- controller-gen.kubebuilder.io/version: v0.22.0
- name: weaves.weft.run
+ controller-gen.kubebuilder.io/version: v0.23.0
+ name: weaves.weft.alethic.solutions
spec:
- group: weft.run
+ group: weft.alethic.solutions
names:
kind: Weave
listKind: WeaveList
@@ -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.run/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..adfd9f4
--- /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.23.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..d516bfb 100644
--- a/charts/weft/templates/NOTES.txt
+++ b/charts/weft/templates/NOTES.txt
@@ -22,6 +22,11 @@ write its outputs. Weft tells them exactly what is missing, in commands they can
kubectl describe weave
+weft.run/v1alpha1 Weaves, from before weft.alethic.solutions/v1, are served too.
+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.
+
To try it:
kubectl create serviceaccount composer
diff --git a/charts/weft/templates/_helpers.tpl b/charts/weft/templates/_helpers.tpl
index 0afc61b..de70a1b 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/templates/crds.yaml b/charts/weft/templates/crds.yaml
index d22b90c..2542d5f 100644
--- a/charts/weft/templates/crds.yaml
+++ b/charts/weft/templates/crds.yaml
@@ -1,21 +1,29 @@
{{- 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.
+
+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.
*/}}
-{{- $crd := .Files.Get "files/weaves-crd.yaml" | fromYaml }}
+{{- $root := . }}
+{{- range $path, $_ := .Files.Glob "files/crds/*.yaml" }}
+{{- $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 }}
diff --git a/charts/weft/templates/rbac.yaml b/charts/weft/templates/rbac.yaml
index 90b6ca7..8f1a060 100644
--- a/charts/weft/templates/rbac.yaml
+++ b/charts/weft/templates/rbac.yaml
@@ -18,6 +18,16 @@ 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.alethic.solutions"]
+ resources: ["weaves"]
+ verbs: ["get", "list", "watch", "update", "patch"]
+ - apiGroups: ["weft.alethic.solutions"]
+ resources: ["weaves/status"]
+ verbs: ["get", "update", "patch"]
+ - apiGroups: ["weft.alethic.solutions"]
+ resources: ["weaves/finalizers"]
+ verbs: ["update"]
+ # The same for weft.run, the group Weft was first deployed under.
- apiGroups: ["weft.run"]
resources: ["weaves"]
verbs: ["get", "list", "watch", "update", "patch"]
diff --git a/charts/weft/values.yaml b/charts/weft/values.yaml
index b403c31..d6e6f5d 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
@@ -75,6 +78,8 @@ controller:
leaderElection:
enabled: true
# Lease name. Change it only to run two independent Weft installations.
+ # 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 b0495f9..3716f35 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,88 @@ 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.run_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 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 _, 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)
+ }
+ }
+ }
+}
+
+// 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")
}
}
@@ -405,25 +488,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.run_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..8a6eb75 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)
}
@@ -81,7 +85,10 @@ 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.StringVar(&c.groups, "impersonate-groups",
@@ -162,7 +169,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 +181,31 @@ 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 {
+ reconciler := &controller.WeaveReconciler{
+ Served: served,
+ Client: mgr.GetClient(),
+ APIReader: mgr.GetAPIReader(),
+ 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 +220,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 +243,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
new file mode 100644
index 0000000..277464c
--- /dev/null
+++ b/config/crd/weft.alethic.solutions_weaves.yaml
@@ -0,0 +1,402 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.23.0
+ name: weaves.weft.alethic.solutions
+spec:
+ group: weft.alethic.solutions
+ 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: v1
+ schema:
+ openAPIV3Schema:
+ description: |-
+ Weave reads existing resources in its namespace and generates others from
+ them, reactively, as the ServiceAccount it names.
+ 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/crd/weft.run_weaves.yaml b/config/crd/weft.run_weaves.yaml
index 9bdaac5..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
@@ -32,6 +32,9 @@ spec:
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: |-
@@ -332,8 +335,8 @@ 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
- 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/rbac/role.yaml b/config/rbac/role.yaml
index 35606f1..77327ca 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -27,6 +27,7 @@ rules:
verbs:
- impersonate
- apiGroups:
+ - weft.alethic.solutions
- weft.run
resources:
- weaves
@@ -37,12 +38,14 @@ rules:
- update
- watch
- apiGroups:
+ - weft.alethic.solutions
- weft.run
resources:
- weaves/finalizers
verbs:
- update
- apiGroups:
+ - weft.alethic.solutions
- weft.run
resources:
- weaves/status
diff --git a/demo/01-app-stack.yaml b/demo/01-app-stack.yaml
index 1e4b4cb..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.run/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 ae9c646..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.run/v1alpha1
+apiVersion: weft.alethic.solutions/v1
kind: Weave
metadata:
name: waiting
diff --git a/demo/03-forbidden.yaml b/demo/03-forbidden.yaml
index 07a790a..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.run/v1alpha1
+apiVersion: weft.alethic.solutions/v1
kind: Weave
metadata:
name: forbidden
diff --git a/demo/04-teardown.yaml b/demo/04-teardown.yaml
index 7612b62..926b801 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/v1
kind: Weave
metadata:
name: teardown
diff --git a/demo/05-pruning.yaml b/demo/05-pruning.yaml
index 6295c05..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.run/v1alpha1
+apiVersion: weft.alethic.solutions/v1
kind: Weave
metadata:
name: pruning
diff --git a/demo/06-finalizer.yaml b/demo/06-finalizer.yaml
index 6f2c3a9..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.run/v1alpha1
+apiVersion: weft.alethic.solutions/v1
kind: Weave
metadata:
name: finalizer
diff --git a/demo/07-bounds.yaml b/demo/07-bounds.yaml
index a0e2c1a..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.run/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.run/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.run/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.run/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.run/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.run/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.run/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.run/v1alpha1
+apiVersion: weft.alethic.solutions/v1
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/migrating.md b/docs/migrating.md
new file mode 100644
index 0000000..86ea3c6
--- /dev/null
+++ b/docs/migrating.md
@@ -0,0 +1,197 @@
+# 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. The old group
+is still served, but nothing uses it.
+
+## 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.
+
+### Upgrading from a 0.1.0 prerelease
+
+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 --reset-then-reuse-values
+```
+
+`--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 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
+rather than its values, so `--reuse-values` cannot pin it either.
+
+## 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.
+
+### 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
+ 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
+```
+
+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
+
+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
new file mode 100644
index 0000000..73eacbd
--- /dev/null
+++ b/docs/operating.md
@@ -0,0 +1,144 @@
+# 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. 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.
+
+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.alethic.solutions/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..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.run/v1alpha1
+apiVersion: weft.alethic.solutions/v1
kind: Weave
metadata:
name: app
diff --git a/examples/env.yaml b/examples/env.yaml
index 9c09c8c..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.run/v1alpha1
+apiVersion: weft.alethic.solutions/v1
kind: Weave
metadata:
name: env
diff --git a/examples/release-values.yaml b/examples/release-values.yaml
index 16fcfb4..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.run/v1alpha1
+apiVersion: weft.alethic.solutions/v1
kind: Weave
metadata:
name: release-values
diff --git a/examples/sql.yaml b/examples/sql.yaml
index 38843e9..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.run/v1alpha1
+apiVersion: weft.alethic.solutions/v1
kind: Weave
metadata:
name: sql
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=
diff --git a/internal/controller/adopt.go b/internal/controller/adopt.go
index b5bff69..8debb93 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.reader().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..36c7e65
--- /dev/null
+++ b/internal/controller/handover_test.go
@@ -0,0 +1,458 @@
+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")
+ }
+}
+
+// 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/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 efc84f9..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"
)
@@ -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"},
})
@@ -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 {
@@ -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/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 7c37901..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)
}
}
@@ -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"},
})
`, "")
@@ -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 b9228c7..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.run/weave label and the weft.run/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..7b66f95 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,7 +166,9 @@ func newHarness(t *testing.T, tune func(*Options)) *harness {
events := record.NewFakeRecorder(256)
r := &WeaveReconciler{
+ Served: ServedV1,
Client: testK8s,
+ APIReader: testK8s,
Scheme: testK8s.Scheme(),
Factory: factory,
Evaluator: eval.NewStarlark(eval.Options{}),
@@ -161,9 +178,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 +225,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 +282,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 +291,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 +336,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 +668,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 +844,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 9471fe8..5bd3e45 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,13 +113,26 @@ 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.
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
@@ -131,17 +144,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,6 +184,11 @@ 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
@@ -171,28 +198,29 @@ func (r *WeaveReconciler) SetupWithManager(mgr ctrl.Manager) error {
// 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 +228,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 +243,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 +252,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 +271,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 +293,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 +311,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 +333,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 +366,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 +393,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 +446,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,
@@ -455,6 +481,15 @@ func (r *WeaveReconciler) reconcileDeletion(ctx context.Context, weave *v1alpha1
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
@@ -520,27 +555,114 @@ 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
}
+// 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 {
+ 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 +670,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 +731,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 +751,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 7a714cd..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.run/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 bf58720..1b15298 100644
--- a/internal/naming/naming.go
+++ b/internal/naming/naming.go
@@ -1,130 +1,209 @@
// 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.
+//
+// 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.run"
- // 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}
)
+// 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 {
+ 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},
}
}