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

Replace the ISC dhcpd PXE-boot VM with a Kea DHCP Kubernetes operator, modelled
on bind-operator. The operator renders kea-dhcp4 config from CRs and runs an HA
pair of kea-dhcp4 + kea-ctrl-agent servers behind an anycast Service.

- add KeaCluster/KeaSubnet/KeaClientClass/KeaAPI CRDs (group kea.unkin.net)
- render deterministic kea-dhcp4.conf + kea-ctrl-agent.conf into a ConfigMap and
  roll the StatefulSet via a config-hash annotation; best-effort hot-reload via
  the kea-ctrl-agent REST channel
- run HA hot-standby (memfile leases) with stable per-peer DNS identity from a
  StatefulSet; expose an anycast LoadBalancer Service for PureLB
- represent the full legacy dhcpd config: 198.18.13-17.0/24 pools, pool-less
  198.18.25.0/24, and the Legacy/UEFI-64 PXE arch classes (option 93)
- add the KeaAPI-spawned REST service: Terraform-friendly CRUD over subnet and
  client-class CRs (stable IDs, PUT upsert, 404 drift, bearer-token auth)
- add Makefile (patch/minor/major tag targets), distroless operator/api images,
  an AlmaLinux+EPEL kea workload image, and woodpecker CI with k8s resources +
  serviceAccountName on every step
