Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# Per-component ownership; add a line whenever a component is added.
/dependency-controller/ @opendefensecloud/cat
/quota-controller/ @opendefensecloud/cat
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
86 changes: 86 additions & 0 deletions quota-controller/README.md
Original file line number Diff line number Diff line change
@@ -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).
66 changes: 66 additions & 0 deletions quota-controller/component-constructor.yaml
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions quota-controller/minimal-values.yaml
Original file line number Diff line number Diff line change
@@ -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
99 changes: 99 additions & 0 deletions quota-controller/production-values.yaml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions quota-controller/values.yaml.tpl
Original file line number Diff line number Diff line change
@@ -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 }}"
8 changes: 8 additions & 0 deletions renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
}
]
}