From d3fb5dcd1ae652350523e4390f6180942ec5b779 Mon Sep 17 00:00:00 2001 From: unkinben Date: Sun, 2 Aug 2026 17:19:53 +1000 Subject: [PATCH 1/2] Scaffold kea-operator: CRDs, controllers, config rendering, REST API, CI Replace the ISC dhcpd PXE-boot VM with a Kea DHCP Kubernetes operator, modelled on bind-operator. The operator renders kea-dhcp4 config from CRs and runs an HA pair of kea-dhcp4 + kea-ctrl-agent servers behind an anycast Service. - add KeaCluster/KeaSubnet/KeaClientClass/KeaAPI CRDs (group kea.unkin.net) - render deterministic kea-dhcp4.conf + kea-ctrl-agent.conf into a ConfigMap and roll the StatefulSet via a config-hash annotation; best-effort hot-reload via the kea-ctrl-agent REST channel - run HA hot-standby (memfile leases) with stable per-peer DNS identity from a StatefulSet; expose an anycast LoadBalancer Service for PureLB - represent the full legacy dhcpd config: 198.18.13-17.0/24 pools, pool-less 198.18.25.0/24, and the Legacy/UEFI-64 PXE arch classes (option 93) - add the KeaAPI-spawned REST service: Terraform-friendly CRUD over subnet and client-class CRs (stable IDs, PUT upsert, 404 drift, bearer-token auth) - add Makefile (patch/minor/major tag targets), distroless operator/api images, an AlmaLinux+EPEL kea workload image, and woodpecker CI with k8s resources + serviceAccountName on every step - unit tests for config rendering, controller reconcile/config-hash, and the API Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT --- .gitignore | 4 + .woodpecker/build.yaml | 37 + .woodpecker/docker.yaml | 73 + .woodpecker/pre-commit.yaml | 19 + .woodpecker/test.yaml | 18 + Dockerfile.api | 12 + Dockerfile.kea | 20 + Dockerfile.operator | 11 + Makefile | 63 + README.md | 86 +- api/v1alpha1/doc.go | 3 + api/v1alpha1/groupversion_info.go | 18 + api/v1alpha1/keaapi_types.go | 99 + api/v1alpha1/keaclientclass_types.go | 82 + api/v1alpha1/keacluster_types.go | 209 ++ api/v1alpha1/keasubnet_types.go | 121 + api/v1alpha1/zz_generated.deepcopy.go | 596 ++++ cmd/api/main.go | 67 + cmd/operator/main.go | 68 + config/crd/bases/kea.unkin.net_keaapis.yaml | 1180 +++++++ .../bases/kea.unkin.net_keaclientclasses.yaml | 185 ++ .../crd/bases/kea.unkin.net_keaclusters.yaml | 1284 ++++++++ .../crd/bases/kea.unkin.net_keasubnets.yaml | 214 ++ config/crd/install.yaml | 2864 +++++++++++++++++ config/rbac/role.yaml | 81 + config/samples/00-cluster.yaml | 30 + config/samples/01-subnets.yaml | 91 + config/samples/02-clientclasses.yaml | 21 + config/samples/03-api.yaml | 21 + go.mod | 68 + go.sum | 198 ++ hack/kind/manifests/namespace.yaml | 5 + hack/kind/manifests/operator-deployment.yaml | 51 + hack/kind/manifests/rbac.yaml | 46 + internal/controller/helpers.go | 104 + internal/controller/keaapi_controller.go | 253 ++ .../controller/keaclientclass_controller.go | 57 + internal/controller/keacluster_controller.go | 396 +++ internal/controller/keacluster_test.go | 167 + internal/controller/keasubnet_controller.go | 72 + internal/controller/setup.go | 37 + internal/kea/agent.go | 51 + internal/kea/client.go | 68 + internal/kea/config.go | 337 ++ internal/kea/config_test.go | 234 ++ internal/kea/consts.go | 47 + internal/keaapi/models.go | 117 + internal/keaapi/server.go | 195 ++ internal/keaapi/server_test.go | 178 + internal/keaapi/store.go | 122 + 50 files changed, 10379 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .woodpecker/build.yaml create mode 100644 .woodpecker/docker.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/test.yaml create mode 100644 Dockerfile.api create mode 100644 Dockerfile.kea create mode 100644 Dockerfile.operator create mode 100644 Makefile create mode 100644 api/v1alpha1/doc.go create mode 100644 api/v1alpha1/groupversion_info.go create mode 100644 api/v1alpha1/keaapi_types.go create mode 100644 api/v1alpha1/keaclientclass_types.go create mode 100644 api/v1alpha1/keacluster_types.go create mode 100644 api/v1alpha1/keasubnet_types.go create mode 100644 api/v1alpha1/zz_generated.deepcopy.go create mode 100644 cmd/api/main.go create mode 100644 cmd/operator/main.go create mode 100644 config/crd/bases/kea.unkin.net_keaapis.yaml create mode 100644 config/crd/bases/kea.unkin.net_keaclientclasses.yaml create mode 100644 config/crd/bases/kea.unkin.net_keaclusters.yaml create mode 100644 config/crd/bases/kea.unkin.net_keasubnets.yaml create mode 100644 config/crd/install.yaml create mode 100644 config/rbac/role.yaml create mode 100644 config/samples/00-cluster.yaml create mode 100644 config/samples/01-subnets.yaml create mode 100644 config/samples/02-clientclasses.yaml create mode 100644 config/samples/03-api.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hack/kind/manifests/namespace.yaml create mode 100644 hack/kind/manifests/operator-deployment.yaml create mode 100644 hack/kind/manifests/rbac.yaml create mode 100644 internal/controller/helpers.go create mode 100644 internal/controller/keaapi_controller.go create mode 100644 internal/controller/keaclientclass_controller.go create mode 100644 internal/controller/keacluster_controller.go create mode 100644 internal/controller/keacluster_test.go create mode 100644 internal/controller/keasubnet_controller.go create mode 100644 internal/controller/setup.go create mode 100644 internal/kea/agent.go create mode 100644 internal/kea/client.go create mode 100644 internal/kea/config.go create mode 100644 internal/kea/config_test.go create mode 100644 internal/kea/consts.go create mode 100644 internal/keaapi/models.go create mode 100644 internal/keaapi/server.go create mode 100644 internal/keaapi/server_test.go create mode 100644 internal/keaapi/store.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..467f51d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/bin/ +*.out +*.test +.env diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..d9a5652 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,37 @@ +when: + - event: pull_request + +steps: + - name: build-operator + image: woodpeckerci/plugin-docker-buildx + backend_options: + kubernetes: + serviceAccountName: kea-operator-ci + resources: + requests: + memory: 512Mi + cpu: "1" + limits: + memory: 2Gi + cpu: "2" + settings: + repo: git.unkin.net/unkin/kea-operator + dockerfile: Dockerfile.operator + dry_run: true + + - name: build-api + image: woodpeckerci/plugin-docker-buildx + backend_options: + kubernetes: + serviceAccountName: kea-operator-ci + resources: + requests: + memory: 512Mi + cpu: "1" + limits: + memory: 2Gi + cpu: "2" + settings: + repo: git.unkin.net/unkin/kea-api + dockerfile: Dockerfile.api + dry_run: true diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml new file mode 100644 index 0000000..32d8864 --- /dev/null +++ b/.woodpecker/docker.yaml @@ -0,0 +1,73 @@ +when: + - event: tag + ref: refs/tags/v* + +steps: + - name: publish-operator + image: woodpeckerci/plugin-docker-buildx + backend_options: + kubernetes: + serviceAccountName: kea-operator-ci + resources: + requests: + memory: 512Mi + cpu: "1" + limits: + memory: 2Gi + cpu: "2" + settings: + registry: git.unkin.net + repo: git.unkin.net/unkin/kea-operator + dockerfile: Dockerfile.operator + username: droneci + password: + from_secret: DRONECI_PASSWORD + tags: + - ${CI_COMMIT_TAG} + - latest + + - name: publish-api + image: woodpeckerci/plugin-docker-buildx + backend_options: + kubernetes: + serviceAccountName: kea-operator-ci + resources: + requests: + memory: 512Mi + cpu: "1" + limits: + memory: 2Gi + cpu: "2" + settings: + registry: git.unkin.net + repo: git.unkin.net/unkin/kea-api + dockerfile: Dockerfile.api + username: droneci + password: + from_secret: DRONECI_PASSWORD + tags: + - ${CI_COMMIT_TAG} + - latest + + - name: publish-kea + image: woodpeckerci/plugin-docker-buildx + backend_options: + kubernetes: + serviceAccountName: kea-operator-ci + resources: + requests: + memory: 1Gi + cpu: "1" + limits: + memory: 2Gi + cpu: "2" + settings: + registry: git.unkin.net + repo: git.unkin.net/unkin/kea + dockerfile: Dockerfile.kea + username: droneci + password: + from_secret: DRONECI_PASSWORD + tags: + - ${CI_COMMIT_TAG} + - latest diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..bc12ad7 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,19 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: golang:1.25 + backend_options: + kubernetes: + serviceAccountName: kea-operator-ci + resources: + requests: + memory: 512Mi + cpu: "1" + limits: + memory: 2Gi + cpu: "2" + commands: + - test -z "$(gofmt -l .)" + - go vet ./... diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..38efe31 --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: test + image: golang:1.25 + backend_options: + kubernetes: + serviceAccountName: kea-operator-ci + resources: + requests: + memory: 512Mi + cpu: "1" + limits: + memory: 2Gi + cpu: "2" + commands: + - go test -race -count=1 ./api/... ./internal/... diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..1337507 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,12 @@ +FROM golang:1.25-alpine AS build +RUN apk add --no-cache git +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o kea-api ./cmd/api + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /src/kea-api /usr/local/bin/kea-api +EXPOSE 8080 +ENTRYPOINT ["kea-api"] diff --git a/Dockerfile.kea b/Dockerfile.kea new file mode 100644 index 0000000..d22717d --- /dev/null +++ b/Dockerfile.kea @@ -0,0 +1,20 @@ +# Kea DHCPv4 workload image (kea-dhcp4 + kea-ctrl-agent + HA/lease hooks). +# ISC publishes no official Kea container, so we build our own from AlmaLinux, +# which is already reachable through the artifactapi dockerhub remote +# (^library/almalinux). The kea packages come from EPEL; the HA hook +# (libdhcp_ha.so) and lease_cmds hook ship open-source since Kea 2.4. +# +# NOTE: this build needs EPEL package access. If the CI build network cannot +# reach EPEL mirrors, add an artifactapi rpm remote for EPEL (see repo README +# follow-ups) and point dnf at it. +FROM almalinux:9 + +RUN dnf -y install epel-release \ + && dnf -y install kea kea-hooks \ + && dnf clean all \ + && rm -rf /var/cache/dnf \ + && mkdir -p /run/kea + +EXPOSE 67/udp 8000/tcp +# Command is supplied by the operator (per-container entrypoint scripts). +CMD ["/usr/sbin/kea-dhcp4", "-c", "/etc/kea/kea-dhcp4.conf"] diff --git a/Dockerfile.operator b/Dockerfile.operator new file mode 100644 index 0000000..61c914b --- /dev/null +++ b/Dockerfile.operator @@ -0,0 +1,11 @@ +FROM golang:1.25-alpine AS build +RUN apk add --no-cache git +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o kea-operator ./cmd/operator + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /src/kea-operator /usr/local/bin/kea-operator +ENTRYPOINT ["kea-operator"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e635a59 --- /dev/null +++ b/Makefile @@ -0,0 +1,63 @@ +.PHONY: build build-api test lint fmt generate manifests docker-operator docker-api docker-kea clean tidy patch minor major + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") +BINARY_OP := bin/kea-operator +BINARY_API := bin/kea-api +CRD_BUNDLE := config/crd/install.yaml + +build: tidy + go build -ldflags="-s -w" -o $(BINARY_OP) ./cmd/operator + +build-api: tidy + go build -ldflags="-s -w" -o $(BINARY_API) ./cmd/api + +test: + go test -race -count=1 ./api/... ./internal/... + +lint: + go vet ./... + +fmt: + gofmt -w . + +generate: + controller-gen object paths="./api/..." + controller-gen crd paths="./api/..." output:crd:artifacts:config=config/crd/bases + controller-gen rbac:roleName=kea-operator paths="./internal/controller/..." output:rbac:dir=config/rbac + printf '# Generated by "make generate". DO NOT EDIT.\n' > $(CRD_BUNDLE) + cat config/crd/bases/*.yaml >> $(CRD_BUNDLE) + +manifests: generate + +docker-operator: + docker build -t kea-operator:$(VERSION) -f Dockerfile.operator . + +docker-api: + docker build -t kea-api:$(VERSION) -f Dockerfile.api . + +docker-kea: + docker build -t kea:$(VERSION) -f Dockerfile.kea . + +clean: + rm -rf bin/ + +tidy: + go mod tidy + +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW diff --git a/README.md b/README.md index f675b55..d96dd6f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,87 @@ # kea-operator -Kubernetes operator for managing Kea DHCP clusters, subnets, and PXE client classes \ No newline at end of file +A Kubernetes operator that runs [ISC Kea](https://www.isc.org/kea/) DHCPv4 +servers to replace the legacy ISC `dhcpd` VM used for PXE-booting physical +hosts. Modelled on the sibling `bind-operator`. + +Namespace: `dhcp-system`. API group: `kea.unkin.net/v1alpha1`. + +## Custom resources + +| Kind | Purpose | +|------|---------| +| **KeaCluster** | Spawns a StatefulSet of `kea-dhcp4` + `kea-ctrl-agent` pods, an HA control channel, and an anycast DHCP `Service`. Holds global config (domain, lease times, NTP, PXE option-defs). | +| **KeaSubnet** | One DHCPv4 subnet referenced to a KeaCluster (`clusterRef`). CIDR, optional pools, routers, DNS, domain, next-server. Pool-less subnets are declared so relayed requests are still serviced. | +| **KeaClientClass** | PXE boot class matched on client architecture (option 93), e.g. `Legacy` → `/undionly.kpxe`, `UEFI-64` → `/ipxe.efi`. | +| **KeaAPI** | Spawns the Terraform-friendly REST API service (see below). | + +The **KeaCluster** controller lists the matching subnets and client classes, +renders a deterministic `kea-dhcp4.conf` (+ `kea-ctrl-agent.conf`) into a +ConfigMap, and rolls the StatefulSet via a config-hash annotation stamped on the +pod template (identical trick to bind-operator). Ready pods are additionally +hot-reloaded best-effort via the kea-ctrl-agent REST control channel +(`config-reload`), analogous to `rndc reconfig`. + +## HA design + +**Mode: hot-standby, memfile lease DB (non-persistent).** PXE leases are +ephemeral, low-volume, and drawn from tiny pools (`.200–.220`). Hot-standby +keeps a single active primary answering all DHCP with a warm secondary that +syncs leases over the HA control channel and auto-promotes on primary failure — +avoiding the split-pool double-allocation two uncoordinated servers would cause. +Load-balancing mode is also selectable via `spec.ha.mode`. + +Kea HA needs a **stable per-peer identity** (each server must know which peer it +is, and peers reference each other by stable URL). That is exactly why this +operator (like bind-operator) uses a **StatefulSet** rather than a bare +Deployment: pods get stable ordinals (`-0`, `-1`) and headless +DNS, the entrypoint derives `this-server-name` from the ordinal, and the peer +URLs are DNS names (never pod IPs, so the config hash never loops). + +### Anycast routing caveat (deployment follow-up) + +The DHCP `Service` is a `LoadBalancer` intended to receive a PureLB anycast IP; +routers relay unicast to it. Under hot-standby the standby does not answer while +the primary is up, so the anycast VIP should be pinned to the active peer. Pin +it operationally (PureLB local traffic policy / active-peer endpoint selection) +before production cutover. See follow-ups. + +## REST API (KeaAPI) + +A separate binary (`cmd/api`) spawned by the `KeaAPI` CRD. It is a Terraform- +friendly CRUD facade over the `KeaSubnet` / `KeaClientClass` CRs — an +alternative to argocd-managed CRs. Contract mirrors `encapi`: + +- `PUT/GET/DELETE /api/v1/subnets/{name}` and `.../clientclasses/{name}`, plus + list endpoints. Stable client-supplied IDs (the CR name, from the URL path). +- `PUT` is an idempotent upsert returning the canonical object (200); `DELETE` + returns 204; `GET` on a missing resource returns 404 (drives provider drift + handling). Error bodies are `{"error": "..."}`. +- Auth: bearer token (constant-time compare, fail-closed) from `KEA_API_TOKEN`, + sourced from a k8s Secret the operator generates if absent (or pre-seeds). + +The Terraform provider is a **separate queued task**; the API is designed so +wrapping it is trivial (full-object PUT, canonical GET, 404 semantics). + +## Build & release + +- `make test` — `go test -race` over `./api/... ./internal/...`. +- `make generate` — regenerates deepcopy, CRDs, RBAC, and the `install.yaml` + bundle (all committed). +- `make patch|minor|major` — tags `vX.Y.Z` and pushes; the tag triggers the + `.woodpecker/docker.yaml` image builds. + +Images: `kea-operator` and `kea-api` are distroless Go binaries. The Kea +workload image (`Dockerfile.kea`) is built from AlmaLinux (reachable via the +artifactapi dockerhub remote) + EPEL kea packages. + +## Follow-ups (not in this repo yet) + +- **argocd-apps**: a `kea-operator-ci` ServiceAccount for the woodpecker CI + steps, and the operator deployment manifests. +- **artifactapi**: the Kea image build pulls kea RPMs from EPEL; if the CI build + network cannot reach EPEL, add an artifactapi rpm remote (or vendor kea via + rpmbuilder) and point `Dockerfile.kea` at it. +- **terraform-provider-kea**: wrap the KeaAPI REST contract. +- **Vault**: issue ephemeral API bearer tokens instead of a static k8s Secret. +- **Anycast**: pin the anycast VIP to the active HA peer (PureLB tuning). diff --git a/api/v1alpha1/doc.go b/api/v1alpha1/doc.go new file mode 100644 index 0000000..405cdee --- /dev/null +++ b/api/v1alpha1/doc.go @@ -0,0 +1,3 @@ +// +kubebuilder:object:generate=true +// +groupName=kea.unkin.net +package v1alpha1 diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..fd62af1 --- /dev/null +++ b/api/v1alpha1/groupversion_info.go @@ -0,0 +1,18 @@ +// Package v1alpha1 contains the kea.unkin.net API types. +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is the group/version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "kea.unkin.net", Version: "v1alpha1"} + + // SchemeBuilder registers the kea.unkin.net types with a scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the kea.unkin.net types to a scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1alpha1/keaapi_types.go b/api/v1alpha1/keaapi_types.go new file mode 100644 index 0000000..0492af0 --- /dev/null +++ b/api/v1alpha1/keaapi_types.go @@ -0,0 +1,99 @@ +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// KeaAPISpec defines the REST API service that manages KeaSubnet and +// KeaClientClass CRs (a Terraform-friendly alternative to argocd-managed CRs). +type KeaAPISpec struct { + // Replicas of the API server. + // +kubebuilder:default=1 + // +kubebuilder:validation:Minimum=1 + // +optional + Replicas *int32 `json:"replicas,omitempty"` + + // Image is the kea-api container image. + // +optional + Image string `json:"image,omitempty"` + + // TokenSecretName is a Secret holding the bearer token under key "token". + // If the Secret does not exist the operator creates one with a random + // token (so it may instead be pre-seeded, e.g. by a Vault static secret). + // +optional + TokenSecretName string `json:"tokenSecretName,omitempty"` + + // Service configures how the API is exposed. + // +optional + Service KeaAPIServiceSpec `json:"service,omitempty"` + + // Resources for the API container. + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // +optional + NodeSelector map[string]string `json:"nodeSelector,omitempty"` + // +optional + Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + // +optional + Affinity *corev1.Affinity `json:"affinity,omitempty"` +} + +// KeaAPIServiceSpec configures the API Service. +type KeaAPIServiceSpec struct { + // +kubebuilder:default=ClusterIP + // +optional + Type corev1.ServiceType `json:"type,omitempty"` + // +kubebuilder:default=8080 + // +optional + Port int32 `json:"port,omitempty"` + // +optional + Annotations map[string]string `json:"annotations,omitempty"` +} + +// KeaAPIStatus captures observed state. +type KeaAPIStatus struct { + // +optional + Phase string `json:"phase,omitempty"` + // Endpoint is the in-cluster base URL of the API. + // +optional + Endpoint string `json:"endpoint,omitempty"` + // +optional + ReadyReplicas int32 `json:"readyReplicas,omitempty"` + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=kapi +// +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.status.endpoint` +// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` + +// KeaAPI spawns the REST API service Deployment. +type KeaAPI struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec KeaAPISpec `json:"spec,omitempty"` + Status KeaAPIStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// KeaAPIList contains a list of KeaAPI. +type KeaAPIList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []KeaAPI `json:"items"` +} + +func init() { + SchemeBuilder.Register(&KeaAPI{}, &KeaAPIList{}) +} diff --git a/api/v1alpha1/keaclientclass_types.go b/api/v1alpha1/keaclientclass_types.go new file mode 100644 index 0000000..2d9e3ce --- /dev/null +++ b/api/v1alpha1/keaclientclass_types.go @@ -0,0 +1,82 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// KeaClientClassSpec defines a PXE boot client-class, typically matching the +// DHCP client architecture option (code 93). +type KeaClientClassSpec struct { + // ClusterRef selects the owning KeaCluster by name. Empty means every + // KeaCluster in the namespace. + // +optional + ClusterRef string `json:"clusterRef,omitempty"` + + // Test is a raw Kea class-match expression. When empty it is generated + // from ArchHex. + // +optional + Test string `json:"test,omitempty"` + + // ArchHex is a convenience list of client-architecture values (option 93), + // e.g. ["0x0000"] or ["0x0007","0x0009"]. Rendered into a Test expression + // of the form: option[93].hex == 0x0007 or option[93].hex == 0x0009. + // +optional + ArchHex []string `json:"archHex,omitempty"` + + // BootFileName handed to matching clients (option 67 / boot-file-name). + // +optional + BootFileName string `json:"bootFileName,omitempty"` + + // NextServer overrides siaddr for matching clients. + // +optional + NextServer string `json:"nextServer,omitempty"` + + // ServerHostname (sname) for matching clients. + // +optional + ServerHostname string `json:"serverHostname,omitempty"` + + // OptionData carries additional options set for matching clients. + // +optional + OptionData []OptionData `json:"optionData,omitempty"` +} + +// KeaClientClassStatus captures observed state. +type KeaClientClassStatus struct { + // +optional + Phase string `json:"phase,omitempty"` + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=kcc +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef` +// +kubebuilder:printcolumn:name="BootFile",type=string,JSONPath=`.spec.bootFileName` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` + +// KeaClientClass is a PXE boot class matched on the client architecture. +type KeaClientClass struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec KeaClientClassSpec `json:"spec,omitempty"` + Status KeaClientClassStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// KeaClientClassList contains a list of KeaClientClass. +type KeaClientClassList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []KeaClientClass `json:"items"` +} + +func init() { + SchemeBuilder.Register(&KeaClientClass{}, &KeaClientClassList{}) +} diff --git a/api/v1alpha1/keacluster_types.go b/api/v1alpha1/keacluster_types.go new file mode 100644 index 0000000..932ccc0 --- /dev/null +++ b/api/v1alpha1/keacluster_types.go @@ -0,0 +1,209 @@ +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// HAMode selects the Kea High Availability hook operating mode. +// +kubebuilder:validation:Enum=hot-standby;load-balancing +type HAMode string + +const ( + // HAHotStandby runs one active (primary) server; the standby only serves + // when the primary is detected down. Preferred for PXE-only, low-volume, + // ephemeral leases where split-pool double allocation must be avoided. + HAHotStandby HAMode = "hot-standby" + // HALoadBalancing splits clients across both peers by a hash. + HALoadBalancing HAMode = "load-balancing" +) + +// HASpec configures the libdhcp_ha hook shared between the cluster replicas. +type HASpec struct { + // Mode is the HA operating mode. + // +kubebuilder:default=hot-standby + // +optional + Mode HAMode `json:"mode,omitempty"` + + // HeartbeatDelay is the interval (ms) between HA heartbeats. + // +kubebuilder:default=10000 + // +kubebuilder:validation:Minimum=1000 + // +optional + HeartbeatDelay int `json:"heartbeatDelay,omitempty"` + + // MaxResponseDelay (ms) before a peer is considered unresponsive. + // +kubebuilder:default=60000 + // +kubebuilder:validation:Minimum=1000 + // +optional + MaxResponseDelay int `json:"maxResponseDelay,omitempty"` + + // MaxAckDelay (ms) a client may wait before HA considers it unacked. + // +kubebuilder:default=5000 + // +kubebuilder:validation:Minimum=0 + // +optional + MaxAckDelay int `json:"maxAckDelay,omitempty"` + + // MaxUnackedClients before a partner is declared to be in a failure state. + // +kubebuilder:default=5 + // +kubebuilder:validation:Minimum=0 + // +optional + MaxUnackedClients int `json:"maxUnackedClients,omitempty"` +} + +// OptionDef is a custom DHCPv4 option definition (e.g. the PXE client +// architecture option, code 93). +type OptionDef struct { + Name string `json:"name"` + Code int `json:"code"` + // Type is a Kea option data type, e.g. "uint16", "string", "ipv4-address". + Type string `json:"type"` + // Space defaults to "dhcp4". + // +kubebuilder:default=dhcp4 + // +optional + Space string `json:"space,omitempty"` + // Array marks the option as an array of Type. + // +optional + Array bool `json:"array,omitempty"` + // RecordTypes is a comma-separated list for record-type options. + // +optional + RecordTypes string `json:"recordTypes,omitempty"` + // Encapsulate names an option space this option encapsulates. + // +optional + Encapsulate string `json:"encapsulate,omitempty"` +} + +// ClusterServiceSpec configures the anycast LoadBalancer Service that fronts +// the DHCP replicas. DHCP traffic arrives unicast via router relays to the +// anycast address (typically a PureLB-assigned IP), so no host networking is +// required. +type ClusterServiceSpec struct { + // Type of the DHCP Service. Defaults to LoadBalancer for anycast exposure. + // +kubebuilder:default=LoadBalancer + // +optional + Type corev1.ServiceType `json:"type,omitempty"` + + // LoadBalancerIP requests a specific (anycast) address. + // +optional + LoadBalancerIP string `json:"loadBalancerIP,omitempty"` + + // LoadBalancerClass selects the LB implementation (e.g. purelb.io/purelb). + // +optional + LoadBalancerClass *string `json:"loadBalancerClass,omitempty"` + + // IPAddressPool is a convenience for the PureLB service-group annotation. + // +optional + IPAddressPool string `json:"ipAddressPool,omitempty"` + + // Annotations are merged onto the Service (e.g. PureLB pool selection). + // +optional + Annotations map[string]string `json:"annotations,omitempty"` +} + +// KeaClusterSpec defines the desired state of a KeaCluster. +type KeaClusterSpec struct { + // Replicas is the number of kea-dhcp4 servers. HA is designed around 2. + // +kubebuilder:default=2 + // +kubebuilder:validation:Minimum=1 + // +optional + Replicas *int32 `json:"replicas,omitempty"` + + // Image is the kea-dhcp4 + kea-ctrl-agent container image. + // +optional + Image string `json:"image,omitempty"` + + // DomainName is the global domain-name option handed to clients. + // +optional + DomainName string `json:"domainName,omitempty"` + + // DefaultLeaseTime in seconds (valid-lifetime). + // +kubebuilder:default=1200 + // +kubebuilder:validation:Minimum=1 + // +optional + DefaultLeaseTime int `json:"defaultLeaseTime,omitempty"` + + // MaxLeaseTime in seconds (max-valid-lifetime). + // +kubebuilder:default=86400 + // +kubebuilder:validation:Minimum=1 + // +optional + MaxLeaseTime int `json:"maxLeaseTime,omitempty"` + + // NTPServers are handed to clients as the ntp-servers option. + // +optional + NTPServers []string `json:"ntpServers,omitempty"` + + // OptionDefs are custom option definitions (e.g. PXE client-arch, code 93). + // +optional + OptionDefs []OptionDef `json:"optionDefs,omitempty"` + + // HA configures the libdhcp_ha hook between replicas. + // +optional + HA HASpec `json:"ha,omitempty"` + + // Service configures the anycast LoadBalancer fronting the replicas. + // +optional + Service ClusterServiceSpec `json:"service,omitempty"` + + // Resources for the kea containers. + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // +optional + NodeSelector map[string]string `json:"nodeSelector,omitempty"` + // +optional + Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + // +optional + Affinity *corev1.Affinity `json:"affinity,omitempty"` +} + +// KeaClusterStatus captures the observed state of a KeaCluster. +type KeaClusterStatus struct { + // +optional + Phase string `json:"phase,omitempty"` + // +optional + Replicas int32 `json:"replicas,omitempty"` + // +optional + ReadyReplicas int32 `json:"readyReplicas,omitempty"` + // ActivePeer is the pod currently acting as the HA primary. + // +optional + ActivePeer string `json:"activePeer,omitempty"` + // ServiceIP is the anycast address assigned to the DHCP Service. + // +optional + ServiceIP string `json:"serviceIP,omitempty"` + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=kc +// +kubebuilder:printcolumn:name="Mode",type=string,JSONPath=`.spec.ha.mode` +// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas` +// +kubebuilder:printcolumn:name="ServiceIP",type=string,JSONPath=`.status.serviceIP` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` + +// KeaCluster spawns a StatefulSet of kea-dhcp4 servers with an HA control +// channel and an anycast DHCP Service. +type KeaCluster struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec KeaClusterSpec `json:"spec,omitempty"` + Status KeaClusterStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// KeaClusterList contains a list of KeaCluster. +type KeaClusterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []KeaCluster `json:"items"` +} + +func init() { + SchemeBuilder.Register(&KeaCluster{}, &KeaClusterList{}) +} diff --git a/api/v1alpha1/keasubnet_types.go b/api/v1alpha1/keasubnet_types.go new file mode 100644 index 0000000..e8b9136 --- /dev/null +++ b/api/v1alpha1/keasubnet_types.go @@ -0,0 +1,121 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// OptionData is a rendered DHCPv4 option value (subnet- or class-scoped). +type OptionData struct { + // Name of the option, e.g. "routers", "domain-name-servers". + // +optional + Name string `json:"name,omitempty"` + // Code of the option (alternative to Name). + // +optional + Code int `json:"code,omitempty"` + // Data is the option value(s), comma-separated per Kea convention. + Data string `json:"data"` + // Space defaults to "dhcp4". + // +optional + Space string `json:"space,omitempty"` + // CSVFormat controls whether Data is parsed as CSV (default true in Kea). + // +optional + CSVFormat *bool `json:"csvFormat,omitempty"` +} + +// KeaSubnetSpec defines a single DHCPv4 subnet served by a KeaCluster. +type KeaSubnetSpec struct { + // ClusterRef selects the owning KeaCluster by name. Empty means every + // KeaCluster in the namespace. + // +optional + ClusterRef string `json:"clusterRef,omitempty"` + + // Subnet is the CIDR, e.g. "198.18.13.0/24". + Subnet string `json:"subnet"` + + // ID is the stable Kea subnet id. When zero the operator assigns one + // deterministically from the sorted set of subnets. + // +optional + ID int `json:"id,omitempty"` + + // Pools are dynamic ranges, e.g. "198.18.13.200 - 198.18.13.220". A subnet + // with no pool is still declared so relayed requests on that network are + // serviced (matching, option delivery) without dynamic allocation. + // +optional + Pools []string `json:"pools,omitempty"` + + // Routers is the default-gateway list (routers option). + // +optional + Routers []string `json:"routers,omitempty"` + + // DNSServers is the domain-name-servers option. + // +optional + DNSServers []string `json:"dnsServers,omitempty"` + + // DomainName is the per-subnet domain-name option. + // +optional + DomainName string `json:"domainName,omitempty"` + + // NextServer is the TFTP server address for PXE (siaddr / next-server). + // +optional + NextServer string `json:"nextServer,omitempty"` + + // BootFileName sets the PXE boot file for this subnet (overridden by class). + // +optional + BootFileName string `json:"bootFileName,omitempty"` + + // ClientClasses restricts the subnet to the listed client classes. + // +optional + ClientClasses []string `json:"clientClasses,omitempty"` + + // ValidLifetime overrides the cluster default lease time for this subnet. + // +optional + ValidLifetime int `json:"validLifetime,omitempty"` + + // OptionData carries any additional option values for the subnet. + // +optional + OptionData []OptionData `json:"optionData,omitempty"` +} + +// KeaSubnetStatus captures observed state. +type KeaSubnetStatus struct { + // +optional + Phase string `json:"phase,omitempty"` + // AssignedID is the subnet id that was rendered into kea config. + // +optional + AssignedID int `json:"assignedID,omitempty"` + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=ksn +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef` +// +kubebuilder:printcolumn:name="Subnet",type=string,JSONPath=`.spec.subnet` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` + +// KeaSubnet is one DHCPv4 subnet declaration referenced to a KeaCluster. +type KeaSubnet struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec KeaSubnetSpec `json:"spec,omitempty"` + Status KeaSubnetStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// KeaSubnetList contains a list of KeaSubnet. +type KeaSubnetList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []KeaSubnet `json:"items"` +} + +func init() { + SchemeBuilder.Register(&KeaSubnet{}, &KeaSubnetList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..0cff0eb --- /dev/null +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,596 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterServiceSpec) DeepCopyInto(out *ClusterServiceSpec) { + *out = *in + if in.LoadBalancerClass != nil { + in, out := &in.LoadBalancerClass, &out.LoadBalancerClass + *out = new(string) + **out = **in + } + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterServiceSpec. +func (in *ClusterServiceSpec) DeepCopy() *ClusterServiceSpec { + if in == nil { + return nil + } + out := new(ClusterServiceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HASpec) DeepCopyInto(out *HASpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HASpec. +func (in *HASpec) DeepCopy() *HASpec { + if in == nil { + return nil + } + out := new(HASpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeaAPI) DeepCopyInto(out *KeaAPI) { + *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 KeaAPI. +func (in *KeaAPI) DeepCopy() *KeaAPI { + if in == nil { + return nil + } + out := new(KeaAPI) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KeaAPI) 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 *KeaAPIList) DeepCopyInto(out *KeaAPIList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]KeaAPI, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeaAPIList. +func (in *KeaAPIList) DeepCopy() *KeaAPIList { + if in == nil { + return nil + } + out := new(KeaAPIList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KeaAPIList) 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 *KeaAPIServiceSpec) DeepCopyInto(out *KeaAPIServiceSpec) { + *out = *in + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeaAPIServiceSpec. +func (in *KeaAPIServiceSpec) DeepCopy() *KeaAPIServiceSpec { + if in == nil { + return nil + } + out := new(KeaAPIServiceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeaAPISpec) DeepCopyInto(out *KeaAPISpec) { + *out = *in + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } + in.Service.DeepCopyInto(&out.Service) + in.Resources.DeepCopyInto(&out.Resources) + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Tolerations != nil { + in, out := &in.Tolerations, &out.Tolerations + *out = make([]v1.Toleration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Affinity != nil { + in, out := &in.Affinity, &out.Affinity + *out = new(v1.Affinity) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeaAPISpec. +func (in *KeaAPISpec) DeepCopy() *KeaAPISpec { + if in == nil { + return nil + } + out := new(KeaAPISpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeaAPIStatus) DeepCopyInto(out *KeaAPIStatus) { + *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]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeaAPIStatus. +func (in *KeaAPIStatus) DeepCopy() *KeaAPIStatus { + if in == nil { + return nil + } + out := new(KeaAPIStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeaClientClass) DeepCopyInto(out *KeaClientClass) { + *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 KeaClientClass. +func (in *KeaClientClass) DeepCopy() *KeaClientClass { + if in == nil { + return nil + } + out := new(KeaClientClass) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KeaClientClass) 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 *KeaClientClassList) DeepCopyInto(out *KeaClientClassList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]KeaClientClass, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeaClientClassList. +func (in *KeaClientClassList) DeepCopy() *KeaClientClassList { + if in == nil { + return nil + } + out := new(KeaClientClassList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KeaClientClassList) 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 *KeaClientClassSpec) DeepCopyInto(out *KeaClientClassSpec) { + *out = *in + if in.ArchHex != nil { + in, out := &in.ArchHex, &out.ArchHex + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.OptionData != nil { + in, out := &in.OptionData, &out.OptionData + *out = make([]OptionData, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeaClientClassSpec. +func (in *KeaClientClassSpec) DeepCopy() *KeaClientClassSpec { + if in == nil { + return nil + } + out := new(KeaClientClassSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeaClientClassStatus) DeepCopyInto(out *KeaClientClassStatus) { + *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]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeaClientClassStatus. +func (in *KeaClientClassStatus) DeepCopy() *KeaClientClassStatus { + if in == nil { + return nil + } + out := new(KeaClientClassStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeaCluster) DeepCopyInto(out *KeaCluster) { + *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 KeaCluster. +func (in *KeaCluster) DeepCopy() *KeaCluster { + if in == nil { + return nil + } + out := new(KeaCluster) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KeaCluster) 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 *KeaClusterList) DeepCopyInto(out *KeaClusterList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]KeaCluster, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeaClusterList. +func (in *KeaClusterList) DeepCopy() *KeaClusterList { + if in == nil { + return nil + } + out := new(KeaClusterList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KeaClusterList) 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 *KeaClusterSpec) DeepCopyInto(out *KeaClusterSpec) { + *out = *in + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } + if in.NTPServers != nil { + in, out := &in.NTPServers, &out.NTPServers + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.OptionDefs != nil { + in, out := &in.OptionDefs, &out.OptionDefs + *out = make([]OptionDef, len(*in)) + copy(*out, *in) + } + out.HA = in.HA + in.Service.DeepCopyInto(&out.Service) + in.Resources.DeepCopyInto(&out.Resources) + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Tolerations != nil { + in, out := &in.Tolerations, &out.Tolerations + *out = make([]v1.Toleration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Affinity != nil { + in, out := &in.Affinity, &out.Affinity + *out = new(v1.Affinity) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeaClusterSpec. +func (in *KeaClusterSpec) DeepCopy() *KeaClusterSpec { + if in == nil { + return nil + } + out := new(KeaClusterSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeaClusterStatus) DeepCopyInto(out *KeaClusterStatus) { + *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]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeaClusterStatus. +func (in *KeaClusterStatus) DeepCopy() *KeaClusterStatus { + if in == nil { + return nil + } + out := new(KeaClusterStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeaSubnet) DeepCopyInto(out *KeaSubnet) { + *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 KeaSubnet. +func (in *KeaSubnet) DeepCopy() *KeaSubnet { + if in == nil { + return nil + } + out := new(KeaSubnet) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KeaSubnet) 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 *KeaSubnetList) DeepCopyInto(out *KeaSubnetList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]KeaSubnet, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeaSubnetList. +func (in *KeaSubnetList) DeepCopy() *KeaSubnetList { + if in == nil { + return nil + } + out := new(KeaSubnetList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KeaSubnetList) 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 *KeaSubnetSpec) DeepCopyInto(out *KeaSubnetSpec) { + *out = *in + if in.Pools != nil { + in, out := &in.Pools, &out.Pools + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Routers != nil { + in, out := &in.Routers, &out.Routers + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.DNSServers != nil { + in, out := &in.DNSServers, &out.DNSServers + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ClientClasses != nil { + in, out := &in.ClientClasses, &out.ClientClasses + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.OptionData != nil { + in, out := &in.OptionData, &out.OptionData + *out = make([]OptionData, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeaSubnetSpec. +func (in *KeaSubnetSpec) DeepCopy() *KeaSubnetSpec { + if in == nil { + return nil + } + out := new(KeaSubnetSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeaSubnetStatus) DeepCopyInto(out *KeaSubnetStatus) { + *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]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeaSubnetStatus. +func (in *KeaSubnetStatus) DeepCopy() *KeaSubnetStatus { + if in == nil { + return nil + } + out := new(KeaSubnetStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OptionData) DeepCopyInto(out *OptionData) { + *out = *in + if in.CSVFormat != nil { + in, out := &in.CSVFormat, &out.CSVFormat + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OptionData. +func (in *OptionData) DeepCopy() *OptionData { + if in == nil { + return nil + } + out := new(OptionData) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OptionDef) DeepCopyInto(out *OptionDef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OptionDef. +func (in *OptionDef) DeepCopy() *OptionDef { + if in == nil { + return nil + } + out := new(OptionDef) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..d73d4a3 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + keav1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" + "git.unkin.net/unkin/kea-operator/internal/keaapi" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(keav1alpha1.AddToScheme(scheme)) +} + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func main() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + logger := zap.New(zap.UseDevMode(false)) + + addr := getenv("LISTEN_ADDR", ":8080") + namespace := getenv("TARGET_NAMESPACE", "dhcp-system") + token := os.Getenv("KEA_API_TOKEN") + if token == "" { + slog.Warn("KEA_API_TOKEN not set; all API endpoints will return 503") + } + + c, err := client.New(ctrl.GetConfigOrDie(), client.Options{Scheme: scheme}) + if err != nil { + slog.Error("build k8s client", "err", err) + os.Exit(1) + } + + srv := &keaapi.Server{ + Store: &keaapi.K8sStore{Client: c, Namespace: namespace}, + Token: token, + Log: logr.Logger(logger), + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + slog.Info("starting kea-api", "addr", addr, "namespace", namespace) + if err := srv.ListenAndServe(ctx, addr); err != nil { + slog.Error("server exited", "err", err) + os.Exit(1) + } +} diff --git a/cmd/operator/main.go b/cmd/operator/main.go new file mode 100644 index 0000000..5d81fe7 --- /dev/null +++ b/cmd/operator/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "flag" + "os" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + keav1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" + "git.unkin.net/unkin/kea-operator/internal/controller" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(keav1alpha1.AddToScheme(scheme)) +} + +func main() { + var metricsAddr, probeAddr string + var leaderElect bool + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "metrics endpoint bind address") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "health probe bind address") + flag.BoolVar(&leaderElect, "leader-elect", false, "enable leader election") + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseDevMode(false))) + log := ctrl.Log.WithName("setup") + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + LeaderElection: leaderElect, + LeaderElectionID: "kea-operator", + }) + if err != nil { + log.Error(err, "unable to create manager") + os.Exit(1) + } + + if err := controller.SetupAll(mgr); err != nil { + log.Error(err, "unable to set up controllers") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + log.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + log.Error(err, "unable to set up ready check") + os.Exit(1) + } + + log.Info("starting kea-operator") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + log.Error(err, "manager exited") + os.Exit(1) + } +} diff --git a/config/crd/bases/kea.unkin.net_keaapis.yaml b/config/crd/bases/kea.unkin.net_keaapis.yaml new file mode 100644 index 0000000..fc43009 --- /dev/null +++ b/config/crd/bases/kea.unkin.net_keaapis.yaml @@ -0,0 +1,1180 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: keaapis.kea.unkin.net +spec: + group: kea.unkin.net + names: + kind: KeaAPI + listKind: KeaAPIList + plural: keaapis + shortNames: + - kapi + singular: keaapi + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.endpoint + name: Endpoint + type: string + - jsonPath: .status.readyReplicas + name: Ready + type: integer + - jsonPath: .status.phase + name: Phase + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: KeaAPI spawns the REST API service Deployment. + 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: |- + KeaAPISpec defines the REST API service that manages KeaSubnet and + KeaClientClass CRs (a Terraform-friendly alternative to argocd-managed CRs). + properties: + affinity: + description: Affinity is a group of affinity scheduling rules. + properties: + nodeAffinity: + description: Describes node affinity scheduling rules for the + pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated with the + corresponding weight. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching the corresponding + nodeSelectorTerm, in the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector terms. + The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules (e.g. co-locate + this pod in the same node, zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling rules (e.g. + avoid putting this pod in the same node, zone, etc. as some + other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and subtracting + "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + image: + description: Image is the kea-api container image. + type: string + nodeSelector: + additionalProperties: + type: string + type: object + replicas: + default: 1 + description: Replicas of the API server. + format: int32 + minimum: 1 + type: integer + resources: + description: Resources for the API container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + service: + description: Service configures how the API is exposed. + properties: + annotations: + additionalProperties: + type: string + type: object + port: + default: 8080 + format: int32 + type: integer + type: + default: ClusterIP + description: Service Type string describes ingress methods for + a service + type: string + type: object + tokenSecretName: + description: |- + TokenSecretName is a Secret holding the bearer token under key "token". + If the Secret does not exist the operator creates one with a random + token (so it may instead be pre-seeded, e.g. by a Vault static secret). + type: string + tolerations: + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + type: object + status: + description: KeaAPIStatus captures observed state. + properties: + conditions: + 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 + endpoint: + description: Endpoint is the in-cluster base URL of the API. + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + readyReplicas: + format: int32 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/kea.unkin.net_keaclientclasses.yaml b/config/crd/bases/kea.unkin.net_keaclientclasses.yaml new file mode 100644 index 0000000..92a302b --- /dev/null +++ b/config/crd/bases/kea.unkin.net_keaclientclasses.yaml @@ -0,0 +1,185 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: keaclientclasses.kea.unkin.net +spec: + group: kea.unkin.net + names: + kind: KeaClientClass + listKind: KeaClientClassList + plural: keaclientclasses + shortNames: + - kcc + singular: keaclientclass + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef + name: Cluster + type: string + - jsonPath: .spec.bootFileName + name: BootFile + type: string + - jsonPath: .status.phase + name: Phase + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: KeaClientClass is a PXE boot class matched on the client architecture. + 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: |- + KeaClientClassSpec defines a PXE boot client-class, typically matching the + DHCP client architecture option (code 93). + properties: + archHex: + description: |- + ArchHex is a convenience list of client-architecture values (option 93), + e.g. ["0x0000"] or ["0x0007","0x0009"]. Rendered into a Test expression + of the form: option[93].hex == 0x0007 or option[93].hex == 0x0009. + items: + type: string + type: array + bootFileName: + description: BootFileName handed to matching clients (option 67 / + boot-file-name). + type: string + clusterRef: + description: |- + ClusterRef selects the owning KeaCluster by name. Empty means every + KeaCluster in the namespace. + type: string + nextServer: + description: NextServer overrides siaddr for matching clients. + type: string + optionData: + description: OptionData carries additional options set for matching + clients. + items: + description: OptionData is a rendered DHCPv4 option value (subnet- + or class-scoped). + properties: + code: + description: Code of the option (alternative to Name). + type: integer + csvFormat: + description: CSVFormat controls whether Data is parsed as CSV + (default true in Kea). + type: boolean + data: + description: Data is the option value(s), comma-separated per + Kea convention. + type: string + name: + description: Name of the option, e.g. "routers", "domain-name-servers". + type: string + space: + description: Space defaults to "dhcp4". + type: string + required: + - data + type: object + type: array + serverHostname: + description: ServerHostname (sname) for matching clients. + type: string + test: + description: |- + Test is a raw Kea class-match expression. When empty it is generated + from ArchHex. + type: string + type: object + status: + description: KeaClientClassStatus captures observed state. + properties: + conditions: + 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 + observedGeneration: + format: int64 + type: integer + phase: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/kea.unkin.net_keaclusters.yaml b/config/crd/bases/kea.unkin.net_keaclusters.yaml new file mode 100644 index 0000000..3c43e31 --- /dev/null +++ b/config/crd/bases/kea.unkin.net_keaclusters.yaml @@ -0,0 +1,1284 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: keaclusters.kea.unkin.net +spec: + group: kea.unkin.net + names: + kind: KeaCluster + listKind: KeaClusterList + plural: keaclusters + shortNames: + - kc + singular: keacluster + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.ha.mode + name: Mode + type: string + - jsonPath: .status.readyReplicas + name: Ready + type: integer + - jsonPath: .status.serviceIP + name: ServiceIP + type: string + - jsonPath: .status.phase + name: Phase + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + KeaCluster spawns a StatefulSet of kea-dhcp4 servers with an HA control + channel and an anycast DHCP Service. + 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: KeaClusterSpec defines the desired state of a KeaCluster. + properties: + affinity: + description: Affinity is a group of affinity scheduling rules. + properties: + nodeAffinity: + description: Describes node affinity scheduling rules for the + pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated with the + corresponding weight. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching the corresponding + nodeSelectorTerm, in the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector terms. + The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules (e.g. co-locate + this pod in the same node, zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling rules (e.g. + avoid putting this pod in the same node, zone, etc. as some + other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and subtracting + "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + defaultLeaseTime: + default: 1200 + description: DefaultLeaseTime in seconds (valid-lifetime). + minimum: 1 + type: integer + domainName: + description: DomainName is the global domain-name option handed to + clients. + type: string + ha: + description: HA configures the libdhcp_ha hook between replicas. + properties: + heartbeatDelay: + default: 10000 + description: HeartbeatDelay is the interval (ms) between HA heartbeats. + minimum: 1000 + type: integer + maxAckDelay: + default: 5000 + description: MaxAckDelay (ms) a client may wait before HA considers + it unacked. + minimum: 0 + type: integer + maxResponseDelay: + default: 60000 + description: MaxResponseDelay (ms) before a peer is considered + unresponsive. + minimum: 1000 + type: integer + maxUnackedClients: + default: 5 + description: MaxUnackedClients before a partner is declared to + be in a failure state. + minimum: 0 + type: integer + mode: + default: hot-standby + description: Mode is the HA operating mode. + enum: + - hot-standby + - load-balancing + type: string + type: object + image: + description: Image is the kea-dhcp4 + kea-ctrl-agent container image. + type: string + maxLeaseTime: + default: 86400 + description: MaxLeaseTime in seconds (max-valid-lifetime). + minimum: 1 + type: integer + nodeSelector: + additionalProperties: + type: string + type: object + ntpServers: + description: NTPServers are handed to clients as the ntp-servers option. + items: + type: string + type: array + optionDefs: + description: OptionDefs are custom option definitions (e.g. PXE client-arch, + code 93). + items: + description: |- + OptionDef is a custom DHCPv4 option definition (e.g. the PXE client + architecture option, code 93). + properties: + array: + description: Array marks the option as an array of Type. + type: boolean + code: + type: integer + encapsulate: + description: Encapsulate names an option space this option encapsulates. + type: string + name: + type: string + recordTypes: + description: RecordTypes is a comma-separated list for record-type + options. + type: string + space: + default: dhcp4 + description: Space defaults to "dhcp4". + type: string + type: + description: Type is a Kea option data type, e.g. "uint16", + "string", "ipv4-address". + type: string + required: + - code + - name + - type + type: object + type: array + replicas: + default: 2 + description: Replicas is the number of kea-dhcp4 servers. HA is designed + around 2. + format: int32 + minimum: 1 + type: integer + resources: + description: Resources for the kea containers. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + service: + description: Service configures the anycast LoadBalancer fronting + the replicas. + properties: + annotations: + additionalProperties: + type: string + description: Annotations are merged onto the Service (e.g. PureLB + pool selection). + type: object + ipAddressPool: + description: IPAddressPool is a convenience for the PureLB service-group + annotation. + type: string + loadBalancerClass: + description: LoadBalancerClass selects the LB implementation (e.g. + purelb.io/purelb). + type: string + loadBalancerIP: + description: LoadBalancerIP requests a specific (anycast) address. + type: string + type: + default: LoadBalancer + description: Type of the DHCP Service. Defaults to LoadBalancer + for anycast exposure. + type: string + type: object + tolerations: + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + type: object + status: + description: KeaClusterStatus captures the observed state of a KeaCluster. + properties: + activePeer: + description: ActivePeer is the pod currently acting as the HA primary. + type: string + conditions: + 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 + observedGeneration: + format: int64 + type: integer + phase: + type: string + readyReplicas: + format: int32 + type: integer + replicas: + format: int32 + type: integer + serviceIP: + description: ServiceIP is the anycast address assigned to the DHCP + Service. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/kea.unkin.net_keasubnets.yaml b/config/crd/bases/kea.unkin.net_keasubnets.yaml new file mode 100644 index 0000000..d03a80e --- /dev/null +++ b/config/crd/bases/kea.unkin.net_keasubnets.yaml @@ -0,0 +1,214 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: keasubnets.kea.unkin.net +spec: + group: kea.unkin.net + names: + kind: KeaSubnet + listKind: KeaSubnetList + plural: keasubnets + shortNames: + - ksn + singular: keasubnet + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef + name: Cluster + type: string + - jsonPath: .spec.subnet + name: Subnet + type: string + - jsonPath: .status.phase + name: Phase + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: KeaSubnet is one DHCPv4 subnet declaration referenced to a KeaCluster. + 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: KeaSubnetSpec defines a single DHCPv4 subnet served by a + KeaCluster. + properties: + bootFileName: + description: BootFileName sets the PXE boot file for this subnet (overridden + by class). + type: string + clientClasses: + description: ClientClasses restricts the subnet to the listed client + classes. + items: + type: string + type: array + clusterRef: + description: |- + ClusterRef selects the owning KeaCluster by name. Empty means every + KeaCluster in the namespace. + type: string + dnsServers: + description: DNSServers is the domain-name-servers option. + items: + type: string + type: array + domainName: + description: DomainName is the per-subnet domain-name option. + type: string + id: + description: |- + ID is the stable Kea subnet id. When zero the operator assigns one + deterministically from the sorted set of subnets. + type: integer + nextServer: + description: NextServer is the TFTP server address for PXE (siaddr + / next-server). + type: string + optionData: + description: OptionData carries any additional option values for the + subnet. + items: + description: OptionData is a rendered DHCPv4 option value (subnet- + or class-scoped). + properties: + code: + description: Code of the option (alternative to Name). + type: integer + csvFormat: + description: CSVFormat controls whether Data is parsed as CSV + (default true in Kea). + type: boolean + data: + description: Data is the option value(s), comma-separated per + Kea convention. + type: string + name: + description: Name of the option, e.g. "routers", "domain-name-servers". + type: string + space: + description: Space defaults to "dhcp4". + type: string + required: + - data + type: object + type: array + pools: + description: |- + Pools are dynamic ranges, e.g. "198.18.13.200 - 198.18.13.220". A subnet + with no pool is still declared so relayed requests on that network are + serviced (matching, option delivery) without dynamic allocation. + items: + type: string + type: array + routers: + description: Routers is the default-gateway list (routers option). + items: + type: string + type: array + subnet: + description: Subnet is the CIDR, e.g. "198.18.13.0/24". + type: string + validLifetime: + description: ValidLifetime overrides the cluster default lease time + for this subnet. + type: integer + required: + - subnet + type: object + status: + description: KeaSubnetStatus captures observed state. + properties: + assignedID: + description: AssignedID is the subnet id that was rendered into kea + config. + type: integer + conditions: + 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 + observedGeneration: + format: int64 + type: integer + phase: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/install.yaml b/config/crd/install.yaml new file mode 100644 index 0000000..5d46a2b --- /dev/null +++ b/config/crd/install.yaml @@ -0,0 +1,2864 @@ +# Generated by "make generate". DO NOT EDIT. +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: keaapis.kea.unkin.net +spec: + group: kea.unkin.net + names: + kind: KeaAPI + listKind: KeaAPIList + plural: keaapis + shortNames: + - kapi + singular: keaapi + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.endpoint + name: Endpoint + type: string + - jsonPath: .status.readyReplicas + name: Ready + type: integer + - jsonPath: .status.phase + name: Phase + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: KeaAPI spawns the REST API service Deployment. + 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: |- + KeaAPISpec defines the REST API service that manages KeaSubnet and + KeaClientClass CRs (a Terraform-friendly alternative to argocd-managed CRs). + properties: + affinity: + description: Affinity is a group of affinity scheduling rules. + properties: + nodeAffinity: + description: Describes node affinity scheduling rules for the + pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated with the + corresponding weight. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching the corresponding + nodeSelectorTerm, in the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector terms. + The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules (e.g. co-locate + this pod in the same node, zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling rules (e.g. + avoid putting this pod in the same node, zone, etc. as some + other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and subtracting + "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + image: + description: Image is the kea-api container image. + type: string + nodeSelector: + additionalProperties: + type: string + type: object + replicas: + default: 1 + description: Replicas of the API server. + format: int32 + minimum: 1 + type: integer + resources: + description: Resources for the API container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + service: + description: Service configures how the API is exposed. + properties: + annotations: + additionalProperties: + type: string + type: object + port: + default: 8080 + format: int32 + type: integer + type: + default: ClusterIP + description: Service Type string describes ingress methods for + a service + type: string + type: object + tokenSecretName: + description: |- + TokenSecretName is a Secret holding the bearer token under key "token". + If the Secret does not exist the operator creates one with a random + token (so it may instead be pre-seeded, e.g. by a Vault static secret). + type: string + tolerations: + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + type: object + status: + description: KeaAPIStatus captures observed state. + properties: + conditions: + 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 + endpoint: + description: Endpoint is the in-cluster base URL of the API. + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + readyReplicas: + format: int32 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: keaclientclasses.kea.unkin.net +spec: + group: kea.unkin.net + names: + kind: KeaClientClass + listKind: KeaClientClassList + plural: keaclientclasses + shortNames: + - kcc + singular: keaclientclass + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef + name: Cluster + type: string + - jsonPath: .spec.bootFileName + name: BootFile + type: string + - jsonPath: .status.phase + name: Phase + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: KeaClientClass is a PXE boot class matched on the client architecture. + 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: |- + KeaClientClassSpec defines a PXE boot client-class, typically matching the + DHCP client architecture option (code 93). + properties: + archHex: + description: |- + ArchHex is a convenience list of client-architecture values (option 93), + e.g. ["0x0000"] or ["0x0007","0x0009"]. Rendered into a Test expression + of the form: option[93].hex == 0x0007 or option[93].hex == 0x0009. + items: + type: string + type: array + bootFileName: + description: BootFileName handed to matching clients (option 67 / + boot-file-name). + type: string + clusterRef: + description: |- + ClusterRef selects the owning KeaCluster by name. Empty means every + KeaCluster in the namespace. + type: string + nextServer: + description: NextServer overrides siaddr for matching clients. + type: string + optionData: + description: OptionData carries additional options set for matching + clients. + items: + description: OptionData is a rendered DHCPv4 option value (subnet- + or class-scoped). + properties: + code: + description: Code of the option (alternative to Name). + type: integer + csvFormat: + description: CSVFormat controls whether Data is parsed as CSV + (default true in Kea). + type: boolean + data: + description: Data is the option value(s), comma-separated per + Kea convention. + type: string + name: + description: Name of the option, e.g. "routers", "domain-name-servers". + type: string + space: + description: Space defaults to "dhcp4". + type: string + required: + - data + type: object + type: array + serverHostname: + description: ServerHostname (sname) for matching clients. + type: string + test: + description: |- + Test is a raw Kea class-match expression. When empty it is generated + from ArchHex. + type: string + type: object + status: + description: KeaClientClassStatus captures observed state. + properties: + conditions: + 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 + observedGeneration: + format: int64 + type: integer + phase: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: keaclusters.kea.unkin.net +spec: + group: kea.unkin.net + names: + kind: KeaCluster + listKind: KeaClusterList + plural: keaclusters + shortNames: + - kc + singular: keacluster + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.ha.mode + name: Mode + type: string + - jsonPath: .status.readyReplicas + name: Ready + type: integer + - jsonPath: .status.serviceIP + name: ServiceIP + type: string + - jsonPath: .status.phase + name: Phase + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + KeaCluster spawns a StatefulSet of kea-dhcp4 servers with an HA control + channel and an anycast DHCP Service. + 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: KeaClusterSpec defines the desired state of a KeaCluster. + properties: + affinity: + description: Affinity is a group of affinity scheduling rules. + properties: + nodeAffinity: + description: Describes node affinity scheduling rules for the + pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated with the + corresponding weight. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching the corresponding + nodeSelectorTerm, in the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector terms. + The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules (e.g. co-locate + this pod in the same node, zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling rules (e.g. + avoid putting this pod in the same node, zone, etc. as some + other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and subtracting + "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + defaultLeaseTime: + default: 1200 + description: DefaultLeaseTime in seconds (valid-lifetime). + minimum: 1 + type: integer + domainName: + description: DomainName is the global domain-name option handed to + clients. + type: string + ha: + description: HA configures the libdhcp_ha hook between replicas. + properties: + heartbeatDelay: + default: 10000 + description: HeartbeatDelay is the interval (ms) between HA heartbeats. + minimum: 1000 + type: integer + maxAckDelay: + default: 5000 + description: MaxAckDelay (ms) a client may wait before HA considers + it unacked. + minimum: 0 + type: integer + maxResponseDelay: + default: 60000 + description: MaxResponseDelay (ms) before a peer is considered + unresponsive. + minimum: 1000 + type: integer + maxUnackedClients: + default: 5 + description: MaxUnackedClients before a partner is declared to + be in a failure state. + minimum: 0 + type: integer + mode: + default: hot-standby + description: Mode is the HA operating mode. + enum: + - hot-standby + - load-balancing + type: string + type: object + image: + description: Image is the kea-dhcp4 + kea-ctrl-agent container image. + type: string + maxLeaseTime: + default: 86400 + description: MaxLeaseTime in seconds (max-valid-lifetime). + minimum: 1 + type: integer + nodeSelector: + additionalProperties: + type: string + type: object + ntpServers: + description: NTPServers are handed to clients as the ntp-servers option. + items: + type: string + type: array + optionDefs: + description: OptionDefs are custom option definitions (e.g. PXE client-arch, + code 93). + items: + description: |- + OptionDef is a custom DHCPv4 option definition (e.g. the PXE client + architecture option, code 93). + properties: + array: + description: Array marks the option as an array of Type. + type: boolean + code: + type: integer + encapsulate: + description: Encapsulate names an option space this option encapsulates. + type: string + name: + type: string + recordTypes: + description: RecordTypes is a comma-separated list for record-type + options. + type: string + space: + default: dhcp4 + description: Space defaults to "dhcp4". + type: string + type: + description: Type is a Kea option data type, e.g. "uint16", + "string", "ipv4-address". + type: string + required: + - code + - name + - type + type: object + type: array + replicas: + default: 2 + description: Replicas is the number of kea-dhcp4 servers. HA is designed + around 2. + format: int32 + minimum: 1 + type: integer + resources: + description: Resources for the kea containers. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + service: + description: Service configures the anycast LoadBalancer fronting + the replicas. + properties: + annotations: + additionalProperties: + type: string + description: Annotations are merged onto the Service (e.g. PureLB + pool selection). + type: object + ipAddressPool: + description: IPAddressPool is a convenience for the PureLB service-group + annotation. + type: string + loadBalancerClass: + description: LoadBalancerClass selects the LB implementation (e.g. + purelb.io/purelb). + type: string + loadBalancerIP: + description: LoadBalancerIP requests a specific (anycast) address. + type: string + type: + default: LoadBalancer + description: Type of the DHCP Service. Defaults to LoadBalancer + for anycast exposure. + type: string + type: object + tolerations: + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + type: object + status: + description: KeaClusterStatus captures the observed state of a KeaCluster. + properties: + activePeer: + description: ActivePeer is the pod currently acting as the HA primary. + type: string + conditions: + 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 + observedGeneration: + format: int64 + type: integer + phase: + type: string + readyReplicas: + format: int32 + type: integer + replicas: + format: int32 + type: integer + serviceIP: + description: ServiceIP is the anycast address assigned to the DHCP + Service. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: keasubnets.kea.unkin.net +spec: + group: kea.unkin.net + names: + kind: KeaSubnet + listKind: KeaSubnetList + plural: keasubnets + shortNames: + - ksn + singular: keasubnet + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef + name: Cluster + type: string + - jsonPath: .spec.subnet + name: Subnet + type: string + - jsonPath: .status.phase + name: Phase + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: KeaSubnet is one DHCPv4 subnet declaration referenced to a KeaCluster. + 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: KeaSubnetSpec defines a single DHCPv4 subnet served by a + KeaCluster. + properties: + bootFileName: + description: BootFileName sets the PXE boot file for this subnet (overridden + by class). + type: string + clientClasses: + description: ClientClasses restricts the subnet to the listed client + classes. + items: + type: string + type: array + clusterRef: + description: |- + ClusterRef selects the owning KeaCluster by name. Empty means every + KeaCluster in the namespace. + type: string + dnsServers: + description: DNSServers is the domain-name-servers option. + items: + type: string + type: array + domainName: + description: DomainName is the per-subnet domain-name option. + type: string + id: + description: |- + ID is the stable Kea subnet id. When zero the operator assigns one + deterministically from the sorted set of subnets. + type: integer + nextServer: + description: NextServer is the TFTP server address for PXE (siaddr + / next-server). + type: string + optionData: + description: OptionData carries any additional option values for the + subnet. + items: + description: OptionData is a rendered DHCPv4 option value (subnet- + or class-scoped). + properties: + code: + description: Code of the option (alternative to Name). + type: integer + csvFormat: + description: CSVFormat controls whether Data is parsed as CSV + (default true in Kea). + type: boolean + data: + description: Data is the option value(s), comma-separated per + Kea convention. + type: string + name: + description: Name of the option, e.g. "routers", "domain-name-servers". + type: string + space: + description: Space defaults to "dhcp4". + type: string + required: + - data + type: object + type: array + pools: + description: |- + Pools are dynamic ranges, e.g. "198.18.13.200 - 198.18.13.220". A subnet + with no pool is still declared so relayed requests on that network are + serviced (matching, option delivery) without dynamic allocation. + items: + type: string + type: array + routers: + description: Routers is the default-gateway list (routers option). + items: + type: string + type: array + subnet: + description: Subnet is the CIDR, e.g. "198.18.13.0/24". + type: string + validLifetime: + description: ValidLifetime overrides the cluster default lease time + for this subnet. + type: integer + required: + - subnet + type: object + status: + description: KeaSubnetStatus captures observed state. + properties: + assignedID: + description: AssignedID is the subnet id that was rendered into kea + config. + type: integer + conditions: + 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 + observedGeneration: + format: int64 + type: integer + phase: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml new file mode 100644 index 0000000..5737fe3 --- /dev/null +++ b/config/rbac/role.yaml @@ -0,0 +1,81 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kea-operator +rules: +- apiGroups: + - "" + resources: + - configmaps + - secrets + - serviceaccounts + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - pods + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - deployments + - statefulsets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - kea.unkin.net + resources: + - keaapis + - keaclientclasses + - keaclusters + - keasubnets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - kea.unkin.net + resources: + - keaapis/status + - keaclientclasses/status + - keaclusters/status + - keasubnets/status + verbs: + - get + - patch + - update +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + verbs: + - create + - delete + - get + - list + - patch + - update + - watch diff --git a/config/samples/00-cluster.yaml b/config/samples/00-cluster.yaml new file mode 100644 index 0000000..fc01e99 --- /dev/null +++ b/config/samples/00-cluster.yaml @@ -0,0 +1,30 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: dhcp-system +--- +apiVersion: kea.unkin.net/v1alpha1 +kind: KeaCluster +metadata: + name: pxe + namespace: dhcp-system +spec: + replicas: 2 + image: git.unkin.net/unkin/kea:latest + domainName: main.unkin.net + defaultLeaseTime: 1200 + maxLeaseTime: 86400 + ha: + mode: hot-standby + service: + type: LoadBalancer + # Anycast address handed out by PureLB; router relays forward unicast here. + loadBalancerIP: 198.18.19.53 + ipAddressPool: dhcp-anycast + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi diff --git a/config/samples/01-subnets.yaml b/config/samples/01-subnets.yaml new file mode 100644 index 0000000..e8c448a --- /dev/null +++ b/config/samples/01-subnets.yaml @@ -0,0 +1,91 @@ +# Full translation of the legacy ISC dhcpd pools: 198.18.13-17.0/24 each with a +# .200-.220 pool, plus 198.18.25.0/24 with no pool (declared so relayed requests +# are still serviced). routers .1, dns 198.18.19.15, next-server 198.18.19.19. +apiVersion: kea.unkin.net/v1alpha1 +kind: KeaSubnet +metadata: + name: net-198-18-13 + namespace: dhcp-system +spec: + clusterRef: pxe + subnet: 198.18.13.0/24 + pools: + - 198.18.13.200 - 198.18.13.220 + routers: [198.18.13.1] + dnsServers: [198.18.19.15] + domainName: main.unkin.net + nextServer: 198.18.19.19 +--- +apiVersion: kea.unkin.net/v1alpha1 +kind: KeaSubnet +metadata: + name: net-198-18-14 + namespace: dhcp-system +spec: + clusterRef: pxe + subnet: 198.18.14.0/24 + pools: + - 198.18.14.200 - 198.18.14.220 + routers: [198.18.14.1] + dnsServers: [198.18.19.15] + domainName: main.unkin.net + nextServer: 198.18.19.19 +--- +apiVersion: kea.unkin.net/v1alpha1 +kind: KeaSubnet +metadata: + name: net-198-18-15 + namespace: dhcp-system +spec: + clusterRef: pxe + subnet: 198.18.15.0/24 + pools: + - 198.18.15.200 - 198.18.15.220 + routers: [198.18.15.1] + dnsServers: [198.18.19.15] + domainName: main.unkin.net + nextServer: 198.18.19.19 +--- +apiVersion: kea.unkin.net/v1alpha1 +kind: KeaSubnet +metadata: + name: net-198-18-16 + namespace: dhcp-system +spec: + clusterRef: pxe + subnet: 198.18.16.0/24 + pools: + - 198.18.16.200 - 198.18.16.220 + routers: [198.18.16.1] + dnsServers: [198.18.19.15] + domainName: main.unkin.net + nextServer: 198.18.19.19 +--- +apiVersion: kea.unkin.net/v1alpha1 +kind: KeaSubnet +metadata: + name: net-198-18-17 + namespace: dhcp-system +spec: + clusterRef: pxe + subnet: 198.18.17.0/24 + pools: + - 198.18.17.200 - 198.18.17.220 + routers: [198.18.17.1] + dnsServers: [198.18.19.15] + domainName: main.unkin.net + nextServer: 198.18.19.19 +--- +# No pool: declared so relayed DHCP requests on this net are matched and get +# options, but no dynamic address is allocated (subnet-mask derives from CIDR). +apiVersion: kea.unkin.net/v1alpha1 +kind: KeaSubnet +metadata: + name: net-198-18-25 + namespace: dhcp-system +spec: + clusterRef: pxe + subnet: 198.18.25.0/24 + routers: [198.18.25.1] + dnsServers: [198.18.19.15] + domainName: main.unkin.net diff --git a/config/samples/02-clientclasses.yaml b/config/samples/02-clientclasses.yaml new file mode 100644 index 0000000..b6ed1b9 --- /dev/null +++ b/config/samples/02-clientclasses.yaml @@ -0,0 +1,21 @@ +# PXE boot classes matching the client architecture option (code 93), replacing +# the legacy dhcpd "Legacy" and "UEFI-64" classes. +apiVersion: kea.unkin.net/v1alpha1 +kind: KeaClientClass +metadata: + name: Legacy + namespace: dhcp-system +spec: + clusterRef: pxe + archHex: ["0x0000"] + bootFileName: /undionly.kpxe +--- +apiVersion: kea.unkin.net/v1alpha1 +kind: KeaClientClass +metadata: + name: UEFI-64 + namespace: dhcp-system +spec: + clusterRef: pxe + archHex: ["0x0007", "0x0009"] + bootFileName: /ipxe.efi diff --git a/config/samples/03-api.yaml b/config/samples/03-api.yaml new file mode 100644 index 0000000..a0b1e04 --- /dev/null +++ b/config/samples/03-api.yaml @@ -0,0 +1,21 @@ +# Optional: spawn the Terraform-friendly REST API that CRUDs KeaSubnet / +# KeaClientClass CRs. Auth is a bearer token; the operator generates the token +# Secret if absent, or it can be pre-seeded (e.g. by a Vault static secret). +apiVersion: kea.unkin.net/v1alpha1 +kind: KeaAPI +metadata: + name: kea-api + namespace: dhcp-system +spec: + replicas: 1 + image: git.unkin.net/unkin/kea-api:latest + service: + type: ClusterIP + port: 8080 + resources: + requests: + cpu: 100m + memory: 64Mi + limits: + cpu: "1" + memory: 256Mi diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f27e6d6 --- /dev/null +++ b/go.mod @@ -0,0 +1,68 @@ +module git.unkin.net/unkin/kea-operator + +go 1.25 + +require ( + github.com/go-logr/logr v1.4.2 + k8s.io/api v0.34.4 + k8s.io/apimachinery v0.34.4 + k8s.io/client-go v0.34.4 + sigs.k8s.io/controller-runtime v0.22.4 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/time v0.9.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.34.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2261246 --- /dev/null +++ b/go.sum @@ -0,0 +1,198 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +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.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= +github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= +github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +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= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.4 h1:Z5hsoQcZ2yBjelb9j5JKzCVo9qv9XLkVm5llnqS4h+0= +k8s.io/api v0.34.4/go.mod h1:6SaGYuGPkMqqCgg8rPG/OQoCrhgSEV+wWn9v21fDP3o= +k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= +k8s.io/apimachinery v0.34.4 h1:C5SiSzLEMyWIk53sSbnk0WlOOyqv/MFnWvuc/d6M+xc= +k8s.io/apimachinery v0.34.4/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.4 h1:IXhvzFdm0e897kXtLbeyMpAGzontcShJ/gi/XCCsOLc= +k8s.io/client-go v0.34.4/go.mod h1:tXIVJTQabT5QRGlFdxZQFxrIhcGUPpKL5DAc4gSWTE8= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/hack/kind/manifests/namespace.yaml b/hack/kind/manifests/namespace.yaml new file mode 100644 index 0000000..14e0658 --- /dev/null +++ b/hack/kind/manifests/namespace.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: dhcp-system diff --git a/hack/kind/manifests/operator-deployment.yaml b/hack/kind/manifests/operator-deployment.yaml new file mode 100644 index 0000000..e76ba69 --- /dev/null +++ b/hack/kind/manifests/operator-deployment.yaml @@ -0,0 +1,51 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kea-operator + namespace: dhcp-system + labels: + app: kea-operator +spec: + replicas: 1 + selector: + matchLabels: + app: kea-operator + template: + metadata: + labels: + app: kea-operator + spec: + serviceAccountName: kea-operator + containers: + - name: operator + image: kea-operator:dev + imagePullPolicy: Never + args: + - --metrics-bind-address=:8080 + - --health-probe-bind-address=:8081 + - --leader-elect + ports: + - containerPort: 8080 + name: metrics + - containerPort: 8081 + name: health + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi diff --git a/hack/kind/manifests/rbac.yaml b/hack/kind/manifests/rbac.yaml new file mode 100644 index 0000000..af9753e --- /dev/null +++ b/hack/kind/manifests/rbac.yaml @@ -0,0 +1,46 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kea-operator + namespace: dhcp-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kea-operator +rules: + - apiGroups: ["kea.unkin.net"] + resources: ["*"] + verbs: ["*"] + - apiGroups: [""] + resources: ["services", "configmaps", "secrets", "serviceaccounts"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: ["apps"] + resources: ["statefulsets", "deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["roles", "rolebindings"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: kea-operator +subjects: + - kind: ServiceAccount + name: kea-operator + namespace: dhcp-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kea-operator diff --git a/internal/controller/helpers.go b/internal/controller/helpers.go new file mode 100644 index 0000000..4327362 --- /dev/null +++ b/internal/controller/helpers.go @@ -0,0 +1,104 @@ +package controller + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func intstrFromInt(i int) intstr.IntOrString { return intstr.FromInt(i) } + +const ( + requeueShort = 15 * time.Second + requeueLong = 2 * time.Minute + + managedByLabel = "app.kubernetes.io/managed-by" + managedByValue = "kea-operator" + clusterLabel = "kea.unkin.net/cluster" + roleLabel = "kea.unkin.net/role" + + finalizer = "kea.unkin.net/finalizer" + + defaultOperatorImage = "git.unkin.net/unkin/kea-operator:latest" + defaultAPIImage = "git.unkin.net/unkin/kea-api:latest" +) + +func headlessName(cluster string) string { return cluster + "-headless" } +func serviceName(cluster string) string { return cluster } +func configMapName(cluster string) string { return cluster + "-config" } +func stsName(cluster string) string { return cluster } +func peerDNS(cluster, ns string, ordinal int) string { + return fmt.Sprintf("http://%s-%d.%s.%s:%d/", cluster, ordinal, headlessName(cluster), ns, 8000) +} + +func commonLabels(cluster string) map[string]string { + return map[string]string{ + managedByLabel: managedByValue, + clusterLabel: cluster, + } +} + +// setReady sets the single "Ready" condition, mirroring bind-operator. +func setReady(conds *[]metav1.Condition, gen int64, ok bool, reason, msg string) { + status := metav1.ConditionFalse + if ok { + status = metav1.ConditionTrue + } + meta.SetStatusCondition(conds, metav1.Condition{ + Type: "Ready", + Status: status, + ObservedGeneration: gen, + Reason: reason, + Message: msg, + }) +} + +// configHash returns a stable hash of the named ConfigMap's data. Pods copy +// config out of a projected volume at startup, so a ConfigMap change alone +// never reaches a running pod; stamping this hash on the pod template is what +// rolls the StatefulSet. The input must contain no pod IPs or the roll loops. +func configHash(ctx context.Context, c client.Client, ns, name string) (string, error) { + var cm corev1.ConfigMap + if err := c.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, &cm); err != nil { + return "", err + } + keys := make([]string, 0, len(cm.Data)) + for k := range cm.Data { + keys = append(keys, k) + } + sort.Strings(keys) + var buf bytes.Buffer + for _, k := range keys { + buf.WriteString(k) + buf.WriteByte(0) + buf.WriteString(cm.Data[k]) + buf.WriteByte(0) + } + sum := sha256.Sum256(buf.Bytes()) + return hex.EncodeToString(sum[:]), nil +} + +func int32ptr(i int32) *int32 { return &i } +func ptr[T any](v T) *T { return &v } + +// splitPool splits a "start - end" (any spacing) or single-address pool into +// its address fields. +func splitPool(p string) []string { + if !strings.Contains(p, "-") { + return []string{strings.TrimSpace(p)} + } + parts := strings.SplitN(p, "-", 2) + return []string{strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])} +} diff --git a/internal/controller/keaapi_controller.go b/internal/controller/keaapi_controller.go new file mode 100644 index 0000000..354a5b6 --- /dev/null +++ b/internal/controller/keaapi_controller.go @@ -0,0 +1,253 @@ +package controller + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + 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" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" +) + +// KeaAPIReconciler spawns the REST API service that CRUDs KeaSubnet and +// KeaClientClass CRs (a Terraform-friendly alternative to argocd-managed CRs). +type KeaAPIReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaapis,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaapis/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=serviceaccounts;secrets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;watch;create;update;patch;delete + +func (r *KeaAPIReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var api v1alpha1.KeaAPI + if err := r.Get(ctx, req.NamespacedName, &api); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + for _, step := range []func(context.Context, *v1alpha1.KeaAPI) error{ + r.reconcileServiceAccount, + r.reconcileRBAC, + r.reconcileTokenSecret, + r.reconcileDeployment, + r.reconcileService, + } { + if err := step(ctx, &api); err != nil { + api.Status.Phase = "Error" + setReady(&api.Status.Conditions, api.Generation, false, "ReconcileError", err.Error()) + _ = r.Status().Update(ctx, &api) + return ctrl.Result{}, err + } + } + + var dep appsv1.Deployment + _ = r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: api.Name}, &dep) + port := api.Spec.Service.Port + if port == 0 { + port = 8080 + } + api.Status.ReadyReplicas = dep.Status.ReadyReplicas + api.Status.Endpoint = fmt.Sprintf("http://%s.%s.svc:%d", api.Name, api.Namespace, port) + api.Status.ObservedGeneration = api.Generation + if dep.Status.ReadyReplicas > 0 { + api.Status.Phase = "Ready" + setReady(&api.Status.Conditions, api.Generation, true, "Ready", "api ready") + } else { + api.Status.Phase = "Progressing" + setReady(&api.Status.Conditions, api.Generation, false, "Progressing", "waiting for api pods") + } + if err := r.Status().Update(ctx, &api); err != nil { + return ctrl.Result{}, err + } + if api.Status.Phase != "Ready" { + return ctrl.Result{RequeueAfter: requeueShort}, nil + } + return ctrl.Result{RequeueAfter: requeueLong}, nil +} + +func (r *KeaAPIReconciler) reconcileServiceAccount(ctx context.Context, api *v1alpha1.KeaAPI) error { + sa := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}} + _, err := ctrl.CreateOrUpdate(ctx, r.Client, sa, func() error { + sa.Labels = apiLabels(api.Name) + return ctrl.SetControllerReference(api, sa, r.Scheme) + }) + return err +} + +func (r *KeaAPIReconciler) reconcileRBAC(ctx context.Context, api *v1alpha1.KeaAPI) error { + role := &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}} + if _, err := ctrl.CreateOrUpdate(ctx, r.Client, role, func() error { + role.Labels = apiLabels(api.Name) + role.Rules = []rbacv1.PolicyRule{ + { + APIGroups: []string{"kea.unkin.net"}, + Resources: []string{"keasubnets", "keaclientclasses"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + { + APIGroups: []string{"kea.unkin.net"}, + Resources: []string{"keasubnets/status", "keaclientclasses/status"}, + Verbs: []string{"get"}, + }, + } + return ctrl.SetControllerReference(api, role, r.Scheme) + }); err != nil { + return err + } + + rb := &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}} + _, err := ctrl.CreateOrUpdate(ctx, r.Client, rb, func() error { + rb.Labels = apiLabels(api.Name) + rb.RoleRef = rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: api.Name} + rb.Subjects = []rbacv1.Subject{{Kind: "ServiceAccount", Name: api.Name, Namespace: api.Namespace}} + return ctrl.SetControllerReference(api, rb, r.Scheme) + }) + return err +} + +// reconcileTokenSecret creates the bearer-token Secret only when absent, so it +// may instead be pre-seeded (e.g. by a Vault static secret). It is intentionally +// not owned/overwritten once it exists. +func (r *KeaAPIReconciler) reconcileTokenSecret(ctx context.Context, api *v1alpha1.KeaAPI) error { + name := tokenSecretName(api) + var existing corev1.Secret + err := r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: name}, &existing) + if err == nil { + return nil + } + if !apierrors.IsNotFound(err) { + return err + } + tok := make([]byte, 32) + if _, err := rand.Read(tok); err != nil { + return err + } + sec := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: api.Namespace, Labels: apiLabels(api.Name)}, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{"token": hex.EncodeToString(tok)}, + } + return r.Create(ctx, sec) +} + +func (r *KeaAPIReconciler) reconcileDeployment(ctx context.Context, api *v1alpha1.KeaAPI) error { + image := api.Spec.Image + if image == "" { + image = defaultAPIImage + } + replicas := int32(1) + if api.Spec.Replicas != nil { + replicas = *api.Spec.Replicas + } + port := api.Spec.Service.Port + if port == 0 { + port = 8080 + } + + dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}} + _, err := ctrl.CreateOrUpdate(ctx, r.Client, dep, func() error { + dep.Labels = apiLabels(api.Name) + dep.Spec.Replicas = int32ptr(replicas) + dep.Spec.Selector = &metav1.LabelSelector{MatchLabels: apiLabels(api.Name)} + dep.Spec.Template = corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: apiLabels(api.Name)}, + Spec: corev1.PodSpec{ + ServiceAccountName: api.Name, + NodeSelector: api.Spec.NodeSelector, + Tolerations: api.Spec.Tolerations, + Affinity: api.Spec.Affinity, + Containers: []corev1.Container{{ + Name: "kea-api", + Image: image, + Command: []string{"kea-api"}, + Ports: []corev1.ContainerPort{{Name: "http", ContainerPort: port}}, + Env: []corev1.EnvVar{ + {Name: "LISTEN_ADDR", Value: fmt.Sprintf(":%d", port)}, + {Name: "TARGET_NAMESPACE", Value: api.Namespace}, + {Name: "KEA_API_TOKEN", ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: tokenSecretName(api)}, + Key: "token", + }, + }}, + }, + Resources: api.Spec.Resources, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{ + Path: "/healthz", Port: intstrFromInt(int(port)), + }}, + InitialDelaySeconds: 3, PeriodSeconds: 10, + }, + SecurityContext: &corev1.SecurityContext{ + RunAsNonRoot: ptr(true), + AllowPrivilegeEscalation: ptr(false), + ReadOnlyRootFilesystem: ptr(true), + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, + }, + }}, + }, + } + return ctrl.SetControllerReference(api, dep, r.Scheme) + }) + return err +} + +func (r *KeaAPIReconciler) reconcileService(ctx context.Context, api *v1alpha1.KeaAPI) error { + port := api.Spec.Service.Port + if port == 0 { + port = 8080 + } + svcType := api.Spec.Service.Type + if svcType == "" { + svcType = corev1.ServiceTypeClusterIP + } + svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}} + _, err := ctrl.CreateOrUpdate(ctx, r.Client, svc, func() error { + svc.Labels = apiLabels(api.Name) + svc.Annotations = api.Spec.Service.Annotations + svc.Spec.Type = svcType + svc.Spec.Selector = apiLabels(api.Name) + svc.Spec.Ports = []corev1.ServicePort{{Name: "http", Port: port, TargetPort: intstrFromInt(int(port))}} + return ctrl.SetControllerReference(api, svc, r.Scheme) + }) + return err +} + +func tokenSecretName(api *v1alpha1.KeaAPI) string { + if api.Spec.TokenSecretName != "" { + return api.Spec.TokenSecretName + } + return api.Name + "-token" +} + +func apiLabels(name string) map[string]string { + return map[string]string{ + managedByLabel: managedByValue, + "app.kubernetes.io/name": "kea-api", + "app.kubernetes.io/instance": name, + } +} + +func (r *KeaAPIReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.KeaAPI{}). + Owns(&appsv1.Deployment{}). + Owns(&corev1.Service{}). + Owns(&corev1.ServiceAccount{}). + Owns(&rbacv1.Role{}). + Owns(&rbacv1.RoleBinding{}). + Complete(r) +} diff --git a/internal/controller/keaclientclass_controller.go b/internal/controller/keaclientclass_controller.go new file mode 100644 index 0000000..771d3b6 --- /dev/null +++ b/internal/controller/keaclientclass_controller.go @@ -0,0 +1,57 @@ +package controller + +import ( + "context" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" +) + +// KeaClientClassReconciler validates KeaClientClass CRs and maintains status. +type KeaClientClassReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaclientclasses,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaclientclasses/status,verbs=get;update;patch + +func (r *KeaClientClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var cc v1alpha1.KeaClientClass + if err := r.Get(ctx, req.NamespacedName, &cc); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if cc.Spec.Test == "" && len(cc.Spec.ArchHex) == 0 { + cc.Status.Phase = "Invalid" + setReady(&cc.Status.Conditions, cc.Generation, false, "NoMatch", "either test or archHex must be set") + _ = r.Status().Update(ctx, &cc) + return ctrl.Result{}, nil + } + for _, a := range cc.Spec.ArchHex { + if !strings.HasPrefix(a, "0x") { + cc.Status.Phase = "Invalid" + setReady(&cc.Status.Conditions, cc.Generation, false, "BadArch", "archHex values must be 0x-prefixed, e.g. 0x0007") + _ = r.Status().Update(ctx, &cc) + return ctrl.Result{}, nil + } + } + + cc.Status.Phase = "Ready" + cc.Status.ObservedGeneration = cc.Generation + setReady(&cc.Status.Conditions, cc.Generation, true, "Validated", "client class accepted") + if err := r.Status().Update(ctx, &cc); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *KeaClientClassReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.KeaClientClass{}). + Complete(r) +} diff --git a/internal/controller/keacluster_controller.go b/internal/controller/keacluster_controller.go new file mode 100644 index 0000000..8c3470b --- /dev/null +++ b/internal/controller/keacluster_controller.go @@ -0,0 +1,396 @@ +package controller + +import ( + "context" + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" + "git.unkin.net/unkin/kea-operator/internal/kea" +) + +// KeaClusterReconciler reconciles a KeaCluster. +type KeaClusterReconciler struct { + client.Client + Scheme *runtime.Scheme + Control *kea.ControlClient +} + +// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaclusters,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaclusters/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=kea.unkin.net,resources=keasubnets,verbs=get;list;watch +// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaclientclasses,verbs=get;list;watch +// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=services;configmaps,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch + +func (r *KeaClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + l := log.FromContext(ctx) + + var cluster v1alpha1.KeaCluster + if err := r.Get(ctx, req.NamespacedName, &cluster); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if err := r.reconcileConfigMap(ctx, &cluster); err != nil { + return r.fail(ctx, &cluster, "ConfigError", err) + } + if err := r.reconcileServices(ctx, &cluster); err != nil { + return r.fail(ctx, &cluster, "ServiceError", err) + } + sts, err := r.reconcileStatefulSet(ctx, &cluster) + if err != nil { + return r.fail(ctx, &cluster, "WorkloadError", err) + } + + r.reloadReadyPods(ctx, &cluster) + + ready := sts.Status.ReadyReplicas + desired := int32(1) + if cluster.Spec.Replicas != nil { + desired = *cluster.Spec.Replicas + } + + cluster.Status.ObservedGeneration = cluster.Generation + cluster.Status.Replicas = sts.Status.Replicas + cluster.Status.ReadyReplicas = ready + cluster.Status.ServiceIP = r.serviceIP(ctx, &cluster) + if ready > 0 { + cluster.Status.ActivePeer = cluster.Name + "-0" + } + if ready >= desired && desired > 0 { + cluster.Status.Phase = "Ready" + setReady(&cluster.Status.Conditions, cluster.Generation, true, "Ready", "all replicas ready") + } else { + cluster.Status.Phase = "Progressing" + setReady(&cluster.Status.Conditions, cluster.Generation, false, "Progressing", + fmt.Sprintf("%d/%d replicas ready", ready, desired)) + } + if err := r.Status().Update(ctx, &cluster); err != nil { + l.Error(err, "status update") + } + + if cluster.Status.Phase != "Ready" { + return ctrl.Result{RequeueAfter: requeueShort}, nil + } + return ctrl.Result{RequeueAfter: requeueLong}, nil +} + +func (r *KeaClusterReconciler) fail(ctx context.Context, c *v1alpha1.KeaCluster, reason string, err error) (ctrl.Result, error) { + c.Status.Phase = "Error" + setReady(&c.Status.Conditions, c.Generation, false, reason, err.Error()) + _ = r.Status().Update(ctx, c) + return ctrl.Result{}, err +} + +// buildInput gathers matching subnets/classes and the stable HA peer list. +func (r *KeaClusterReconciler) buildInput(ctx context.Context, c *v1alpha1.KeaCluster) (kea.RenderInput, error) { + var subnetList v1alpha1.KeaSubnetList + if err := r.List(ctx, &subnetList, client.InNamespace(c.Namespace)); err != nil { + return kea.RenderInput{}, err + } + var subnets []v1alpha1.KeaSubnet + for _, s := range subnetList.Items { + if s.Spec.ClusterRef == "" || s.Spec.ClusterRef == c.Name { + subnets = append(subnets, s) + } + } + + var classList v1alpha1.KeaClientClassList + if err := r.List(ctx, &classList, client.InNamespace(c.Namespace)); err != nil { + return kea.RenderInput{}, err + } + var classes []v1alpha1.KeaClientClass + for _, cc := range classList.Items { + if cc.Spec.ClusterRef == "" || cc.Spec.ClusterRef == c.Name { + classes = append(classes, cc) + } + } + + return kea.RenderInput{ + Cluster: *c, + Subnets: subnets, + ClientClasses: classes, + Peers: r.peers(c), + }, nil +} + +// peers returns stable HA peer identities (DNS only, no pod IPs). +func (r *KeaClusterReconciler) peers(c *v1alpha1.KeaCluster) []kea.Peer { + replicas := int32(1) + if c.Spec.Replicas != nil { + replicas = *c.Spec.Replicas + } + mode := c.Spec.HA.Mode + if mode == "" { + mode = v1alpha1.HAHotStandby + } + peers := make([]kea.Peer, 0, replicas) + for i := int32(0); i < replicas; i++ { + role := "backup" + switch { + case i == 0: + role = "primary" + case i == 1 && mode == v1alpha1.HAHotStandby: + role = "standby" + case i == 1: + role = "secondary" + } + peers = append(peers, kea.Peer{ + Name: fmt.Sprintf("server%d", i), + URL: peerDNS(c.Name, c.Namespace, int(i)), + Role: role, + }) + } + return peers +} + +func (r *KeaClusterReconciler) reconcileConfigMap(ctx context.Context, c *v1alpha1.KeaCluster) error { + in, err := r.buildInput(ctx, c) + if err != nil { + return err + } + dhcp4, err := kea.RenderDHCP4(in) + if err != nil { + return err + } + agent, err := kea.RenderCtrlAgent() + if err != nil { + return err + } + + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: configMapName(c.Name), Namespace: c.Namespace}} + _, err = ctrl.CreateOrUpdate(ctx, r.Client, cm, func() error { + cm.Labels = commonLabels(c.Name) + cm.Data = map[string]string{ + "kea-dhcp4.conf": dhcp4, + "kea-ctrl-agent.conf": agent, + "entrypoint-dhcp4.sh": kea.EntrypointDHCP4(), + "entrypoint-ctrlagent.sh": kea.EntrypointCtrlAgent(), + } + return ctrl.SetControllerReference(c, cm, r.Scheme) + }) + return err +} + +func (r *KeaClusterReconciler) reconcileServices(ctx context.Context, c *v1alpha1.KeaCluster) error { + // Headless service for stable per-pod DNS (HA peer URLs, ctrl-agent). + headless := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: headlessName(c.Name), Namespace: c.Namespace}} + if _, err := ctrl.CreateOrUpdate(ctx, r.Client, headless, func() error { + headless.Labels = commonLabels(c.Name) + headless.Spec.ClusterIP = corev1.ClusterIPNone + headless.Spec.PublishNotReadyAddresses = true + headless.Spec.Selector = commonLabels(c.Name) + headless.Spec.Ports = []corev1.ServicePort{ + {Name: "ctrl", Port: kea.CtrlAgentPort, Protocol: corev1.ProtocolTCP}, + } + return ctrl.SetControllerReference(c, headless, r.Scheme) + }); err != nil { + return err + } + + // Anycast DHCP service (LoadBalancer via PureLB by default). + svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: serviceName(c.Name), Namespace: c.Namespace}} + _, err := ctrl.CreateOrUpdate(ctx, r.Client, svc, func() error { + svc.Labels = commonLabels(c.Name) + svc.Annotations = mergeAnnotations(c.Spec.Service) + svc.Spec.Selector = commonLabels(c.Name) + svcType := c.Spec.Service.Type + if svcType == "" { + svcType = corev1.ServiceTypeLoadBalancer + } + svc.Spec.Type = svcType + if c.Spec.Service.LoadBalancerIP != "" { + svc.Spec.LoadBalancerIP = c.Spec.Service.LoadBalancerIP + } + if c.Spec.Service.LoadBalancerClass != nil { + svc.Spec.LoadBalancerClass = c.Spec.Service.LoadBalancerClass + } + if svcType == corev1.ServiceTypeLoadBalancer || svcType == corev1.ServiceTypeNodePort { + svc.Spec.ExternalTrafficPolicy = corev1.ServiceExternalTrafficPolicyLocal + } + svc.Spec.Ports = []corev1.ServicePort{ + {Name: "dhcp", Port: kea.DHCP4Port, Protocol: corev1.ProtocolUDP}, + } + return ctrl.SetControllerReference(c, svc, r.Scheme) + }) + return err +} + +func mergeAnnotations(s v1alpha1.ClusterServiceSpec) map[string]string { + out := map[string]string{} + for k, v := range s.Annotations { + out[k] = v + } + if s.IPAddressPool != "" { + out["purelb.io/service-group"] = s.IPAddressPool + } + if len(out) == 0 { + return nil + } + return out +} + +func (r *KeaClusterReconciler) reconcileStatefulSet(ctx context.Context, c *v1alpha1.KeaCluster) (*appsv1.StatefulSet, error) { + hash, err := configHash(ctx, r.Client, c.Namespace, configMapName(c.Name)) + if err != nil { + return nil, err + } + image := c.Spec.Image + if image == "" { + image = kea.DefaultImage + } + replicas := int32(1) + if c.Spec.Replicas != nil { + replicas = *c.Spec.Replicas + } + + sts := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: stsName(c.Name), Namespace: c.Namespace}} + _, err = ctrl.CreateOrUpdate(ctx, r.Client, sts, func() error { + sts.Labels = commonLabels(c.Name) + sts.Spec.ServiceName = headlessName(c.Name) + sts.Spec.Replicas = int32ptr(replicas) + sts.Spec.Selector = &metav1.LabelSelector{MatchLabels: commonLabels(c.Name)} + sts.Spec.PodManagementPolicy = appsv1.ParallelPodManagement + sts.Spec.Template = r.podTemplate(c, image, hash) + return ctrl.SetControllerReference(c, sts, r.Scheme) + }) + if err != nil { + return nil, err + } + return sts, nil +} + +func (r *KeaClusterReconciler) podTemplate(c *v1alpha1.KeaCluster, image, hash string) corev1.PodTemplateSpec { + volProjected := corev1.Volume{ + Name: "kea-etc", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMapName(c.Name)}, + DefaultMode: int32ptr(0o755), + }, + }, + } + volRun := corev1.Volume{Name: "run", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}} + + mounts := []corev1.VolumeMount{ + {Name: "kea-etc", MountPath: kea.ConfigDir, ReadOnly: true}, + {Name: "run", MountPath: kea.RunDir}, + } + + dhcp4 := corev1.Container{ + Name: kea.ContainerDHCP4, + Image: image, + Command: []string{"/bin/sh", kea.ConfigDir + "/entrypoint-dhcp4.sh"}, + Resources: c.Spec.Resources, + Ports: []corev1.ContainerPort{ + {Name: "dhcp", ContainerPort: kea.DHCP4Port, Protocol: corev1.ProtocolUDP}, + }, + VolumeMounts: mounts, + } + agent := corev1.Container{ + Name: kea.ContainerCtrlAgent, + Image: image, + Command: []string{"/bin/sh", kea.ConfigDir + "/entrypoint-ctrlagent.sh"}, + Resources: c.Spec.Resources, + Ports: []corev1.ContainerPort{ + {Name: "ctrl", ContainerPort: kea.CtrlAgentPort, Protocol: corev1.ProtocolTCP}, + }, + VolumeMounts: mounts, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{TCPSocket: &corev1.TCPSocketAction{Port: intstrFromInt(kea.CtrlAgentPort)}}, + InitialDelaySeconds: 5, PeriodSeconds: 10, + }, + } + + labels := commonLabels(c.Name) + return corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + Annotations: map[string]string{"kea.unkin.net/config-hash": hash}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{dhcp4, agent}, + Volumes: []corev1.Volume{volProjected, volRun}, + NodeSelector: c.Spec.NodeSelector, + Tolerations: c.Spec.Tolerations, + Affinity: c.Spec.Affinity, + }, + } +} + +// reloadReadyPods best-effort hot-reloads config on ready pods via the +// ctrl-agent REST channel, analogous to bind-operator's rndc reconfig. +func (r *KeaClusterReconciler) reloadReadyPods(ctx context.Context, c *v1alpha1.KeaCluster) { + if r.Control == nil { + return + } + var pods corev1.PodList + if err := r.List(ctx, &pods, client.InNamespace(c.Namespace), client.MatchingLabels(commonLabels(c.Name))); err != nil { + return + } + l := log.FromContext(ctx) + for i := range pods.Items { + p := &pods.Items[i] + if p.Status.PodIP == "" || !podReady(p) { + continue + } + url := fmt.Sprintf("http://%s:%d/", p.Status.PodIP, kea.CtrlAgentPort) + if err := r.Control.ConfigReload(ctx, url); err != nil { + l.V(1).Info("config-reload failed", "pod", p.Name, "err", err.Error()) + } + } +} + +func (r *KeaClusterReconciler) serviceIP(ctx context.Context, c *v1alpha1.KeaCluster) string { + var svc corev1.Service + if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: serviceName(c.Name)}, &svc); err != nil { + return "" + } + if len(svc.Status.LoadBalancer.Ingress) > 0 { + return svc.Status.LoadBalancer.Ingress[0].IP + } + return svc.Spec.ClusterIP +} + +func podReady(p *corev1.Pod) bool { + for _, cond := range p.Status.Conditions { + if cond.Type == corev1.PodReady { + return cond.Status == corev1.ConditionTrue + } + } + return false +} + +func (r *KeaClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { + mapToClusters := func(ctx context.Context, obj client.Object) []reconcile.Request { + var list v1alpha1.KeaClusterList + if err := r.List(ctx, &list, client.InNamespace(obj.GetNamespace())); err != nil { + return nil + } + var reqs []reconcile.Request + for _, c := range list.Items { + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: c.Namespace, Name: c.Name}}) + } + return reqs + } + + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.KeaCluster{}). + Owns(&appsv1.StatefulSet{}). + Owns(&corev1.Service{}). + Owns(&corev1.ConfigMap{}). + Watches(&v1alpha1.KeaSubnet{}, handler.EnqueueRequestsFromMapFunc(mapToClusters)). + Watches(&v1alpha1.KeaClientClass{}, handler.EnqueueRequestsFromMapFunc(mapToClusters)). + Complete(r) +} diff --git a/internal/controller/keacluster_test.go b/internal/controller/keacluster_test.go new file mode 100644 index 0000000..df91d54 --- /dev/null +++ b/internal/controller/keacluster_test.go @@ -0,0 +1,167 @@ +package controller + +import ( + "context" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" +) + +func testScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(s); err != nil { + t.Fatal(err) + } + if err := v1alpha1.AddToScheme(s); err != nil { + t.Fatal(err) + } + return s +} + +func newClusterFixture() *v1alpha1.KeaCluster { + return &v1alpha1.KeaCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "pxe", Namespace: "dhcp-system"}, + Spec: v1alpha1.KeaClusterSpec{ + Replicas: int32ptr(2), + DomainName: "main.unkin.net", + HA: v1alpha1.HASpec{Mode: v1alpha1.HAHotStandby}, + }, + } +} + +func TestKeaClusterReconcileCreatesWorkload(t *testing.T) { + scheme := testScheme(t) + cluster := newClusterFixture() + subnet := &v1alpha1.KeaSubnet{ + ObjectMeta: metav1.ObjectMeta{Name: "s13", Namespace: "dhcp-system"}, + Spec: v1alpha1.KeaSubnetSpec{Subnet: "198.18.13.0/24", Pools: []string{"198.18.13.200 - 198.18.13.220"}}, + } + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&v1alpha1.KeaCluster{}). + WithObjects(cluster, subnet). + Build() + + r := &KeaClusterReconciler{Client: cl, Scheme: scheme} + if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "dhcp-system", Name: "pxe"}}); err != nil { + t.Fatalf("reconcile: %v", err) + } + + // ConfigMap rendered with the subnet. + var cm corev1.ConfigMap + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: "dhcp-system", Name: "pxe-config"}, &cm); err != nil { + t.Fatalf("configmap not created: %v", err) + } + if got := cm.Data["kea-dhcp4.conf"]; got == "" || !contains(got, "198.18.13.0/24") { + t.Errorf("configmap missing subnet render") + } + + // StatefulSet with 2 containers and a config-hash annotation. + var sts appsv1.StatefulSet + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: "dhcp-system", Name: "pxe"}, &sts); err != nil { + t.Fatalf("statefulset not created: %v", err) + } + if *sts.Spec.Replicas != 2 { + t.Errorf("expected 2 replicas, got %d", *sts.Spec.Replicas) + } + if len(sts.Spec.Template.Spec.Containers) != 2 { + t.Errorf("expected kea-dhcp4 + kea-ctrl-agent containers, got %d", len(sts.Spec.Template.Spec.Containers)) + } + if sts.Spec.Template.Annotations["kea.unkin.net/config-hash"] == "" { + t.Errorf("missing config-hash annotation") + } + + // Anycast + headless services. + for _, name := range []string{"pxe", "pxe-headless"} { + var svc corev1.Service + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: "dhcp-system", Name: name}, &svc); err != nil { + t.Errorf("service %s not created: %v", name, err) + } + } +} + +// TestConfigHashChangesWithSubnets guards the roll trigger: adding a subnet +// must change the pod-template config hash (so the STS rolls). +func TestConfigHashChangesWithSubnets(t *testing.T) { + scheme := testScheme(t) + + hashFor := func(objs ...client.Object) string { + base := []client.Object{newClusterFixture()} + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&v1alpha1.KeaCluster{}). + WithObjects(append(base, objs...)...). + Build() + r := &KeaClusterReconciler{Client: cl, Scheme: scheme} + if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "dhcp-system", Name: "pxe"}}); err != nil { + t.Fatal(err) + } + var sts appsv1.StatefulSet + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: "dhcp-system", Name: "pxe"}, &sts); err != nil { + t.Fatal(err) + } + return sts.Spec.Template.Annotations["kea.unkin.net/config-hash"] + } + + empty := hashFor() + withSubnet := hashFor(&v1alpha1.KeaSubnet{ + ObjectMeta: metav1.ObjectMeta{Name: "s13", Namespace: "dhcp-system"}, + Spec: v1alpha1.KeaSubnetSpec{Subnet: "198.18.13.0/24"}, + }) + if empty == withSubnet { + t.Errorf("config hash did not change when a subnet was added") + } +} + +func TestClusterRefFiltersSubnets(t *testing.T) { + scheme := testScheme(t) + cluster := newClusterFixture() + mine := &v1alpha1.KeaSubnet{ + ObjectMeta: metav1.ObjectMeta{Name: "mine", Namespace: "dhcp-system"}, + Spec: v1alpha1.KeaSubnetSpec{Subnet: "198.18.13.0/24", ClusterRef: "pxe"}, + } + other := &v1alpha1.KeaSubnet{ + ObjectMeta: metav1.ObjectMeta{Name: "other", Namespace: "dhcp-system"}, + Spec: v1alpha1.KeaSubnetSpec{Subnet: "10.9.9.0/24", ClusterRef: "someone-else"}, + } + cl := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&v1alpha1.KeaCluster{}). + WithObjects(cluster, mine, other).Build() + r := &KeaClusterReconciler{Client: cl, Scheme: scheme} + if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "dhcp-system", Name: "pxe"}}); err != nil { + t.Fatal(err) + } + var cm corev1.ConfigMap + _ = cl.Get(context.Background(), types.NamespacedName{Namespace: "dhcp-system", Name: "pxe-config"}, &cm) + conf := cm.Data["kea-dhcp4.conf"] + if !contains(conf, "198.18.13.0/24") { + t.Errorf("cluster-matched subnet missing from config") + } + if contains(conf, "10.9.9.0/24") { + t.Errorf("subnet bound to another cluster leaked into config") + } +} + +func contains(hay, needle string) bool { + return len(hay) >= len(needle) && (indexOf(hay, needle) >= 0) +} + +func indexOf(hay, needle string) int { + for i := 0; i+len(needle) <= len(hay); i++ { + if hay[i:i+len(needle)] == needle { + return i + } + } + return -1 +} diff --git a/internal/controller/keasubnet_controller.go b/internal/controller/keasubnet_controller.go new file mode 100644 index 0000000..a994b05 --- /dev/null +++ b/internal/controller/keasubnet_controller.go @@ -0,0 +1,72 @@ +package controller + +import ( + "context" + "fmt" + "net" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" +) + +// KeaSubnetReconciler validates KeaSubnet CRs and maintains their status. The +// actual kea config is rendered by the KeaCluster controller, which watches +// subnets and re-renders on change. +type KeaSubnetReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=kea.unkin.net,resources=keasubnets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=kea.unkin.net,resources=keasubnets/status,verbs=get;update;patch + +func (r *KeaSubnetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var subnet v1alpha1.KeaSubnet + if err := r.Get(ctx, req.NamespacedName, &subnet); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if _, _, err := net.ParseCIDR(subnet.Spec.Subnet); err != nil { + subnet.Status.Phase = "Invalid" + setReady(&subnet.Status.Conditions, subnet.Generation, false, "InvalidCIDR", err.Error()) + _ = r.Status().Update(ctx, &subnet) + return ctrl.Result{}, nil + } + for _, p := range subnet.Spec.Pools { + if err := validatePool(p); err != nil { + subnet.Status.Phase = "Invalid" + setReady(&subnet.Status.Conditions, subnet.Generation, false, "InvalidPool", err.Error()) + _ = r.Status().Update(ctx, &subnet) + return ctrl.Result{}, nil + } + } + + subnet.Status.Phase = "Ready" + subnet.Status.AssignedID = subnet.Spec.ID + subnet.Status.ObservedGeneration = subnet.Generation + setReady(&subnet.Status.Conditions, subnet.Generation, true, "Validated", "subnet accepted") + if err := r.Status().Update(ctx, &subnet); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func validatePool(p string) error { + // Accept "start - end" (with or without spaces) or a single address. + fields := splitPool(p) + for _, f := range fields { + if net.ParseIP(f) == nil { + return fmt.Errorf("invalid pool address %q in %q", f, p) + } + } + return nil +} + +func (r *KeaSubnetReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.KeaSubnet{}). + Complete(r) +} diff --git a/internal/controller/setup.go b/internal/controller/setup.go new file mode 100644 index 0000000..c4e8f3a --- /dev/null +++ b/internal/controller/setup.go @@ -0,0 +1,37 @@ +package controller + +import ( + ctrl "sigs.k8s.io/controller-runtime" + + "git.unkin.net/unkin/kea-operator/internal/kea" +) + +// SetupAll wires every reconciler into the manager. +func SetupAll(mgr ctrl.Manager) error { + if err := (&KeaClusterReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Control: kea.NewControlClient(), + }).SetupWithManager(mgr); err != nil { + return err + } + if err := (&KeaSubnetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return err + } + if err := (&KeaClientClassReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return err + } + if err := (&KeaAPIReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return err + } + return nil +} diff --git a/internal/kea/agent.go b/internal/kea/agent.go new file mode 100644 index 0000000..45e6df4 --- /dev/null +++ b/internal/kea/agent.go @@ -0,0 +1,51 @@ +package kea + +import "fmt" + +type ctrlAgentRoot struct { + ControlAgent controlAgent `json:"Control-agent"` +} + +type controlAgent struct { + HTTPHost string `json:"http-host"` + HTTPPort int `json:"http-port"` + ControlSockets map[string]map[string]any `json:"control-sockets"` + Loggers []logger `json:"loggers"` +} + +// RenderCtrlAgent renders the deterministic kea-ctrl-agent.conf JSON. The +// control agent exposes the HA/REST control channel on CtrlAgentPort and +// forwards to kea-dhcp4 over the shared unix socket. +func RenderCtrlAgent() (string, error) { + return marshal(ctrlAgentRoot{ControlAgent: controlAgent{ + HTTPHost: "0.0.0.0", + HTTPPort: CtrlAgentPort, + ControlSockets: map[string]map[string]any{ + "dhcp4": {"socket-type": "unix", "socket-name": CtrlSocketPath}, + }, + Loggers: loggers("kea-ctrl-agent"), + }}) +} + +// EntrypointDHCP4 is the kea-dhcp4 container entrypoint. It derives this pod's +// HA peer name from the StatefulSet ordinal, substitutes the placeholder in the +// projected config, and execs the server. +func EntrypointDHCP4() string { + return fmt.Sprintf(`#!/bin/sh +set -e +ORD="${HOSTNAME##*-}" +mkdir -p %[1]s +sed "s/%[2]s/server${ORD}/g" %[3]s/kea-dhcp4.conf > %[4]s +exec %[5]s -c %[4]s +`, RunDir, ThisServerPlaceholder, ConfigDir, DHCP4ConfPath, DHCP4Bin) +} + +// EntrypointCtrlAgent is the kea-ctrl-agent container entrypoint. +func EntrypointCtrlAgent() string { + return fmt.Sprintf(`#!/bin/sh +set -e +mkdir -p %[1]s +cp %[2]s/kea-ctrl-agent.conf %[3]s +exec %[4]s -c %[3]s +`, RunDir, ConfigDir, CtrlAgentConfPath, CtrlAgentBin) +} diff --git a/internal/kea/client.go b/internal/kea/client.go new file mode 100644 index 0000000..46b2972 --- /dev/null +++ b/internal/kea/client.go @@ -0,0 +1,68 @@ +package kea + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// ControlClient talks to a kea-ctrl-agent REST endpoint. +type ControlClient struct { + HTTP *http.Client +} + +// NewControlClient returns a ControlClient with a bounded timeout. +func NewControlClient() *ControlClient { + return &ControlClient{HTTP: &http.Client{Timeout: 5 * time.Second}} +} + +type command struct { + Command string `json:"command"` + Service []string `json:"service,omitempty"` + Arguments any `json:"arguments,omitempty"` +} + +type response struct { + Result int `json:"result"` + Text string `json:"text"` +} + +// ConfigReload asks the dhcp4 server behind the agent at baseURL to re-read its +// config file from disk (the hot-reload path, analogous to rndc reconfig). +func (c *ControlClient) ConfigReload(ctx context.Context, baseURL string) error { + return c.send(ctx, baseURL, command{Command: "config-reload", Service: []string{"dhcp4"}}) +} + +func (c *ControlClient) send(ctx context.Context, baseURL string, cmd command) error { + body, err := json.Marshal(cmd) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("kea control %q: http %d", cmd.Command, resp.StatusCode) + } + var results []response + if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { + return fmt.Errorf("decode kea control response: %w", err) + } + for _, r := range results { + if r.Result != 0 { + return fmt.Errorf("kea control %q failed: %s", cmd.Command, r.Text) + } + } + return nil +} diff --git a/internal/kea/config.go b/internal/kea/config.go new file mode 100644 index 0000000..acb4859 --- /dev/null +++ b/internal/kea/config.go @@ -0,0 +1,337 @@ +package kea + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" +) + +// ThisServerPlaceholder is substituted by each pod's entrypoint with its +// ordinal-derived HA peer name (e.g. "server0"). Keeping it a placeholder in +// the shared config means the ConfigMap is identical across pods and carries +// no pod IPs, so the config hash never triggers a restart loop. +const ThisServerPlaceholder = "@@THIS_SERVER_NAME@@" + +// Peer is a stable HA peer identity (no pod IPs — DNS names only). +type Peer struct { + Name string + URL string + Role string +} + +// RenderInput aggregates a KeaCluster with its matching subnets and client +// classes into everything needed to render the kea configs. +type RenderInput struct { + Cluster v1alpha1.KeaCluster + Subnets []v1alpha1.KeaSubnet + ClientClasses []v1alpha1.KeaClientClass + Peers []Peer +} + +// ---- kea-dhcp4.conf model (field order = JSON key order, deterministic) ---- + +type dhcp4Root struct { + Dhcp4 dhcp4 `json:"Dhcp4"` +} + +type dhcp4 struct { + InterfacesConfig map[string]any `json:"interfaces-config"` + ControlSocket map[string]any `json:"control-socket"` + LeaseDatabase map[string]any `json:"lease-database"` + ValidLifetime int `json:"valid-lifetime"` + MaxValidLifetime int `json:"max-valid-lifetime"` + Authoritative bool `json:"authoritative"` + DDNSSendUpdates bool `json:"ddns-send-updates"` + OptionDef []optionDef `json:"option-def,omitempty"` + OptionData []optionData `json:"option-data,omitempty"` + ClientClasses []clientClass `json:"client-classes,omitempty"` + HooksLibraries []hookLib `json:"hooks-libraries"` + Subnet4 []subnet4 `json:"subnet4"` + Loggers []logger `json:"loggers"` +} + +type optionDef struct { + Name string `json:"name"` + Code int `json:"code"` + Type string `json:"type"` + Space string `json:"space"` + Array bool `json:"array,omitempty"` + RecordTypes string `json:"record-types,omitempty"` + Encapsulate string `json:"encapsulate,omitempty"` +} + +type optionData struct { + Name string `json:"name,omitempty"` + Code int `json:"code,omitempty"` + Space string `json:"space,omitempty"` + Data string `json:"data"` + CSVFormat *bool `json:"csv-format,omitempty"` +} + +type clientClass struct { + Name string `json:"name"` + Test string `json:"test,omitempty"` + BootFileName string `json:"boot-file-name,omitempty"` + NextServer string `json:"next-server,omitempty"` + ServerHostname string `json:"server-hostname,omitempty"` + OptionData []optionData `json:"option-data,omitempty"` +} + +type pool struct { + Pool string `json:"pool"` +} + +type subnet4 struct { + ID int `json:"id"` + Subnet string `json:"subnet"` + Pools []pool `json:"pools,omitempty"` + NextServer string `json:"next-server,omitempty"` + BootFileName string `json:"boot-file-name,omitempty"` + ValidLifetime int `json:"valid-lifetime,omitempty"` + ClientClass string `json:"client-class,omitempty"` + OptionData []optionData `json:"option-data,omitempty"` +} + +type hookLib struct { + Library string `json:"library"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +type logger struct { + Name string `json:"name"` + Severity string `json:"severity"` + OutputOptions []map[string]any `json:"output_options"` +} + +// RenderDHCP4 renders the deterministic kea-dhcp4.conf JSON. +func RenderDHCP4(in RenderInput) (string, error) { + in = sortInput(in) + spec := in.Cluster.Spec + + valid := spec.DefaultLeaseTime + if valid == 0 { + valid = 1200 + } + maxValid := spec.MaxLeaseTime + if maxValid == 0 { + maxValid = 86400 + } + + d := dhcp4{ + InterfacesConfig: map[string]any{"interfaces": []string{"*"}}, + ControlSocket: map[string]any{"socket-type": "unix", "socket-name": CtrlSocketPath}, + LeaseDatabase: map[string]any{"type": "memfile", "persist": false}, + ValidLifetime: valid, + MaxValidLifetime: maxValid, + Authoritative: true, + DDNSSendUpdates: false, + HooksLibraries: hooks(in), + Subnet4: renderSubnets(in), + Loggers: loggers("kea-dhcp4"), + } + + for _, od := range spec.OptionDefs { + space := od.Space + if space == "" { + space = "dhcp4" + } + d.OptionDef = append(d.OptionDef, optionDef{ + Name: od.Name, Code: od.Code, Type: od.Type, Space: space, + Array: od.Array, RecordTypes: od.RecordTypes, Encapsulate: od.Encapsulate, + }) + } + + // Global options shared by every subnet. + if spec.DomainName != "" { + d.OptionData = append(d.OptionData, optionData{Name: "domain-name", Data: spec.DomainName}) + } + if len(spec.NTPServers) > 0 { + d.OptionData = append(d.OptionData, optionData{Name: "ntp-servers", Data: strings.Join(spec.NTPServers, ",")}) + } + + d.ClientClasses = renderClasses(in) + + return marshal(dhcp4Root{Dhcp4: d}) +} + +func renderSubnets(in RenderInput) []subnet4 { + out := make([]subnet4, 0, len(in.Subnets)) + for _, s := range in.Subnets { + sub := subnet4{ + ID: s.Spec.ID, + Subnet: s.Spec.Subnet, + NextServer: s.Spec.NextServer, + BootFileName: s.Spec.BootFileName, + ValidLifetime: s.Spec.ValidLifetime, + } + for _, p := range s.Spec.Pools { + sub.Pools = append(sub.Pools, pool{Pool: normalizePool(p)}) + } + if len(s.Spec.ClientClasses) == 1 { + sub.ClientClass = s.Spec.ClientClasses[0] + } + if len(s.Spec.Routers) > 0 { + sub.OptionData = append(sub.OptionData, optionData{Name: "routers", Data: strings.Join(s.Spec.Routers, ",")}) + } + if len(s.Spec.DNSServers) > 0 { + sub.OptionData = append(sub.OptionData, optionData{Name: "domain-name-servers", Data: strings.Join(s.Spec.DNSServers, ",")}) + } + if s.Spec.DomainName != "" { + sub.OptionData = append(sub.OptionData, optionData{Name: "domain-name", Data: s.Spec.DomainName}) + } + sub.OptionData = append(sub.OptionData, convertOptionData(s.Spec.OptionData)...) + out = append(out, sub) + } + return out +} + +func renderClasses(in RenderInput) []clientClass { + out := make([]clientClass, 0, len(in.ClientClasses)) + for _, c := range in.ClientClasses { + cc := clientClass{ + Name: c.Name, + Test: classTest(c.Spec), + BootFileName: c.Spec.BootFileName, + NextServer: c.Spec.NextServer, + ServerHostname: c.Spec.ServerHostname, + OptionData: convertOptionData(c.Spec.OptionData), + } + out = append(out, cc) + } + return out +} + +// classTest returns the raw test if set, else builds one from ArchHex. +func classTest(spec v1alpha1.KeaClientClassSpec) string { + if spec.Test != "" { + return spec.Test + } + terms := make([]string, 0, len(spec.ArchHex)) + for _, a := range spec.ArchHex { + terms = append(terms, fmt.Sprintf("option[%d].hex == %s", ClientArchOption, a)) + } + return strings.Join(terms, " or ") +} + +func hooks(in RenderInput) []hookLib { + libs := []hookLib{{Library: LeaseCmdsLibrary}} + + peers := make([]map[string]any, 0, len(in.Peers)) + for _, p := range in.Peers { + peers = append(peers, map[string]any{ + "name": p.Name, + "url": p.URL, + "role": p.Role, + "auto-failover": true, + }) + } + + mode := string(in.Cluster.Spec.HA.Mode) + if mode == "" { + mode = string(v1alpha1.HAHotStandby) + } + ha := in.Cluster.Spec.HA + rel := map[string]any{ + "this-server-name": ThisServerPlaceholder, + "mode": mode, + "heartbeat-delay": firstNonZero(ha.HeartbeatDelay, 10000), + "max-response-delay": firstNonZero(ha.MaxResponseDelay, 60000), + "max-ack-delay": firstNonZero(ha.MaxAckDelay, 5000), + "max-unacked-clients": firstNonZero(ha.MaxUnackedClients, 5), + "peers": peers, + } + libs = append(libs, hookLib{ + Library: HALibrary, + Parameters: map[string]any{"high-availability": []any{rel}}, + }) + return libs +} + +func loggers(name string) []logger { + return []logger{{ + Name: name, + Severity: "INFO", + OutputOptions: []map[string]any{ + {"output": "stdout"}, + }, + }} +} + +// AssignSubnetIDs stamps a stable numeric id on every subnet that lacks one, +// choosing the smallest unused positive integer in sorted-CIDR order. +func AssignSubnetIDs(subnets []v1alpha1.KeaSubnet) { + sort.SliceStable(subnets, func(i, j int) bool { return subnets[i].Spec.Subnet < subnets[j].Spec.Subnet }) + used := map[int]bool{} + for i := range subnets { + if subnets[i].Spec.ID > 0 { + used[subnets[i].Spec.ID] = true + } + } + next := 1 + for i := range subnets { + if subnets[i].Spec.ID == 0 { + for used[next] { + next++ + } + subnets[i].Spec.ID = next + used[next] = true + } + } +} + +// sortInput sorts subnets and classes deterministically and assigns subnet ids. +func sortInput(in RenderInput) RenderInput { + subs := make([]v1alpha1.KeaSubnet, len(in.Subnets)) + copy(subs, in.Subnets) + AssignSubnetIDs(subs) + sort.SliceStable(subs, func(i, j int) bool { return subs[i].Spec.ID < subs[j].Spec.ID }) + in.Subnets = subs + + classes := make([]v1alpha1.KeaClientClass, len(in.ClientClasses)) + copy(classes, in.ClientClasses) + sort.SliceStable(classes, func(i, j int) bool { return classes[i].Name < classes[j].Name }) + in.ClientClasses = classes + + peers := make([]Peer, len(in.Peers)) + copy(peers, in.Peers) + sort.SliceStable(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name }) + in.Peers = peers + return in +} + +func convertOptionData(in []v1alpha1.OptionData) []optionData { + out := make([]optionData, 0, len(in)) + for _, o := range in { + out = append(out, optionData{ + Name: o.Name, Code: o.Code, Space: o.Space, Data: o.Data, CSVFormat: o.CSVFormat, + }) + } + return out +} + +// normalizePool ensures the "start - end" spacing Kea expects. +func normalizePool(p string) string { + if strings.Contains(p, "-") && !strings.Contains(p, " - ") { + parts := strings.SplitN(p, "-", 2) + return strings.TrimSpace(parts[0]) + " - " + strings.TrimSpace(parts[1]) + } + return strings.TrimSpace(p) +} + +func firstNonZero(v, def int) int { + if v != 0 { + return v + } + return def +} + +func marshal(v any) (string, error) { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "", err + } + return string(b) + "\n", nil +} diff --git a/internal/kea/config_test.go b/internal/kea/config_test.go new file mode 100644 index 0000000..d6c29cc --- /dev/null +++ b/internal/kea/config_test.go @@ -0,0 +1,234 @@ +package kea + +import ( + "encoding/json" + "strings" + "testing" + + v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// referenceInput mirrors the ISC dhcpd config that must be fully representable: +// subnets 198.18.13-17.0/24 (pool .200-.220, routers, dns .19.15, next-server +// .19.19, domain main.unkin.net), plus 198.18.25.0/24 with no pool; the two PXE +// arch classes; authoritative; ddns off. +func referenceInput() RenderInput { + cluster := v1alpha1.KeaCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "pxe"}, + Spec: v1alpha1.KeaClusterSpec{ + DomainName: "main.unkin.net", + DefaultLeaseTime: 1200, + MaxLeaseTime: 86400, + HA: v1alpha1.HASpec{Mode: v1alpha1.HAHotStandby}, + }, + } + + mkSubnet := func(name, cidr string, withPool bool) v1alpha1.KeaSubnet { + s := v1alpha1.KeaSubnet{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: v1alpha1.KeaSubnetSpec{ + Subnet: cidr, + Routers: []string{strings.TrimSuffix(cidr, "0/24") + "1"}, + DNSServers: []string{"198.18.19.15"}, + DomainName: "main.unkin.net", + NextServer: "198.18.19.19", + }, + } + if withPool { + base := strings.TrimSuffix(cidr, "0/24") + s.Spec.Pools = []string{base + "200 - " + base + "220"} + } + return s + } + + subnets := []v1alpha1.KeaSubnet{ + mkSubnet("s13", "198.18.13.0/24", true), + mkSubnet("s14", "198.18.14.0/24", true), + mkSubnet("s15", "198.18.15.0/24", true), + mkSubnet("s16", "198.18.16.0/24", true), + mkSubnet("s17", "198.18.17.0/24", true), + mkSubnet("s25", "198.18.25.0/24", false), + } + + classes := []v1alpha1.KeaClientClass{ + {ObjectMeta: metav1.ObjectMeta{Name: "Legacy"}, Spec: v1alpha1.KeaClientClassSpec{ + ArchHex: []string{"0x0000"}, BootFileName: "/undionly.kpxe"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "UEFI-64"}, Spec: v1alpha1.KeaClientClassSpec{ + ArchHex: []string{"0x0007", "0x0009"}, BootFileName: "/ipxe.efi"}}, + } + + peers := []Peer{ + {Name: "server0", URL: "http://pxe-0.pxe-headless.dhcp-system:8000/", Role: "primary"}, + {Name: "server1", URL: "http://pxe-1.pxe-headless.dhcp-system:8000/", Role: "standby"}, + } + + return RenderInput{Cluster: cluster, Subnets: subnets, ClientClasses: classes, Peers: peers} +} + +func TestRenderDHCP4IsValidJSON(t *testing.T) { + out, err := RenderDHCP4(referenceInput()) + if err != nil { + t.Fatalf("render: %v", err) + } + var root map[string]any + if err := json.Unmarshal([]byte(out), &root); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, out) + } + if _, ok := root["Dhcp4"]; !ok { + t.Fatalf("missing Dhcp4 top-level key") + } +} + +func TestRenderDHCP4ReferenceSemantics(t *testing.T) { + out, err := RenderDHCP4(referenceInput()) + if err != nil { + t.Fatalf("render: %v", err) + } + must := []string{ + `"authoritative": true`, + `"ddns-send-updates": false`, + `"valid-lifetime": 1200`, + `"max-valid-lifetime": 86400`, + `"198.18.13.0/24"`, + `"198.18.25.0/24"`, + `"198.18.13.200 - 198.18.13.220"`, + `"next-server": "198.18.19.19"`, + `"data": "198.18.19.15"`, // domain-name-servers + `"data": "198.18.13.1"`, // routers + `"data": "main.unkin.net"`, // domain-name + `"boot-file-name": "/undionly.kpxe"`, + `"boot-file-name": "/ipxe.efi"`, + `option[93].hex == 0x0000`, + `option[93].hex == 0x0007 or option[93].hex == 0x0009`, + `libdhcp_ha.so`, + `libdhcp_lease_cmds.so`, + `"mode": "hot-standby"`, + ThisServerPlaceholder, + `memfile`, + } + for _, m := range must { + if !strings.Contains(out, m) { + t.Errorf("rendered config missing %q\n---\n%s", m, out) + } + } +} + +// TestSubnetWithoutPoolIsDeclared verifies the pool-less subnet still appears +// (Kea must know the subnet to service relayed requests) but carries no pools. +func TestSubnetWithoutPoolIsDeclared(t *testing.T) { + out, err := RenderDHCP4(referenceInput()) + if err != nil { + t.Fatalf("render: %v", err) + } + var root dhcp4Root + if err := json.Unmarshal([]byte(out), &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } + var found bool + for _, s := range root.Dhcp4.Subnet4 { + if s.Subnet == "198.18.25.0/24" { + found = true + if len(s.Pools) != 0 { + t.Errorf("198.18.25.0/24 should have no pools, got %v", s.Pools) + } + } + } + if !found { + t.Fatalf("pool-less subnet 198.18.25.0/24 not declared") + } +} + +// TestRenderDeterministicWithShuffledInput asserts byte-identical output +// regardless of input ordering — unsorted input would churn the ConfigMap and +// trigger a restart loop. +func TestRenderDeterministicWithShuffledInput(t *testing.T) { + a := referenceInput() + b := referenceInput() + // shuffle b + b.Subnets[0], b.Subnets[5] = b.Subnets[5], b.Subnets[0] + b.ClientClasses[0], b.ClientClasses[1] = b.ClientClasses[1], b.ClientClasses[0] + b.Peers[0], b.Peers[1] = b.Peers[1], b.Peers[0] + + oa, err := RenderDHCP4(a) + if err != nil { + t.Fatal(err) + } + ob, err := RenderDHCP4(b) + if err != nil { + t.Fatal(err) + } + if oa != ob { + t.Errorf("render not deterministic under shuffled input\n--A--\n%s\n--B--\n%s", oa, ob) + } +} + +// TestNoPodIPsInConfig guards the restart-loop invariant: the rendered config +// (which drives the config hash) must contain only stable DNS peer names. +func TestNoPodIPsInConfig(t *testing.T) { + out, err := RenderDHCP4(referenceInput()) + if err != nil { + t.Fatal(err) + } + for _, ip := range []string{"10.", "172.", "192.168."} { + if strings.Contains(out, `"url": "http://`+ip) { + t.Errorf("pod IP leaked into HA peer url (contains %q)", ip) + } + } + if !strings.Contains(out, "pxe-headless") { + t.Errorf("expected stable headless DNS peer url") + } +} + +func TestSubnetIDAssignmentStableAndUnique(t *testing.T) { + in := referenceInput() + out, err := RenderDHCP4(in) + if err != nil { + t.Fatal(err) + } + var root dhcp4Root + if err := json.Unmarshal([]byte(out), &root); err != nil { + t.Fatal(err) + } + seen := map[int]bool{} + for _, s := range root.Dhcp4.Subnet4 { + if s.ID <= 0 { + t.Errorf("subnet %s has invalid id %d", s.Subnet, s.ID) + } + if seen[s.ID] { + t.Errorf("duplicate subnet id %d", s.ID) + } + seen[s.ID] = true + } + if len(seen) != 6 { + t.Errorf("expected 6 unique subnet ids, got %d", len(seen)) + } +} + +func TestExplicitSubnetIDPreserved(t *testing.T) { + in := referenceInput() + in.Subnets[2].Spec.ID = 42 + out, err := RenderDHCP4(in) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"id": 42`) { + t.Errorf("explicit subnet id 42 not preserved") + } +} + +func TestRenderCtrlAgent(t *testing.T) { + out, err := RenderCtrlAgent() + if err != nil { + t.Fatal(err) + } + var root map[string]any + if err := json.Unmarshal([]byte(out), &root); err != nil { + t.Fatalf("ctrl-agent config not valid JSON: %v", err) + } + for _, m := range []string{`"http-port": 8000`, `kea4-ctrl-socket`, `"dhcp4"`} { + if !strings.Contains(out, m) { + t.Errorf("ctrl-agent config missing %q", m) + } + } +} diff --git a/internal/kea/consts.go b/internal/kea/consts.go new file mode 100644 index 0000000..29d3852 --- /dev/null +++ b/internal/kea/consts.go @@ -0,0 +1,47 @@ +package kea + +// Filesystem and binary paths inside the kea container image, plus the +// operator's label/annotation vocabulary. +const ( + // ContainerDHCP4 is the kea-dhcp4 container name. + ContainerDHCP4 = "kea-dhcp4" + // ContainerCtrlAgent is the kea-ctrl-agent container name. + ContainerCtrlAgent = "kea-ctrl-agent" + + // ConfigDir is where projected config is mounted read-only. + ConfigDir = "/etc/kea-operator" + // RunDir is a shared emptyDir for the config copy and control socket. + RunDir = "/run/kea" + + // DHCP4ConfPath is the runtime kea-dhcp4 config. + DHCP4ConfPath = RunDir + "/kea-dhcp4.conf" + // CtrlAgentConfPath is the runtime kea-ctrl-agent config. + CtrlAgentConfPath = RunDir + "/kea-ctrl-agent.conf" + // CtrlSocketPath is the unix control socket between ctrl-agent and dhcp4. + CtrlSocketPath = RunDir + "/kea4-ctrl-socket" + // EntrypointPath is the generated container entrypoint. + EntrypointPath = ConfigDir + "/entrypoint.sh" + + // DHCP4Bin is the kea-dhcp4 server binary. + DHCP4Bin = "/usr/sbin/kea-dhcp4" + // CtrlAgentBin is the kea-ctrl-agent binary. + CtrlAgentBin = "/usr/sbin/kea-ctrl-agent" + + // HooksDir holds the Kea hook libraries. + HooksDir = "/usr/lib64/kea/hooks" + // HALibrary is the High Availability hook. + HALibrary = HooksDir + "/libdhcp_ha.so" + // LeaseCmdsLibrary is the lease commands hook (required by HA lease sync). + LeaseCmdsLibrary = HooksDir + "/libdhcp_lease_cmds.so" + + // CtrlAgentPort is the REST control channel port. + CtrlAgentPort = 8000 + // DHCP4Port is the DHCPv4 server port. + DHCP4Port = 67 + + // DefaultImage is the kea workload image built by this repo. + DefaultImage = "git.unkin.net/unkin/kea:latest" + + // ClientArchOption is the DHCP option code carrying PXE client arch. + ClientArchOption = 93 +) diff --git a/internal/keaapi/models.go b/internal/keaapi/models.go new file mode 100644 index 0000000..f4bc935 --- /dev/null +++ b/internal/keaapi/models.go @@ -0,0 +1,117 @@ +package keaapi + +import v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" + +// OptionDataAPI is the wire form of a DHCP option value. +type OptionDataAPI struct { + Name string `json:"name,omitempty"` + Code int `json:"code,omitempty"` + Space string `json:"space,omitempty"` + Data string `json:"data"` +} + +// SubnetAPI is the JSON contract for a subnet resource. Name is the stable id +// (the CR name) and is authoritative from the URL path. +type SubnetAPI struct { + Name string `json:"name"` + ClusterRef string `json:"cluster_ref,omitempty"` + Subnet string `json:"subnet"` + ID int `json:"id,omitempty"` + Pools []string `json:"pools,omitempty"` + Routers []string `json:"routers,omitempty"` + DNSServers []string `json:"dns_servers,omitempty"` + DomainName string `json:"domain_name,omitempty"` + NextServer string `json:"next_server,omitempty"` + BootFileName string `json:"boot_file_name,omitempty"` + ClientClasses []string `json:"client_classes,omitempty"` + ValidLifetime int `json:"valid_lifetime,omitempty"` + OptionData []OptionDataAPI `json:"option_data,omitempty"` +} + +// ClientClassAPI is the JSON contract for a PXE client-class resource. +type ClientClassAPI struct { + Name string `json:"name"` + ClusterRef string `json:"cluster_ref,omitempty"` + Test string `json:"test,omitempty"` + ArchHex []string `json:"arch_hex,omitempty"` + BootFileName string `json:"boot_file_name,omitempty"` + NextServer string `json:"next_server,omitempty"` + ServerHostname string `json:"server_hostname,omitempty"` + OptionData []OptionDataAPI `json:"option_data,omitempty"` +} + +func optionDataToAPI(in []v1alpha1.OptionData) []OptionDataAPI { + out := make([]OptionDataAPI, 0, len(in)) + for _, o := range in { + out = append(out, OptionDataAPI{Name: o.Name, Code: o.Code, Space: o.Space, Data: o.Data}) + } + return out +} + +func optionDataFromAPI(in []OptionDataAPI) []v1alpha1.OptionData { + out := make([]v1alpha1.OptionData, 0, len(in)) + for _, o := range in { + out = append(out, v1alpha1.OptionData{Name: o.Name, Code: o.Code, Space: o.Space, Data: o.Data}) + } + return out +} + +func subnetToAPI(s *v1alpha1.KeaSubnet) SubnetAPI { + return SubnetAPI{ + Name: s.Name, + ClusterRef: s.Spec.ClusterRef, + Subnet: s.Spec.Subnet, + ID: s.Spec.ID, + Pools: s.Spec.Pools, + Routers: s.Spec.Routers, + DNSServers: s.Spec.DNSServers, + DomainName: s.Spec.DomainName, + NextServer: s.Spec.NextServer, + BootFileName: s.Spec.BootFileName, + ClientClasses: s.Spec.ClientClasses, + ValidLifetime: s.Spec.ValidLifetime, + OptionData: optionDataToAPI(s.Spec.OptionData), + } +} + +func subnetSpecFromAPI(a SubnetAPI) v1alpha1.KeaSubnetSpec { + return v1alpha1.KeaSubnetSpec{ + ClusterRef: a.ClusterRef, + Subnet: a.Subnet, + ID: a.ID, + Pools: a.Pools, + Routers: a.Routers, + DNSServers: a.DNSServers, + DomainName: a.DomainName, + NextServer: a.NextServer, + BootFileName: a.BootFileName, + ClientClasses: a.ClientClasses, + ValidLifetime: a.ValidLifetime, + OptionData: optionDataFromAPI(a.OptionData), + } +} + +func classToAPI(c *v1alpha1.KeaClientClass) ClientClassAPI { + return ClientClassAPI{ + Name: c.Name, + ClusterRef: c.Spec.ClusterRef, + Test: c.Spec.Test, + ArchHex: c.Spec.ArchHex, + BootFileName: c.Spec.BootFileName, + NextServer: c.Spec.NextServer, + ServerHostname: c.Spec.ServerHostname, + OptionData: optionDataToAPI(c.Spec.OptionData), + } +} + +func classSpecFromAPI(a ClientClassAPI) v1alpha1.KeaClientClassSpec { + return v1alpha1.KeaClientClassSpec{ + ClusterRef: a.ClusterRef, + Test: a.Test, + ArchHex: a.ArchHex, + BootFileName: a.BootFileName, + NextServer: a.NextServer, + ServerHostname: a.ServerHostname, + OptionData: optionDataFromAPI(a.OptionData), + } +} diff --git a/internal/keaapi/server.go b/internal/keaapi/server.go new file mode 100644 index 0000000..16e12ce --- /dev/null +++ b/internal/keaapi/server.go @@ -0,0 +1,195 @@ +package keaapi + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/go-logr/logr" +) + +// Server is the REST API exposing CRUD over KeaSubnet / KeaClientClass CRs. +type Server struct { + Store Store + Token string + Log logr.Logger +} + +// Handler builds the routed http.Handler. Reads and writes are both token +// guarded (the whole surface mutates cluster state indirectly). Go 1.22+ +// pattern routing gives chi-style method+path matching with no dependency. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + mux.Handle("GET /api/v1/subnets", s.auth(http.HandlerFunc(s.listSubnets))) + mux.Handle("GET /api/v1/subnets/{name}", s.auth(http.HandlerFunc(s.getSubnet))) + mux.Handle("PUT /api/v1/subnets/{name}", s.auth(http.HandlerFunc(s.putSubnet))) + mux.Handle("DELETE /api/v1/subnets/{name}", s.auth(http.HandlerFunc(s.deleteSubnet))) + + mux.Handle("GET /api/v1/clientclasses", s.auth(http.HandlerFunc(s.listClasses))) + mux.Handle("GET /api/v1/clientclasses/{name}", s.auth(http.HandlerFunc(s.getClass))) + mux.Handle("PUT /api/v1/clientclasses/{name}", s.auth(http.HandlerFunc(s.putClass))) + mux.Handle("DELETE /api/v1/clientclasses/{name}", s.auth(http.HandlerFunc(s.deleteClass))) + + return mux +} + +// ListenAndServe runs the server until ctx is cancelled. +func (s *Server) ListenAndServe(ctx context.Context, addr string) error { + srv := &http.Server{Addr: addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second} + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +func (s *Server) auth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.Token == "" { + writeError(w, http.StatusServiceUnavailable, "auth disabled: KEA_API_TOKEN not set") + return + } + presented := bearer(r) + if presented == "" || subtle.ConstantTimeCompare([]byte(presented), []byte(s.Token)) != 1 { + writeError(w, http.StatusUnauthorized, "invalid or missing token") + return + } + next.ServeHTTP(w, r) + }) +} + +func bearer(r *http.Request) string { + if h := r.Header.Get("Authorization"); h != "" { + if after, ok := strings.CutPrefix(h, "Bearer "); ok { + return after + } + } + return r.Header.Get("token") +} + +// ---- subnet handlers ---- + +func (s *Server) listSubnets(w http.ResponseWriter, r *http.Request) { + items, err := s.Store.ListSubnets(r.Context()) + if err != nil { + s.fail(w, err) + return + } + writeJSON(w, http.StatusOK, items) +} + +func (s *Server) getSubnet(w http.ResponseWriter, r *http.Request) { + item, err := s.Store.GetSubnet(r.Context(), r.PathValue("name")) + if err != nil { + s.fail(w, err) + return + } + writeJSON(w, http.StatusOK, item) +} + +func (s *Server) putSubnet(w http.ResponseWriter, r *http.Request) { + var in SubnetAPI + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + in.Name = r.PathValue("name") + if in.Subnet == "" { + writeError(w, http.StatusBadRequest, "subnet is required") + return + } + out, err := s.Store.UpsertSubnet(r.Context(), in) + if err != nil { + s.fail(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) deleteSubnet(w http.ResponseWriter, r *http.Request) { + if err := s.Store.DeleteSubnet(r.Context(), r.PathValue("name")); err != nil { + s.fail(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ---- client class handlers ---- + +func (s *Server) listClasses(w http.ResponseWriter, r *http.Request) { + items, err := s.Store.ListClasses(r.Context()) + if err != nil { + s.fail(w, err) + return + } + writeJSON(w, http.StatusOK, items) +} + +func (s *Server) getClass(w http.ResponseWriter, r *http.Request) { + item, err := s.Store.GetClass(r.Context(), r.PathValue("name")) + if err != nil { + s.fail(w, err) + return + } + writeJSON(w, http.StatusOK, item) +} + +func (s *Server) putClass(w http.ResponseWriter, r *http.Request) { + var in ClientClassAPI + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + in.Name = r.PathValue("name") + if in.Test == "" && len(in.ArchHex) == 0 { + writeError(w, http.StatusBadRequest, "either test or arch_hex is required") + return + } + out, err := s.Store.UpsertClass(r.Context(), in) + if err != nil { + s.fail(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) deleteClass(w http.ResponseWriter, r *http.Request) { + if err := s.Store.DeleteClass(r.Context(), r.PathValue("name")); err != nil { + s.fail(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) fail(w http.ResponseWriter, err error) { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "not found") + return + } + s.Log.Error(err, "request failed") + writeError(w, http.StatusInternalServerError, err.Error()) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/internal/keaapi/server_test.go b/internal/keaapi/server_test.go new file mode 100644 index 0000000..957ddff --- /dev/null +++ b/internal/keaapi/server_test.go @@ -0,0 +1,178 @@ +package keaapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" +) + +const testToken = "s3cr3t" + +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + srv := &Server{ + Store: &K8sStore{Client: cl, Namespace: "dhcp-system"}, + Token: testToken, + Log: logr.Discard(), + } + return httptest.NewServer(srv.Handler()) +} + +func do(t *testing.T, method, url, token string, body any) *http.Response { + t.Helper() + var buf bytes.Buffer + if body != nil { + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatal(err) + } + } + req, err := http.NewRequestWithContext(context.Background(), method, url, &buf) + if err != nil { + t.Fatal(err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return resp +} + +func TestSubnetCRUDLifecycle(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + base := ts.URL + "/api/v1/subnets/net13" + + // PUT create + resp := do(t, http.MethodPut, base, testToken, SubnetAPI{ + Subnet: "198.18.13.0/24", Pools: []string{"198.18.13.200 - 198.18.13.220"}, + Routers: []string{"198.18.13.1"}, NextServer: "198.18.19.19", + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("PUT create: got %d", resp.StatusCode) + } + var created SubnetAPI + _ = json.NewDecoder(resp.Body).Decode(&created) + resp.Body.Close() + if created.Name != "net13" { + t.Errorf("name not stamped from URL, got %q", created.Name) + } + + // GET + resp = do(t, http.MethodGet, base, testToken, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET: got %d", resp.StatusCode) + } + resp.Body.Close() + + // PUT update (idempotent upsert) + resp = do(t, http.MethodPut, base, testToken, SubnetAPI{Subnet: "198.18.13.0/24", DomainName: "main.unkin.net"}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("PUT update: got %d", resp.StatusCode) + } + var updated SubnetAPI + _ = json.NewDecoder(resp.Body).Decode(&updated) + resp.Body.Close() + if updated.DomainName != "main.unkin.net" { + t.Errorf("update not applied") + } + + // LIST + resp = do(t, http.MethodGet, ts.URL+"/api/v1/subnets", testToken, nil) + var list []SubnetAPI + _ = json.NewDecoder(resp.Body).Decode(&list) + resp.Body.Close() + if len(list) != 1 { + t.Errorf("expected 1 subnet, got %d", len(list)) + } + + // DELETE + resp = do(t, http.MethodDelete, base, testToken, nil) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("DELETE: got %d", resp.StatusCode) + } + resp.Body.Close() + + // GET after delete -> 404 (drives provider drift handling) + resp = do(t, http.MethodGet, base, testToken, nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("GET after delete: expected 404, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestAuthRequired(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + // no token + resp := do(t, http.MethodGet, ts.URL+"/api/v1/subnets", "", nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 without token, got %d", resp.StatusCode) + } + resp.Body.Close() + // wrong token + resp = do(t, http.MethodGet, ts.URL+"/api/v1/subnets", "nope", nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 with bad token, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestHealthzOpen(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + resp := do(t, http.MethodGet, ts.URL+"/healthz", "", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("healthz should be open, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestPutSubnetValidation(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + resp := do(t, http.MethodPut, ts.URL+"/api/v1/subnets/bad", testToken, SubnetAPI{}) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for missing subnet, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestClientClassCRUD(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + base := ts.URL + "/api/v1/clientclasses/UEFI-64" + resp := do(t, http.MethodPut, base, testToken, ClientClassAPI{ + ArchHex: []string{"0x0007", "0x0009"}, BootFileName: "/ipxe.efi", + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("PUT class: got %d", resp.StatusCode) + } + resp.Body.Close() + + resp = do(t, http.MethodPut, ts.URL+"/api/v1/clientclasses/empty", testToken, ClientClassAPI{}) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for class with no match, got %d", resp.StatusCode) + } + resp.Body.Close() +} diff --git a/internal/keaapi/store.go b/internal/keaapi/store.go new file mode 100644 index 0000000..20f3b02 --- /dev/null +++ b/internal/keaapi/store.go @@ -0,0 +1,122 @@ +package keaapi + +import ( + "context" + "errors" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" +) + +// ErrNotFound is the sentinel the HTTP layer maps to 404. +var ErrNotFound = errors.New("not found") + +// Store is the persistence seam. The HTTP handlers depend only on this; the +// k8s implementation below CRUDs CRs, but it could be swapped for any backend. +type Store interface { + UpsertSubnet(ctx context.Context, a SubnetAPI) (SubnetAPI, error) + GetSubnet(ctx context.Context, name string) (SubnetAPI, error) + ListSubnets(ctx context.Context) ([]SubnetAPI, error) + DeleteSubnet(ctx context.Context, name string) error + + UpsertClass(ctx context.Context, a ClientClassAPI) (ClientClassAPI, error) + GetClass(ctx context.Context, name string) (ClientClassAPI, error) + ListClasses(ctx context.Context) ([]ClientClassAPI, error) + DeleteClass(ctx context.Context, name string) error +} + +// K8sStore backs the API with KeaSubnet / KeaClientClass CRs in a namespace. +type K8sStore struct { + Client client.Client + Namespace string +} + +func (s *K8sStore) key(name string) types.NamespacedName { + return types.NamespacedName{Namespace: s.Namespace, Name: name} +} + +func (s *K8sStore) UpsertSubnet(ctx context.Context, a SubnetAPI) (SubnetAPI, error) { + obj := &v1alpha1.KeaSubnet{ObjectMeta: metav1.ObjectMeta{Name: a.Name, Namespace: s.Namespace}} + if _, err := controllerutil.CreateOrUpdate(ctx, s.Client, obj, func() error { + obj.Spec = subnetSpecFromAPI(a) + return nil + }); err != nil { + return SubnetAPI{}, err + } + return subnetToAPI(obj), nil +} + +func (s *K8sStore) GetSubnet(ctx context.Context, name string) (SubnetAPI, error) { + var obj v1alpha1.KeaSubnet + if err := s.Client.Get(ctx, s.key(name), &obj); err != nil { + return SubnetAPI{}, mapGet(err) + } + return subnetToAPI(&obj), nil +} + +func (s *K8sStore) ListSubnets(ctx context.Context) ([]SubnetAPI, error) { + var list v1alpha1.KeaSubnetList + if err := s.Client.List(ctx, &list, client.InNamespace(s.Namespace)); err != nil { + return nil, err + } + out := make([]SubnetAPI, 0, len(list.Items)) + for i := range list.Items { + out = append(out, subnetToAPI(&list.Items[i])) + } + return out, nil +} + +func (s *K8sStore) DeleteSubnet(ctx context.Context, name string) error { + obj := &v1alpha1.KeaSubnet{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: s.Namespace}} + return mapGet(s.Client.Delete(ctx, obj)) +} + +func (s *K8sStore) UpsertClass(ctx context.Context, a ClientClassAPI) (ClientClassAPI, error) { + obj := &v1alpha1.KeaClientClass{ObjectMeta: metav1.ObjectMeta{Name: a.Name, Namespace: s.Namespace}} + if _, err := controllerutil.CreateOrUpdate(ctx, s.Client, obj, func() error { + obj.Spec = classSpecFromAPI(a) + return nil + }); err != nil { + return ClientClassAPI{}, err + } + return classToAPI(obj), nil +} + +func (s *K8sStore) GetClass(ctx context.Context, name string) (ClientClassAPI, error) { + var obj v1alpha1.KeaClientClass + if err := s.Client.Get(ctx, s.key(name), &obj); err != nil { + return ClientClassAPI{}, mapGet(err) + } + return classToAPI(&obj), nil +} + +func (s *K8sStore) ListClasses(ctx context.Context) ([]ClientClassAPI, error) { + var list v1alpha1.KeaClientClassList + if err := s.Client.List(ctx, &list, client.InNamespace(s.Namespace)); err != nil { + return nil, err + } + out := make([]ClientClassAPI, 0, len(list.Items)) + for i := range list.Items { + out = append(out, classToAPI(&list.Items[i])) + } + return out, nil +} + +func (s *K8sStore) DeleteClass(ctx context.Context, name string) error { + obj := &v1alpha1.KeaClientClass{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: s.Namespace}} + return mapGet(s.Client.Delete(ctx, obj)) +} + +func mapGet(err error) error { + if apierrors.IsNotFound(err) { + return ErrNotFound + } + return err +} + +var _ Store = (*K8sStore)(nil) From 4f2567f5ba3b79bad34ed9efc5bf9301162ecdb4 Mon Sep 17 00:00:00 2001 From: unkinben Date: Sun, 2 Aug 2026 19:02:13 +1000 Subject: [PATCH 2/2] Drop the pool-less 198.18.25.0/24 subnet from the migration samples The 'local' subnet is not needed in the kea migration. Remove it from the ISC translation sample; the pool-less-subnet capability and its unit tests remain. - remove net-198-18-25 KeaSubnet from config/samples/01-subnets.yaml Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT --- config/samples/01-subnets.yaml | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/config/samples/01-subnets.yaml b/config/samples/01-subnets.yaml index e8c448a..69677e0 100644 --- a/config/samples/01-subnets.yaml +++ b/config/samples/01-subnets.yaml @@ -1,6 +1,5 @@ -# Full translation of the legacy ISC dhcpd pools: 198.18.13-17.0/24 each with a -# .200-.220 pool, plus 198.18.25.0/24 with no pool (declared so relayed requests -# are still serviced). routers .1, dns 198.18.19.15, next-server 198.18.19.19. +# Translation of the legacy ISC dhcpd pools: 198.18.13-17.0/24 each with a +# .200-.220 pool. routers .1, dns 198.18.19.15, next-server 198.18.19.19. apiVersion: kea.unkin.net/v1alpha1 kind: KeaSubnet metadata: @@ -75,17 +74,3 @@ spec: dnsServers: [198.18.19.15] domainName: main.unkin.net nextServer: 198.18.19.19 ---- -# No pool: declared so relayed DHCP requests on this net are matched and get -# options, but no dynamic address is allocated (subnet-mask derives from CIDR). -apiVersion: kea.unkin.net/v1alpha1 -kind: KeaSubnet -metadata: - name: net-198-18-25 - namespace: dhcp-system -spec: - clusterRef: pxe - subnet: 198.18.25.0/24 - routers: [198.18.25.1] - dnsServers: [198.18.19.15] - domainName: main.unkin.net