- unit tests for config rendering, controller reconcile/config-hash, and the API

Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
This commit is contained in:
unkinben
2026-08-02 17:19:53 +10:00
parent 9d471b0bff
commit d3fb5dcd1a
50 changed files with 10379 additions and 1 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,185 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.3
name: keaclientclasses.kea.unkin.net
spec:
group: kea.unkin.net
names:
kind: KeaClientClass
listKind: KeaClientClassList
plural: keaclientclasses
shortNames:
- kcc
singular: keaclientclass
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.clusterRef
name: Cluster
type: string
- jsonPath: .spec.bootFileName
name: BootFile
type: string
- jsonPath: .status.phase
name: Phase
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: KeaClientClass is a PXE boot class matched on the client architecture.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: |-
KeaClientClassSpec defines a PXE boot client-class, typically matching the
DHCP client architecture option (code 93).
properties:
archHex:
description: |-
ArchHex is a convenience list of client-architecture values (option 93),
e.g. ["0x0000"] or ["0x0007","0x0009"]. Rendered into a Test expression
of the form: option[93].hex == 0x0007 or option[93].hex == 0x0009.
items:
type: string
type: array
bootFileName:
description: BootFileName handed to matching clients (option 67 /
boot-file-name).
type: string
clusterRef:
description: |-
ClusterRef selects the owning KeaCluster by name. Empty means every
KeaCluster in the namespace.
type: string
nextServer:
description: NextServer overrides siaddr for matching clients.
type: string
optionData:
description: OptionData carries additional options set for matching
clients.
items:
description: OptionData is a rendered DHCPv4 option value (subnet-
or class-scoped).
properties:
code:
description: Code of the option (alternative to Name).
type: integer
csvFormat:
description: CSVFormat controls whether Data is parsed as CSV
(default true in Kea).
type: boolean
data:
description: Data is the option value(s), comma-separated per
Kea convention.
type: string
name:
description: Name of the option, e.g. "routers", "domain-name-servers".
type: string
space:
description: Space defaults to "dhcp4".
type: string
required:
- data
type: object
type: array
serverHostname:
description: ServerHostname (sname) for matching clients.
type: string
test:
description: |-
Test is a raw Kea class-match expression. When empty it is generated
from ArchHex.
type: string
type: object
status:
description: KeaClientClassStatus captures observed state.
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
observedGeneration:
format: int64
type: integer
phase:
type: string
type: object
type: object
served: true
storage: true
subresources:
status: {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,214 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.3
name: keasubnets.kea.unkin.net
spec:
group: kea.unkin.net
names:
kind: KeaSubnet
listKind: KeaSubnetList
plural: keasubnets
shortNames:
- ksn
singular: keasubnet
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.clusterRef
name: Cluster
type: string
- jsonPath: .spec.subnet
name: Subnet
type: string
- jsonPath: .status.phase
name: Phase
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: KeaSubnet is one DHCPv4 subnet declaration referenced to a KeaCluster.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: KeaSubnetSpec defines a single DHCPv4 subnet served by a
KeaCluster.
properties:
bootFileName:
description: BootFileName sets the PXE boot file for this subnet (overridden
by class).
type: string
clientClasses:
description: ClientClasses restricts the subnet to the listed client
classes.
items:
type: string
type: array
clusterRef:
description: |-
ClusterRef selects the owning KeaCluster by name. Empty means every
KeaCluster in the namespace.
type: string
dnsServers:
description: DNSServers is the domain-name-servers option.
items:
type: string
type: array
domainName:
description: DomainName is the per-subnet domain-name option.
type: string
id:
description: |-
ID is the stable Kea subnet id. When zero the operator assigns one
deterministically from the sorted set of subnets.
type: integer
nextServer:
description: NextServer is the TFTP server address for PXE (siaddr
/ next-server).
type: string
optionData:
description: OptionData carries any additional option values for the
subnet.
items:
description: OptionData is a rendered DHCPv4 option value (subnet-
or class-scoped).
properties:
code:
description: Code of the option (alternative to Name).
type: integer
csvFormat:
description: CSVFormat controls whether Data is parsed as CSV
(default true in Kea).
type: boolean
data:
description: Data is the option value(s), comma-separated per
Kea convention.
type: string
name:
description: Name of the option, e.g. "routers", "domain-name-servers".
type: string
space:
description: Space defaults to "dhcp4".
type: string
required:
- data
type: object
type: array
pools:
description: |-
Pools are dynamic ranges, e.g. "198.18.13.200 - 198.18.13.220". A subnet
with no pool is still declared so relayed requests on that network are
serviced (matching, option delivery) without dynamic allocation.
items:
type: string
type: array
routers:
description: Routers is the default-gateway list (routers option).
items:
type: string
type: array
subnet:
description: Subnet is the CIDR, e.g. "198.18.13.0/24".
type: string
validLifetime:
description: ValidLifetime overrides the cluster default lease time
for this subnet.
type: integer
required:
- subnet
type: object
status:
description: KeaSubnetStatus captures observed state.
properties:
assignedID:
description: AssignedID is the subnet id that was rendered into kea
config.
type: integer
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
observedGeneration:
format: int64
type: integer
phase:
type: string
type: object
type: object
served: true
storage: true
subresources:
status: {}
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kea-operator
rules:
- apiGroups:
- ""
resources:
- configmaps
- secrets
- serviceaccounts
- services
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- watch
- apiGroups:
- apps
resources:
- deployments
- statefulsets
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- kea.unkin.net
resources:
- keaapis
- keaclientclasses
- keaclusters
- keasubnets
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- kea.unkin.net
resources:
- keaapis/status
- keaclientclasses/status
- keaclusters/status
- keasubnets/status
verbs:
- get
- patch
- update
- apiGroups:
- rbac.authorization.k8s.io
resources:
- rolebindings
- roles
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
+30
View File
@@ -0,0 +1,30 @@
apiVersion: v1
kind: Namespace
metadata:
name: dhcp-system
---
apiVersion: kea.unkin.net/v1alpha1
kind: KeaCluster
metadata:
name: pxe
namespace: dhcp-system
spec:
replicas: 2
image: git.unkin.net/unkin/kea:latest
domainName: main.unkin.net
defaultLeaseTime: 1200
maxLeaseTime: 86400
ha:
mode: hot-standby
service:
type: LoadBalancer
# Anycast address handed out by PureLB; router relays forward unicast here.
loadBalancerIP: 198.18.19.53
ipAddressPool: dhcp-anycast
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
+91
View File
@@ -0,0 +1,91 @@
# Full translation of the legacy ISC dhcpd pools: 198.18.13-17.0/24 each with a
# .200-.220 pool, plus 198.18.25.0/24 with no pool (declared so relayed requests
# are still serviced). routers .1, dns 198.18.19.15, next-server 198.18.19.19.
apiVersion: kea.unkin.net/v1alpha1
kind: KeaSubnet
metadata:
name: net-198-18-13
namespace: dhcp-system
spec:
clusterRef: pxe
subnet: 198.18.13.0/24
pools:
- 198.18.13.200 - 198.18.13.220
routers: [198.18.13.1]
dnsServers: [198.18.19.15]
domainName: main.unkin.net
nextServer: 198.18.19.19
---
apiVersion: kea.unkin.net/v1alpha1
kind: KeaSubnet
metadata:
name: net-198-18-14
namespace: dhcp-system
spec:
clusterRef: pxe
subnet: 198.18.14.0/24
pools:
- 198.18.14.200 - 198.18.14.220
routers: [198.18.14.1]
dnsServers: [198.18.19.15]
domainName: main.unkin.net
nextServer: 198.18.19.19
---
apiVersion: kea.unkin.net/v1alpha1
kind: KeaSubnet
metadata:
name: net-198-18-15
namespace: dhcp-system
spec:
clusterRef: pxe
subnet: 198.18.15.0/24
pools:
- 198.18.15.200 - 198.18.15.220
routers: [198.18.15.1]
dnsServers: [198.18.19.15]
domainName: main.unkin.net
nextServer: 198.18.19.19
---
apiVersion: kea.unkin.net/v1alpha1
kind: KeaSubnet
metadata:
name: net-198-18-16
namespace: dhcp-system
spec:
clusterRef: pxe
subnet: 198.18.16.0/24
pools:
- 198.18.16.200 - 198.18.16.220
routers: [198.18.16.1]
dnsServers: [198.18.19.15]
domainName: main.unkin.net
nextServer: 198.18.19.19
---
apiVersion: kea.unkin.net/v1alpha1
kind: KeaSubnet
metadata:
name: net-198-18-17
namespace: dhcp-system
spec:
clusterRef: pxe
subnet: 198.18.17.0/24
pools:
- 198.18.17.200 - 198.18.17.220
routers: [198.18.17.1]
dnsServers: [198.18.19.15]
domainName: main.unkin.net
nextServer: 198.18.19.19
---
# No pool: declared so relayed DHCP requests on this net are matched and get
# options, but no dynamic address is allocated (subnet-mask derives from CIDR).
apiVersion: kea.unkin.net/v1alpha1
kind: KeaSubnet
metadata:
name: net-198-18-25
namespace: dhcp-system
spec:
clusterRef: pxe
subnet: 198.18.25.0/24
routers: [198.18.25.1]
dnsServers: [198.18.19.15]
domainName: main.unkin.net
+21
View File
@@ -0,0 +1,21 @@
# PXE boot classes matching the client architecture option (code 93), replacing
# the legacy dhcpd "Legacy" and "UEFI-64" classes.
apiVersion: kea.unkin.net/v1alpha1
kind: KeaClientClass
metadata:
name: Legacy
namespace: dhcp-system
spec:
clusterRef: pxe
archHex: ["0x0000"]
bootFileName: /undionly.kpxe
---
apiVersion: kea.unkin.net/v1alpha1
kind: KeaClientClass
metadata:
name: UEFI-64
namespace: dhcp-system
spec:
clusterRef: pxe
archHex: ["0x0007", "0x0009"]
bootFileName: /ipxe.efi
+21
View File
@@ -0,0 +1,21 @@
# Optional: spawn the Terraform-friendly REST API that CRUDs KeaSubnet /
# KeaClientClass CRs. Auth is a bearer token; the operator generates the token
# Secret if absent, or it can be pre-seeded (e.g. by a Vault static secret).
apiVersion: kea.unkin.net/v1alpha1
kind: KeaAPI
metadata:
name: kea-api
namespace: dhcp-system
spec:
replicas: 1
image: git.unkin.net/unkin/kea-api:latest
service:
type: ClusterIP
port: 8080
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: "1"
memory: 256Mi