Scaffold kea-operator: CRDs, controllers, config rendering, REST API, CI
ci/woodpecker/pr/build Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline failed
ci/woodpecker/pr/test Pipeline failed

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
This commit is contained in:
unkinben
2026-08-02 17:19:53 +10:00
parent 9d471b0bff
commit d3fb5dcd1a
50 changed files with 10379 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
/bin/
*.out
*.test
.env
+37
View File
@@ -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
+73
View File
@@ -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
+19
View File
@@ -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 ./...
+18
View File
@@ -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/...
+12
View File
@@ -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"]
+20
View File
@@ -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"]
+11
View File
@@ -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"]
+63
View File
@@ -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
+85 -1
View File
@@ -1,3 +1,87 @@
# kea-operator
Kubernetes operator for managing Kea DHCP clusters, subnets, and PXE client classes
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 (`<cluster>-0`, `<cluster>-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).
+3
View File
@@ -0,0 +1,3 @@
// +kubebuilder:object:generate=true
// +groupName=kea.unkin.net
package v1alpha1
+18
View File
@@ -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
)
+99
View File
@@ -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{})
}
+82
View File
@@ -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{})
}
+209
View File
@@ -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{})
}
+121
View File
@@ -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{})
}
+596
View File
@@ -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
}
+67
View File
@@ -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)
}
}
+68
View File
@@ -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)
}
}
File diff suppressed because it is too large Load Diff
@@ -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: {}
File diff suppressed because it is too large Load Diff
@@ -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: {}
File diff suppressed because it is too large Load Diff
+81
View File
@@ -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
+30
View File
@@ -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
+91
View File
@@ -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
+21
View File
@@ -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
+21
View File
@@ -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
+68
View File
@@ -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
)
+198
View File
@@ -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=
+5
View File
@@ -0,0 +1,5 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: dhcp-system
@@ -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
+46
View File
@@ -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
+104
View File
@@ -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])}
}
+253
View File
@@ -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)
}
@@ -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)
}
@@ -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)
}
+167
View File
@@ -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
}
@@ -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)
}
+37
View File
@@ -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
}
+51
View File
@@ -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)
}
+68
View File
@@ -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
}
+337
View File
@@ -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
}
+234
View File
@@ -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)
}
}
}
+47
View File
@@ -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
)
+117
View File
@@ -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),
}
}
+195
View File
@@ -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})
}
+178
View File
@@ -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()
}
+122
View File
@@ -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)