diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f57e80e..5c56b79 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,3 @@ # Per-component ownership; add a line whenever a component is added. /dependency-controller/ @opendefensecloud/cat +/quota-controller/ @opendefensecloud/cat diff --git a/CLAUDE.md b/CLAUDE.md index 145103d..482147d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,7 +145,7 @@ Measured against v1 0.42.0 and v2 0.15.0. Do not rediscover these: **conventional commits are enforced** (commitlint + PR title check). See `CONTRIBUTING.md`. - Renovate maintains the wrapped versions through jsonata managers over - `component-constructor.yaml`; do not add `# renovate:` annotations. Four things + `component-constructor.yaml`; do not add `# renovate:` annotations. Six things differ from the shared config and are load-bearing: - a manager for charts published as `ociArtifact` — the ocm-components one only matches `access.type: helm` and would never bump ours; @@ -156,6 +156,15 @@ Measured against v1 0.42.0 and v2 0.15.0. Do not rediscover these: - `allowedVersions` restricting constructors to unprefixed versions — the image repositories publish both `0.5.0` and `v0.5.0`, the chart repository only the former, so without it a component mixes both formats; + - `groupName`/`groupSlug` set to `{{parentDir}}` — one PR per component. The + shared preset puts every minor update into a single branch, which would + publish two components off one merge. **Both** keys are required: the preset + pins `groupSlug`, and `groupName` alone does not override it — the branch + then comes out named after the literal, un-expanded template; + - `minimumReleaseAge: null` for constructors — the docker datasource builds its + release list from the registry tag list, which carries no dates, so no + release ever gets a `releaseTimestamp` and the window can never be satisfied. + Updates sit at `pendingChecks: true` indefinitely. ## Not yet adopted diff --git a/README.md b/README.md index 7c62b8f..6fd28d8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ from their own source repositories. | Component | Wraps | Docs | | --- | --- | --- | | `opendefense.cloud/dependency-controller` | controller + webhook images, Helm chart | [README](dependency-controller/README.md) | +| `opendefense.cloud/quota-controller` | controller + webhook images, Helm chart | [README](quota-controller/README.md) | ## Layout diff --git a/quota-controller/README.md b/quota-controller/README.md new file mode 100644 index 0000000..d0bb7da --- /dev/null +++ b/quota-controller/README.md @@ -0,0 +1,86 @@ +# quota-controller + +Per-workspace consumption quotas for kcp, enforced through the +`quota-provider` APIExport and an admission webhook. + +- **License**: Apache 2.0 — of the upstream project, not of this packaging +- **Source**: [opendefensecloud/quota-controller](https://github.com/opendefensecloud/quota-controller) +- **Configurations**: + - Minimal (single replica each, self-managed webhook TLS, dev/test) + - Production (2 controller / 3 webhook replicas, cert-manager TLS, anti-affinity) + +## Contents + +| Resource | Type | Wraps | +| --- | --- | --- | +| `quota-controller-chart` | `helmChart` | `ghcr.io/opendefensecloud/charts/quota-controller` | +| `quota-controller-image` | `ociImage` | `ghcr.io/opendefensecloud/quota-controller` | +| `quota-webhook-image` | `ociImage` | `ghcr.io/opendefensecloud/quota-webhook` | +| `quota-controller-minimal-config` | `yaml` | `minimal-values.yaml` | +| `quota-controller-production-config` | `yaml` | `production-values.yaml` | + +The images and chart are built and published by the source repository; this +directory only packages them as an OCM component. + +## Quick Start + +The webhook sits in the admission path and will not start without TLS, and the +controller mounts `ca.crt` from the *same* Secret, so the minimal config needs +a Secret holding `tls.crt`, `tls.key` and `ca.crt`: + +```bash +kubectl create namespace quota-system + +kubectl -n quota-system create secret generic quota-webhook-tls \ + --from-file=tls.crt --from-file=tls.key --from-file=ca.crt + +# The pinned version lives in component-constructor.yaml (make version). +helm install quota-controller \ + oci://ghcr.io/opendefensecloud/charts/quota-controller \ + --namespace quota-system \ + --values quota-controller/minimal-values.yaml \ + --set webhook.tls.existingSecret=quota-webhook-tls +``` + +For production use cert-manager instead — see *Configuration notes*. + +## Configuration notes + +The webhook sits in the admission path: if it is unavailable, writes to every +quota-guarded resource are blocked. Production therefore runs **3 webhook +replicas** so a rolling update or node drain cannot take it offline. + +**cert-manager needs no ClusterIssuer here.** With +`webhook.tls.certManager.enabled` the chart bootstraps its own chain — a +selfSigned `Issuer`, a CA `Certificate`, a CA `Issuer`, then the serving +`Certificate` — so only cert-manager itself has to be installed. This differs +from `dependency-controller`, which expects an existing ClusterIssuer. + +`existingSecret` and `certManager.enabled` are mutually exclusive: setting the +former suppresses the whole cert-manager chain. + +There is no top-level `image` key. Repository and tag live under +`controller.image.*` and `webhook.image.*`, and are rewritten to the localized +references by `values.yaml.tpl` after a transfer. + +`controller.reservationTTL` and `webhook.reservationTTL` must match, and +`controller.resyncInterval` must not exceed the TTL, or the accounting sweep +reclaims slots the webhook still considers held. + +Both `kcp.workspace` and `apiExportName` must match the workspace the chart is +installed into; the kubeconfig's current context has to point there already. +When the controller runs outside kcp, set `controller.kubeconfig.secretName` +and `webhook.kubeconfig.secretName`. + +## Build + +```bash +make validate COMPONENT=quota-controller # publishes nothing +make airgap COMPONENT=quota-controller # self-contained CTF bundle +``` + +## Release + +Automatic. The component version is the chart version, so a Renovate bump +merged to `main` publishes and signs it. Nothing to tag by hand — see +[Releasing](../README.md#releasing). diff --git a/quota-controller/component-constructor.yaml b/quota-controller/component-constructor.yaml new file mode 100644 index 0000000..5bf4e9a --- /dev/null +++ b/quota-controller/component-constructor.yaml @@ -0,0 +1,66 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/open-component-model/open-component-model/main/bindings/go/constructor/spec/v1/resources/schema-2020-12.json +components: + - name: opendefense.cloud/quota-controller + # v2 has no --version flag; the value comes from the environment. An unset + # variable expands to "" silently, so the Makefile guards it. + version: ${OCM_VERSION} + provider: + name: opendefense.cloud + labels: + - name: app.kubernetes.io/name + value: quota-controller + - name: app.kubernetes.io/component + value: quota-management + - name: app.kubernetes.io/part-of + value: quota-controller + resources: + # Helm Chart + - name: quota-controller-chart + type: helmChart + version: "0.1.0" + relation: external + access: + type: ociArtifact + imageReference: ghcr.io/opendefensecloud/charts/quota-controller:0.1.0 + # Values template, rendered by ocm-kit: the label links it to the chart, + # and the image references it emits are the localized ones after transfer. + - name: quota-controller-chart-values-template + type: yaml + labels: + - name: opendefense.cloud/helm/values-for + value: quota-controller-chart + relation: local + input: + type: file + path: values.yaml.tpl + + # Controller Image + - name: quota-controller-image + type: ociImage + version: "0.1.0" + relation: external + access: + type: ociArtifact + imageReference: ghcr.io/opendefensecloud/quota-controller:0.1.0 + # Admission Webhook Image + - name: quota-webhook-image + type: ociImage + version: "0.1.0" + relation: external + access: + type: ociArtifact + imageReference: ghcr.io/opendefensecloud/quota-webhook:0.1.0 + # Minimal Configuration (single instance, development/testing) + - name: quota-controller-minimal-config + type: yaml + relation: local + input: + type: file + path: minimal-values.yaml + # Production Configuration (HA, cert-manager TLS, anti-affinity) + - name: quota-controller-production-config + type: yaml + relation: local + input: + type: file + path: production-values.yaml diff --git a/quota-controller/minimal-values.yaml b/quota-controller/minimal-values.yaml new file mode 100644 index 0000000..2bf4aa6 --- /dev/null +++ b/quota-controller/minimal-values.yaml @@ -0,0 +1,73 @@ +# Minimal quota-controller Configuration +# Deploys a single-instance setup suitable for: +# - Development environments +# - Testing and evaluation +# - Resource-constrained environments +# - Non-HA scenarios +# +# TLS for the admission webhook uses a self-managed secret rather than +# cert-manager, so this config has no external dependencies. + +apiExportName: quota-provider + +kcp: + workspace: root:cloud-api-system + +controller: + replicas: 1 + + image: + pullPolicy: IfNotPresent + + serviceAccount: + create: true + name: quota-controller + + leaderElect: true + + # Must not exceed reservationTTL, and must match webhook.reservationTTL. + reservationTTL: 60s + resyncInterval: 60s + + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 100m + memory: 64Mi + +webhook: + replicas: 1 + + image: + pullPolicy: IfNotPresent + + port: 9443 + tlsCertDir: /etc/webhook-tls + + serviceAccount: + create: true + name: quota-webhook + + service: + type: ClusterIP + port: 443 + + tls: + # Supply a Secret with tls.crt, tls.key and ca.crt. Set this before + # installing — the webhook mounts it for its serving certificate and the + # controller mounts ca.crt from the same Secret. + existingSecret: "" + certManager: + enabled: false + + reservationTTL: 60s + + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 100m + memory: 64Mi diff --git a/quota-controller/production-values.yaml b/quota-controller/production-values.yaml new file mode 100644 index 0000000..b8b2c4d --- /dev/null +++ b/quota-controller/production-values.yaml @@ -0,0 +1,99 @@ +# Production quota-controller Configuration +# High-availability setup: +# - Controller: 2 replicas (leader election keeps one active) +# - Webhook: 3 replicas — it sits in the admission path, so an outage +# blocks writes to every quota-guarded resource +# - TLS issued and rotated by cert-manager +# - Pod anti-affinity spreads replicas across nodes +# - Headroom on limits for larger workspace counts +# +# REQUIRES cert-manager. No ClusterIssuer is needed: the chart bootstraps its +# own selfSigned -> CA -> serving certificate chain. + +apiExportName: quota-provider + +kcp: + workspace: root:cloud-api-system + +controller: + replicas: 2 + + image: + pullPolicy: IfNotPresent + + serviceAccount: + create: true + name: quota-controller + + leaderElect: true + + # Must not exceed reservationTTL, and must match webhook.reservationTTL. + reservationTTL: 60s + resyncInterval: 60s + + resources: + limits: + cpu: "1" + memory: 512Mi + requests: + cpu: 200m + memory: 128Mi + + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + # Both deployments carry the chart name; only the component + # label distinguishes controller pods from webhook pods. + app.kubernetes.io/name: quota-controller + app.kubernetes.io/component: controller + +webhook: + # Admission webhooks are in the critical path: fewer replicas than 3 risks + # rejecting writes during a rolling update or node drain. + replicas: 3 + + image: + pullPolicy: IfNotPresent + + port: 9443 + tlsCertDir: /etc/webhook-tls + + serviceAccount: + create: true + name: quota-webhook + + service: + type: ClusterIP + port: 443 + + tls: + existingSecret: "" + certManager: + enabled: true + additionalDnsNames: [] + + reservationTTL: 60s + + resources: + limits: + cpu: "1" + memory: 512Mi + requests: + cpu: 200m + memory: 128Mi + + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app.kubernetes.io/name: quota-controller + app.kubernetes.io/component: webhook diff --git a/quota-controller/values.yaml.tpl b/quota-controller/values.yaml.tpl new file mode 100644 index 0000000..1d1c266 --- /dev/null +++ b/quota-controller/values.yaml.tpl @@ -0,0 +1,11 @@ +{{- $controller := index .OCIResources "quota-controller-image" }} +controller: + image: + repository: "{{ $controller.Host }}/{{ $controller.Repository }}" + tag: "{{ $controller.Tag }}" + +{{- $webhook := index .OCIResources "quota-webhook-image" }} +webhook: + image: + repository: "{{ $webhook.Host }}/{{ $webhook.Repository }}" + tag: "{{ $webhook.Tag }}" diff --git a/renovate.json b/renovate.json index d1ef1ac..958dd70 100644 --- a/renovate.json +++ b/renovate.json @@ -73,6 +73,14 @@ "matchFileNames": [ "**/component-constructor.yaml" ] + }, + { + "description": "One PR per component. A component's version is derived from its chart, so its chart and both images have to move together — but two different components must never share a PR, or one merge publishes two releases. groupSlug has to be set alongside groupName: the shared preset pins it to all-minor-updates, and groupName alone does not override it — measured, the branch then comes out named after the literal, un-expanded template.", + "matchFileNames": [ + "**/component-constructor.yaml" + ], + "groupName": "{{parentDir}}", + "groupSlug": "{{parentDir}}" } ] }