Merge pull request 'Scaffold kea-operator: CRDs, controllers, HA rendering, REST API, CI' (#1) from benvin/initial into main
ci/woodpecker/tag/docker Pipeline was successful
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
/bin/
|
||||
*.out
|
||||
*.test
|
||||
.env
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ./...
|
||||
@@ -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/...
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// +kubebuilder:object:generate=true
|
||||
// +groupName=kea.unkin.net
|
||||
package v1alpha1
|
||||
@@ -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
|
||||
)
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,76 @@
|
||||
# Translation of the legacy ISC dhcpd pools: 198.18.13-17.0/24 each with a
|
||||
# .200-.220 pool. routers .1, dns 198.18.19.15, next-server 198.18.19.19.
|
||||
apiVersion: kea.unkin.net/v1alpha1
|
||||
kind: KeaSubnet
|
||||
metadata:
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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])}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user