From ff3086d00f6e6b8d8c311c495834295fdf86a944 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 2 Jul 2026 10:56:52 +0100 Subject: [PATCH 01/48] disable dnsmasq statefulset --- components/ironic/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ironic/kustomization.yaml b/components/ironic/kustomization.yaml index c413942f7..4998ef477 100644 --- a/components/ironic/kustomization.yaml +++ b/components/ironic/kustomization.yaml @@ -6,7 +6,7 @@ resources: - ironic-mariadb-db.yaml - ironic-rabbitmq-queue.yaml - dnsmasq-pvc.yaml - - dnsmasq-ss.yaml +# - dnsmasq-ss.yaml - ironic-ks-user-baremetal.yaml # less than ideal addition but necessary so that we can have the ironic.conf.d loading # working due to the way the chart hardcodes the config-file parameter which then From c590ade78ea57450122e916adf712396b7d42997 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 2 Jul 2026 11:00:00 +0100 Subject: [PATCH 02/48] feat(kea): add kea DHCP server as new site-level component Wraps the kea-dhcp chart (github.com/mglants/charts) as an ArgoCD Application, following the karma/dex external-chart pattern with chrony-style site-only scoping. Starts with upstream chart defaults. --- charts/argocd-understack/README.md | 1 + .../templates/application-kea.yaml | 46 +++++++++++++++++++ charts/argocd-understack/values.yaml | 9 ++++ components/kea/values.yaml | 1 + docs/deploy-guide/components/kea.md | 39 ++++++++++++++++ properdocs.yml | 1 + 6 files changed, 97 insertions(+) create mode 100644 charts/argocd-understack/templates/application-kea.yaml create mode 100644 components/kea/values.yaml create mode 100644 docs/deploy-guide/components/kea.md diff --git a/charts/argocd-understack/README.md b/charts/argocd-understack/README.md index 9a176f77f..624b0c234 100644 --- a/charts/argocd-understack/README.md +++ b/charts/argocd-understack/README.md @@ -177,6 +177,7 @@ Components deployed on site clusters: | argo-workflows | `site.argo_workflows` | Workflow engine | | chrony | `site.chrony` | NTP service | | envoy-configs | `site.envoy_configs` | Gateway configs | +| kea | `site.kea` | DHCP server | | openstack-exporter | `site.openstack_exporter` | Metrics exporter | | openstack-memcached | `site.openstack_memcached` | Caching | | site-workflows | `site.site_workflows` | Site workflows | diff --git a/charts/argocd-understack/templates/application-kea.yaml b/charts/argocd-understack/templates/application-kea.yaml new file mode 100644 index 000000000..3a3bb7219 --- /dev/null +++ b/charts/argocd-understack/templates/application-kea.yaml @@ -0,0 +1,46 @@ +{{- if eq (include "understack.isEnabled" (list $.Values.site "kea")) "true" }} +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ printf "%s-%s" $.Release.Name "kea" }} + finalizers: + - resources-finalizer.argocd.argoproj.io + annotations: + argocd.argoproj.io/compare-options: ServerSideDiff=true,IncludeMutationWebhook=true +{{- include "understack.appLabelsBlock" $ | nindent 2 }} +spec: + destination: + namespace: kea + server: {{ $.Values.cluster_server }} + project: understack + sources: + - chart: kea-dhcp + helm: + ignoreMissingValueFiles: true + releaseName: kea + valueFiles: + - $understack/components/kea/values.yaml + - $deploy/{{ include "understack.deploy_path" $ }}/kea/values.yaml + repoURL: https://mglants.github.io/charts + targetRevision: {{ $.Values.site.kea.chartVersion }} + - ref: understack + repoURL: {{ include "understack.understack_url" $ }} + targetRevision: {{ include "understack.understack_ref" $ }} + - path: {{ include "understack.deploy_path" $ }}/kea + ref: deploy + repoURL: {{ include "understack.deploy_url" $ }} + targetRevision: {{ include "understack.deploy_ref" $ }} + syncPolicy: + automated: + prune: true + selfHeal: true + managedNamespaceMetadata: + annotations: + argocd.argoproj.io/sync-options: Delete=false + syncOptions: + - CreateNamespace=true + - ServerSideApply=true + - RespectIgnoreDifferences=true + - ApplyOutOfSyncOnly=true +{{- end }} diff --git a/charts/argocd-understack/values.yaml b/charts/argocd-understack/values.yaml index abf3a677f..a801d691a 100644 --- a/charts/argocd-understack/values.yaml +++ b/charts/argocd-understack/values.yaml @@ -475,6 +475,15 @@ site: # @default -- false enabled: false + # -- Kea DHCP server + kea: + # -- Enable/disable deploying Kea DHCP + # @default -- false + enabled: false + # -- Chart version for Kea DHCP + # renovate: datasource=helm depName=kea-dhcp registryUrl=https://mglants.github.io/charts + chartVersion: "0.7.1" + # -- External DNS operator external_dns: # -- Enable/disable deploying External DNS diff --git a/components/kea/values.yaml b/components/kea/values.yaml new file mode 100644 index 000000000..d69091039 --- /dev/null +++ b/components/kea/values.yaml @@ -0,0 +1 @@ +# Default values for kea-dhcp. Using upstream chart defaults for now. diff --git a/docs/deploy-guide/components/kea.md b/docs/deploy-guide/components/kea.md new file mode 100644 index 000000000..2ffcf119b --- /dev/null +++ b/docs/deploy-guide/components/kea.md @@ -0,0 +1,39 @@ +--- +charts: +- kea-dhcp +deploy_overrides: + helm: + mode: values + kustomize: + mode: second_source +--- + +# kea + +Kea DHCP server (ISC Kea) for site network DHCP service. + +## Deployment Scope + +- Cluster scope: site +- Values key: `site.kea` +- ArgoCD Application template: `charts/argocd-understack/templates/application-kea.yaml` + +## How to Enable + +Set this component to enabled in your deployment values file: + +```yaml title="$CLUSTER_NAME/deploy.yaml" +site: + kea: + enabled: true +``` + +## How ArgoCD Builds It + +{{ component_argocd_builds() }} + +## Deployment Repo Content + +{{ secrets_disclaimer }} + +Currently deployed with upstream chart defaults; no required deployment-repo overrides yet. diff --git a/properdocs.yml b/properdocs.yml index 23b373a76..698b80f4f 100644 --- a/properdocs.yml +++ b/properdocs.yml @@ -188,6 +188,7 @@ nav: - deploy-guide/components/ironic.md - deploy-guide/components/ironic-hardware-exporter.md - deploy-guide/components/karma.md + - deploy-guide/components/kea.md - deploy-guide/components/keystone.md - deploy-guide/components/mariadb-operator.md - deploy-guide/components/monitoring.md From 0f88e9ccd9bc5de4f5e1e9ea15e36b87c33bd550 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 2 Jul 2026 11:03:37 +0100 Subject: [PATCH 03/48] chore(understack): allow deployment to kea namespace --- apps/appsets/project-understack.yaml | 68 ++++++++++++++-------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/apps/appsets/project-understack.yaml b/apps/appsets/project-understack.yaml index 27e6cee7e..f9728f035 100644 --- a/apps/appsets/project-understack.yaml +++ b/apps/appsets/project-understack.yaml @@ -7,38 +7,40 @@ metadata: # they move between AppProject's spec: sourceRepos: - - '*' + - "*" destinations: - - namespace: 'argo' - server: '*' - - namespace: 'argocd' - server: '*' - - namespace: 'argo-events' - server: '*' - - namespace: 'understack-cdn' - server: '*' - - namespace: 'cert-manager' - server: '*' - - namespace: 'dex' - server: '*' - - namespace: 'nautobot' - server: '*' - - namespace: 'nautobotop' - server: '*' - - namespace: 'undersync' - server: '*' - - namespace: 'openstack' - server: '*' - - namespace: 'monitoring' - server: '*' - - namespace: 'otel-collector' - server: '*' - - namespace: 'kube-system' - server: '*' - - namespace: 'envoy-gateway' - server: '*' - - namespace: 'rook-ceph' - server: '*' + - namespace: "argo" + server: "*" + - namespace: "argocd" + server: "*" + - namespace: "argo-events" + server: "*" + - namespace: "understack-cdn" + server: "*" + - namespace: "cert-manager" + server: "*" + - namespace: "dex" + server: "*" + - namespace: "kea" + server: "*" + - namespace: "nautobot" + server: "*" + - namespace: "nautobotop" + server: "*" + - namespace: "undersync" + server: "*" + - namespace: "openstack" + server: "*" + - namespace: "monitoring" + server: "*" + - namespace: "otel-collector" + server: "*" + - namespace: "kube-system" + server: "*" + - namespace: "envoy-gateway" + server: "*" + - namespace: "rook-ceph" + server: "*" clusterResourceWhitelist: - - group: '*' - kind: '*' + - group: "*" + kind: "*" From 1ad5e896476718ae62bdc4944ab7ecb79e3f8edc Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 2 Jul 2026 11:07:47 +0100 Subject: [PATCH 04/48] drop! temporarily deploy kea into openstack namespace The kea namespace can't be deployed to right now, so route the kea Application there as a stopgap. Revert this once kea namespace works. --- charts/argocd-understack/templates/application-kea.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/charts/argocd-understack/templates/application-kea.yaml b/charts/argocd-understack/templates/application-kea.yaml index 3a3bb7219..494bbd173 100644 --- a/charts/argocd-understack/templates/application-kea.yaml +++ b/charts/argocd-understack/templates/application-kea.yaml @@ -11,7 +11,8 @@ metadata: {{- include "understack.appLabelsBlock" $ | nindent 2 }} spec: destination: - namespace: kea + # TODO: revert to `kea` once that namespace is deployable again + namespace: openstack server: {{ $.Values.cluster_server }} project: understack sources: From a0c8f7bbcf9f08ee7f3936da72d2787efef010ed Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 2 Jul 2026 11:41:56 +0100 Subject: [PATCH 05/48] feat(kea): expose DHCP service as LoadBalancer DHCP needs to be reachable from the physical network, not just in-cluster, so default the kea-dhcp service to LoadBalancer for all sites. --- components/kea/values.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/kea/values.yaml b/components/kea/values.yaml index d69091039..5676398d6 100644 --- a/components/kea/values.yaml +++ b/components/kea/values.yaml @@ -1 +1,7 @@ # Default values for kea-dhcp. Using upstream chart defaults for now. + +service: + # DHCP servers need to be reachable from the physical network, not just + # in-cluster, so expose the DHCP service via a LoadBalancer. + dhcp: + type: LoadBalancer From bb9b55a1e1fc1a1493434feef594330a2287d29b Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 2 Jul 2026 12:02:06 +0100 Subject: [PATCH 06/48] fix(kea): revert chart-level LoadBalancer default The kea-dhcp chart (v0.7.1, and current main) renders invalid YAML when service.dhcp.annotations is non-empty. Sites needing an annotated LoadBalancer endpoint should add their own standalone Service in their deploy-repo overlay instead, so leave the chart's own Service at defaults (ClusterIP). --- components/kea/values.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/components/kea/values.yaml b/components/kea/values.yaml index 5676398d6..9df23fafc 100644 --- a/components/kea/values.yaml +++ b/components/kea/values.yaml @@ -1,7 +1,7 @@ # Default values for kea-dhcp. Using upstream chart defaults for now. - -service: - # DHCP servers need to be reachable from the physical network, not just - # in-cluster, so expose the DHCP service via a LoadBalancer. - dhcp: - type: LoadBalancer +# +# The chart's own DHCP Service is left at chart defaults (ClusterIP). External +# reachability is provided per-site via a hand-written LoadBalancer Service in +# each site's deploy-repo kea/ overlay, working around a chart bug where +# setting service.dhcp.annotations renders invalid YAML (missing newline in +# templates/service.yaml, still present on chart main as of 0.7.1). From 3f7322e7a3261451a5a6232444c63159e95677e2 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 2 Jul 2026 16:20:51 +0100 Subject: [PATCH 07/48] feat: enable kea control agent --- components/kea/values.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/kea/values.yaml b/components/kea/values.yaml index 9df23fafc..87badda9e 100644 --- a/components/kea/values.yaml +++ b/components/kea/values.yaml @@ -5,3 +5,9 @@ # each site's deploy-repo kea/ overlay, working around a chart bug where # setting service.dhcp.annotations renders invalid YAML (missing newline in # templates/service.yaml, still present on chart main as of 0.7.1). + +kea: + ctrlagent: + #Needed for HA, monitoring and stork + enabled: true + loglevel: "DEBUG" From 87b2f6cb4491e3e4b7d0ee91953775551becead9 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Fri, 3 Jul 2026 16:59:55 +0100 Subject: [PATCH 08/48] add ironic kea dhcp backend handler --- .../ironic_understack/conf.py | 18 ++ .../ironic_understack/dhcp/kea.py | 167 ++++++++++++++++++ python/ironic-understack/pyproject.toml | 3 + 3 files changed, 188 insertions(+) create mode 100644 python/ironic-understack/ironic_understack/dhcp/kea.py diff --git a/python/ironic-understack/ironic_understack/conf.py b/python/ironic-understack/ironic_understack/conf.py index 459d4ff30..b7296b23f 100644 --- a/python/ironic-understack/ironic_understack/conf.py +++ b/python/ironic-understack/ironic_understack/conf.py @@ -26,6 +26,24 @@ def setup_conf(): "1d": "bmc", }, ), + cfg.StrOpt( + "kea_url", + default="http://kea-kea-dhcp-ctrl.openstack.svc.cluster.local:8000", + help="URL of the Kea DHCP server's HTTP API endpoint. " + "This endpoint is used for managing DHCP " + "configuration, reservations, leases and subnet " + "operations through Kea's HTTP API interface.", + ), + cfg.IntOpt( + "kea_request_timeout", + default=10, + help="Timeout in seconds for requests to the Kea API.", + ), + cfg.IntOpt( + "kea_max_retries", + default=3, + help="Maximum number of retry attempts for failed " "requests.", + ), ] cfg.CONF.register_group(grp) cfg.CONF.register_opts(opts, group=grp) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py new file mode 100644 index 000000000..394388a01 --- /dev/null +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -0,0 +1,167 @@ +import requests +from ironic import objects +from ironic.common import exception +from ironic.dhcp import base +from oslo_log import log as logging + +from ironic_understack.conf import CONF + +LOG = logging.getLogger(__name__) + + +class DHCPConfigurationError(exception.IronicException): + """Raised when there is an error in configuring DHCP.""" + + _msg_fmt = "DHCP configuration error: %(reason)s" + + +class KeaDHCPApi(base.BaseDHCP): + def __init__(self): + super().__init__() + self.max_retries = CONF.kea_max_retries + + if not CONF.kea_url: + raise DHCPConfigurationError("Kea URL must be specified in configuration") + + def _make_request(self, command, arguments, services=None): + payload = { + "command": command, + "service": services or ["dhcp4"], + "arguments": arguments, + } + + for attempt in range(self.max_retries): + try: + response = requests.post( + CONF.kea_url, json=payload, timeout=CONF.kea_request_timeout + ) + response.raise_for_status() + return response.json() + except requests.exceptions.Timeout: + LOG.warning( + "Timeout on attempt %d/%d for command %s", + attempt + 1, + self.max_retries, + command, + ) + except requests.exceptions.RequestException as e: + if attempt == self.max_retries - 1: + LOG.error("Failed to execute command %s: %s", command, e) + raise DHCPConfigurationError( + f"Failed to execute {command}: {e}" + ) from e + LOG.warning( + "Request failed on attempt %d/%d: %s", + attempt + 1, + self.max_retries, + e, + ) + + def get_config(self): + """Retrieve current Kea configuration.""" + return self._make_request("config-get", {}) + + def set_config(self, config): + """Update Kea configuration.""" + return self._make_request("config-set", {"config": config}) + + def get_statistics(self, name=None): + """Retrieve DHCP server statistics.""" + if name: + return self._make_request("statistic-get", {"name": name}) + return self._make_request("statistic-get-all", {}) + + def _update_host_reservation(self, hw_address, options=None, remove=False): + """Modify a host reservation in the Kea config file or hosts database.""" + # TODO(cid) add support/replace with the host database configuration + # option in a central database managed by Ironic; the commands to have + # Kea manage it at runtime without restarting the server is a premium + # offering + try: + config = self.get_config() + dhcp4_config = config["arguments"]["Dhcp4"] + + reservations = dhcp4_config.get("reservations", []) + found = False + for reservation in reservations: + if reservation.get("hw-address") == hw_address: + reservation["option-data"] = options + found = True + break + + if not found: + reservations.append({"hw-address": hw_address, "option-data": options}) + dhcp4_config["reservations"] = reservations + + config["arguments"]["Dhcp4"] = dhcp4_config + self.set_config(config["arguments"]) + return True + except Exception as e: + LOG.error("Failed to update reservation for %s: %s", hw_address, e) + return False + + def update_port_dhcp_opts(self, port_id, dhcp_options, context=None): + """Update DHCP options for a specific port in Kea.""" + port = objects.Port.get(context, port_id) + + kea_options = [] + for opt in dhcp_options: + kea_opt = { + "name": opt["opt_name"], + "data": opt["opt_value"], + "always-send": True, + } + if "ip_version" in opt: + kea_opt["space"] = f'dhcp{opt["ip_version"]}' + kea_options.append(kea_opt) + return self._update_host_reservation(port.address, kea_options) + + def update_dhcp_opts(self, task, options, vifs=None): + """Update DHCP options for all ports associated with a node.""" + ports = vifs or task.ports + success = True + + for port in ports: + if not self.update_port_dhcp_opts(port.uuid, options): + success = False + LOG.error("Failed to update DHCP options for port %s", port.uuid) + return success + + def clean_dhcp_opts(self, task): + """Remove DHCP options for all ports associated with a node.""" + success = True + for port in task.ports: + if not self._update_host_reservation(port.address, remove=True): + success = False + LOG.error("Failed to clean DHCP options for port %s", port.uuid) + return success + + def get_ip_addresses(self, task): + """Retrieve IP addresses for all ports associated to a node.""" + addresses = [] + for port in task.ports: + for command, service in [("lease4-get", "dhcp4"), ("lease6-get", "dhcp6")]: + try: + response = self._make_request( + command, {"hw-address": port.address}, services=[service] + ) + leases = response.get("arguments", {}).get("leases", []) + if not leases: + LOG.warning("No leases found for port %s", port.address) + if service == "dhcp4": + addresses.extend([lease["ip-address"] for lease in leases]) + else: + for lease in leases: + addresses.extend(lease.get("ip-addresses", [])) + except DHCPConfigurationError as e: + LOG.warning( + "Failed to fetch %s addresses for port %s: %s", + service, + port.address, + e, + ) + return addresses + + def supports_ipxe_tag(self): + """Indicate whether the provider supports the 'ipxe' tag.""" + return True diff --git a/python/ironic-understack/pyproject.toml b/python/ironic-understack/pyproject.toml index 52d996a25..74d0adae6 100644 --- a/python/ironic-understack/pyproject.toml +++ b/python/ironic-understack/pyproject.toml @@ -19,6 +19,9 @@ dependencies = [ [project.entry-points."ironic.console.container"] kubernetes = "ironic.console.container.kubernetes:KubernetesConsoleContainer" +[project.entry-points."ironic.dhcp"] +kea = "ironic_understack.dhcp.kea:KeaDHCPApi" + [project.entry-points."ironic.inspection.hooks"] resource-class = "ironic_understack.resource_class:ResourceClassHook" update-baremetal-port = "ironic_understack.inspect_hook_update_baremetal_ports:InspectHookUpdateBaremetalPorts" From 06a3cdf0961ee9678f9ba7a3da0f2e29ae5a0c98 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Fri, 3 Jul 2026 17:14:23 +0100 Subject: [PATCH 09/48] change ironic dhcp provider to kea and adjust ironic images for testing --- components/images-openstack.yaml | 10 +++++----- components/ironic/values.yaml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/components/images-openstack.yaml b/components/images-openstack.yaml index d63aa6968..4026d7e3a 100644 --- a/components/images-openstack.yaml +++ b/components/images-openstack.yaml @@ -22,12 +22,12 @@ images: keystone_fernet_setup: "ghcr.io/rackerlabs/understack/keystone:2026.1" # ironic - ironic_api: "ghcr.io/rackerlabs/understack/ironic:2026.1" - ironic_conductor: "ghcr.io/rackerlabs/understack/ironic:2026.1" - ironic_pxe: "ghcr.io/rackerlabs/understack/ironic:2026.1" - ironic_pxe_init: "ghcr.io/rackerlabs/understack/ironic:2026.1" + ironic_api: "ghcr.io/rackerlabs/understack/ironic:pr-2111" + ironic_conductor: "ghcr.io/rackerlabs/understack/ironic:pr-2111" + ironic_pxe: "ghcr.io/rackerlabs/understack/ironic:pr-2111" + ironic_pxe_init: "ghcr.io/rackerlabs/understack/ironic:pr-2111" ironic_pxe_http: "docker.io/nginx:1.29.8" - ironic_db_sync: "ghcr.io/rackerlabs/understack/ironic:2026.1" + ironic_db_sync: "ghcr.io/rackerlabs/understack/ironic:pr-2111" # these want curl which apparently is in the openstack-client image ironic_manage_cleaning_network: "ghcr.io/rackerlabs/understack/openstack-client:2025.2" ironic_retrive_cleaning_network: "ghcr.io/rackerlabs/understack/openstack-client:2025.2" diff --git a/components/ironic/values.yaml b/components/ironic/values.yaml index a6171ce6d..2e0ff8e60 100644 --- a/components/ironic/values.yaml +++ b/components/ironic/values.yaml @@ -90,7 +90,7 @@ conf: # https://docs.openstack.org/ironic/latest/admin/drivers/idrac.html#timeout-when-powering-off post_deploy_get_power_state_retry_interval: 18 dhcp: - dhcp_provider: dnsmasq + dhcp_provider: kea oslo_messaging_notifications: driver: messagingv2 oslo_messaging_rabbit: From 799d40280ef0c4013b3f6266c96dab38064776d1 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Fri, 3 Jul 2026 17:38:21 +0100 Subject: [PATCH 10/48] fix kea CONF refs --- python/ironic-understack/ironic_understack/dhcp/kea.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 394388a01..c83427e74 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -18,9 +18,9 @@ class DHCPConfigurationError(exception.IronicException): class KeaDHCPApi(base.BaseDHCP): def __init__(self): super().__init__() - self.max_retries = CONF.kea_max_retries + self.max_retries = CONF.ironic_understack.kea_max_retries - if not CONF.kea_url: + if not CONF.ironic_understack.kea_url: raise DHCPConfigurationError("Kea URL must be specified in configuration") def _make_request(self, command, arguments, services=None): @@ -33,7 +33,9 @@ def _make_request(self, command, arguments, services=None): for attempt in range(self.max_retries): try: response = requests.post( - CONF.kea_url, json=payload, timeout=CONF.kea_request_timeout + CONF.ironic_understack.kea_url, + json=payload, + timeout=CONF.ironic_understack.kea_request_timeout, ) response.raise_for_status() return response.json() From 355bf8874c81f73866fd89bc6e2e1026041b92bf Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Fri, 3 Jul 2026 18:40:20 +0100 Subject: [PATCH 11/48] adjust kea response --- python/ironic-understack/ironic_understack/dhcp/kea.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index c83427e74..0e7306a64 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -61,7 +61,7 @@ def _make_request(self, command, arguments, services=None): def get_config(self): """Retrieve current Kea configuration.""" - return self._make_request("config-get", {}) + return self._make_request("config-get", {})[0] def set_config(self, config): """Update Kea configuration.""" From 2eac4ff3a60de7645e001cfb47b78eb1f0d2f65f Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Fri, 3 Jul 2026 19:26:34 +0100 Subject: [PATCH 12/48] print dhcp options for tshooting --- python/ironic-understack/ironic_understack/dhcp/kea.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 0e7306a64..906d674ba 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -116,6 +116,7 @@ def update_port_dhcp_opts(self, port_id, dhcp_options, context=None): if "ip_version" in opt: kea_opt["space"] = f'dhcp{opt["ip_version"]}' kea_options.append(kea_opt) + print(kea_options) return self._update_host_reservation(port.address, kea_options) def update_dhcp_opts(self, task, options, vifs=None): From 6e03844e2551067f6c6aefa55fbb57df7289e2e9 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Fri, 3 Jul 2026 21:52:19 +0100 Subject: [PATCH 13/48] disable ipxe support in kea --- python/ironic-understack/ironic_understack/dhcp/kea.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 906d674ba..63391c168 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -167,4 +167,4 @@ def get_ip_addresses(self, task): def supports_ipxe_tag(self): """Indicate whether the provider supports the 'ipxe' tag.""" - return True + return False From 97542ae8b25bf789a7d57e80a151828f29588d6e Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Fri, 3 Jul 2026 22:51:58 +0100 Subject: [PATCH 14/48] kea fix config-set in ironic --- python/ironic-understack/ironic_understack/dhcp/kea.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 63391c168..7baa73eb3 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -65,7 +65,7 @@ def get_config(self): def set_config(self, config): """Update Kea configuration.""" - return self._make_request("config-set", {"config": config}) + return self._make_request("config-set", config) def get_statistics(self, name=None): """Retrieve DHCP server statistics.""" From 110cd0bdf0ae5c62f0147a0cd30dbbe392ea240f Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Fri, 3 Jul 2026 23:03:38 +0100 Subject: [PATCH 15/48] print payload for kea in ironic for thsoot --- python/ironic-understack/ironic_understack/dhcp/kea.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 7baa73eb3..06dfab8e7 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -29,6 +29,7 @@ def _make_request(self, command, arguments, services=None): "service": services or ["dhcp4"], "arguments": arguments, } + print(f"PAYLOAD: {payload}") if command == "config-set" else 0 for attempt in range(self.max_retries): try: From 3e78f2a34a188f3f6fddd9a967ed6db51eb48669 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Fri, 3 Jul 2026 23:31:56 +0100 Subject: [PATCH 16/48] remove hash from kea config-set payload --- python/ironic-understack/ironic_understack/dhcp/kea.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 06dfab8e7..72e4c5425 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -82,6 +82,7 @@ def _update_host_reservation(self, hw_address, options=None, remove=False): # offering try: config = self.get_config() + config["arguments"].pop("hash", None) dhcp4_config = config["arguments"]["Dhcp4"] reservations = dhcp4_config.get("reservations", []) From 17d1eab48c3a746c2f13e108e1b223897bf96966 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Fri, 3 Jul 2026 23:55:20 +0100 Subject: [PATCH 17/48] exclude pxe options from kea in ironic --- .../ironic_understack/dhcp/kea.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 72e4c5425..f6958c003 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -110,14 +110,15 @@ def update_port_dhcp_opts(self, port_id, dhcp_options, context=None): kea_options = [] for opt in dhcp_options: - kea_opt = { - "name": opt["opt_name"], - "data": opt["opt_value"], - "always-send": True, - } - if "ip_version" in opt: - kea_opt["space"] = f'dhcp{opt["ip_version"]}' - kea_options.append(kea_opt) + if not opt["opt_name"].startswith("!"): + kea_opt = { + "name": opt["opt_name"], + "data": opt["opt_value"], + "always-send": True, + } + if "ip_version" in opt: + kea_opt["space"] = f'dhcp{opt["ip_version"]}' + kea_options.append(kea_opt) print(kea_options) return self._update_host_reservation(port.address, kea_options) From 871e1975f1f4be5f1e361245a7fc6067b264b87e Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Sat, 4 Jul 2026 00:23:54 +0100 Subject: [PATCH 18/48] swap kea options to boot file name only in ironic --- .../ironic_understack/dhcp/kea.py | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index f6958c003..6adda7435 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -74,7 +74,7 @@ def get_statistics(self, name=None): return self._make_request("statistic-get", {"name": name}) return self._make_request("statistic-get-all", {}) - def _update_host_reservation(self, hw_address, options=None, remove=False): + def _update_host_reservation(self, hw_address, boot_file_name=None, remove=False): """Modify a host reservation in the Kea config file or hosts database.""" # TODO(cid) add support/replace with the host database configuration # option in a central database managed by Ironic; the commands to have @@ -89,12 +89,12 @@ def _update_host_reservation(self, hw_address, options=None, remove=False): found = False for reservation in reservations: if reservation.get("hw-address") == hw_address: - reservation["option-data"] = options + reservation["boot-file-name"] = boot_file_name found = True break if not found: - reservations.append({"hw-address": hw_address, "option-data": options}) + reservations.append({"hw-address": hw_address, "boot-file-name": boot_file_name}) dhcp4_config["reservations"] = reservations config["arguments"]["Dhcp4"] = dhcp4_config @@ -108,19 +108,12 @@ def update_port_dhcp_opts(self, port_id, dhcp_options, context=None): """Update DHCP options for a specific port in Kea.""" port = objects.Port.get(context, port_id) - kea_options = [] + boot_file_name = "" for opt in dhcp_options: - if not opt["opt_name"].startswith("!"): - kea_opt = { - "name": opt["opt_name"], - "data": opt["opt_value"], - "always-send": True, - } - if "ip_version" in opt: - kea_opt["space"] = f'dhcp{opt["ip_version"]}' - kea_options.append(kea_opt) - print(kea_options) - return self._update_host_reservation(port.address, kea_options) + if opt["opt_name"].startswith("!"): + boot_file_name = opt["opt_value"] + break + return self._update_host_reservation(port.address, boot_file_name) def update_dhcp_opts(self, task, options, vifs=None): """Update DHCP options for all ports associated with a node.""" From 7160943defc41a3cea619c454d81fb7647dd3063 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Sat, 4 Jul 2026 01:06:53 +0100 Subject: [PATCH 19/48] add next-server to kea in ironic --- .../ironic-understack/ironic_understack/dhcp/kea.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 6adda7435..1bfb8647b 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -3,6 +3,7 @@ from ironic.common import exception from ironic.dhcp import base from oslo_log import log as logging +from urllib.parse import urlparse from ironic_understack.conf import CONF @@ -84,17 +85,25 @@ def _update_host_reservation(self, hw_address, boot_file_name=None, remove=False config = self.get_config() config["arguments"].pop("hash", None) dhcp4_config = config["arguments"]["Dhcp4"] + next_server = urlparse(boot_file_name).hostname reservations = dhcp4_config.get("reservations", []) found = False for reservation in reservations: if reservation.get("hw-address") == hw_address: reservation["boot-file-name"] = boot_file_name + reservation["next-server"] = next_server found = True break if not found: - reservations.append({"hw-address": hw_address, "boot-file-name": boot_file_name}) + reservations.append( + { + "hw-address": hw_address, + "boot-file-name": boot_file_name, + "next-server": next_server, + } + ) dhcp4_config["reservations"] = reservations config["arguments"]["Dhcp4"] = dhcp4_config From 42e10d91de00be84ebdc19cc4043be03ec2b3c58 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Mon, 6 Jul 2026 16:19:13 +0100 Subject: [PATCH 20/48] print dhcp options for kea --- python/ironic-understack/ironic_understack/dhcp/kea.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 1bfb8647b..073514b4f 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -30,7 +30,6 @@ def _make_request(self, command, arguments, services=None): "service": services or ["dhcp4"], "arguments": arguments, } - print(f"PAYLOAD: {payload}") if command == "config-set" else 0 for attempt in range(self.max_retries): try: @@ -118,6 +117,7 @@ def update_port_dhcp_opts(self, port_id, dhcp_options, context=None): port = objects.Port.get(context, port_id) boot_file_name = "" + print(f"DHCP_OPTIONS: {dhcp_options}") for opt in dhcp_options: if opt["opt_name"].startswith("!"): boot_file_name = opt["opt_value"] From 8d649110768a02985cd35a2ae439bb099e2ebd32 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 6 Jul 2026 16:21:20 +0100 Subject: [PATCH 21/48] feat: add kea chart --- .yamllint.yaml | 1 + charts/kea-dhcp/.helmignore | 23 ++ charts/kea-dhcp/Chart.yaml | 33 +++ charts/kea-dhcp/LICENSE | 21 ++ charts/kea-dhcp/README.md | 7 + charts/kea-dhcp/README.md.gotmpl | 161 ++++++++++ charts/kea-dhcp/README.orig.md | 156 ++++++++++ charts/kea-dhcp/templates/_helpers.tpl | 58 ++++ charts/kea-dhcp/templates/configmap.yaml | 280 ++++++++++++++++++ .../kea-dhcp/templates/metrics-service.yaml | 30 ++ charts/kea-dhcp/templates/role.yaml | 16 + charts/kea-dhcp/templates/rolebinding.yaml | 19 ++ charts/kea-dhcp/templates/service.yaml | 112 +++++++ charts/kea-dhcp/templates/serviceaccount.yaml | 10 + charts/kea-dhcp/templates/servicemonitor.yaml | 15 + charts/kea-dhcp/templates/statefulset.yaml | 201 +++++++++++++ charts/kea-dhcp/values.schema.json | 10 + charts/kea-dhcp/values.yaml | 215 ++++++++++++++ 18 files changed, 1368 insertions(+) create mode 100644 charts/kea-dhcp/.helmignore create mode 100644 charts/kea-dhcp/Chart.yaml create mode 100644 charts/kea-dhcp/LICENSE create mode 100644 charts/kea-dhcp/README.md create mode 100644 charts/kea-dhcp/README.md.gotmpl create mode 100644 charts/kea-dhcp/README.orig.md create mode 100644 charts/kea-dhcp/templates/_helpers.tpl create mode 100644 charts/kea-dhcp/templates/configmap.yaml create mode 100644 charts/kea-dhcp/templates/metrics-service.yaml create mode 100644 charts/kea-dhcp/templates/role.yaml create mode 100644 charts/kea-dhcp/templates/rolebinding.yaml create mode 100644 charts/kea-dhcp/templates/service.yaml create mode 100644 charts/kea-dhcp/templates/serviceaccount.yaml create mode 100644 charts/kea-dhcp/templates/servicemonitor.yaml create mode 100644 charts/kea-dhcp/templates/statefulset.yaml create mode 100644 charts/kea-dhcp/values.schema.json create mode 100644 charts/kea-dhcp/values.yaml diff --git a/.yamllint.yaml b/.yamllint.yaml index f38b70c63..02d08cc73 100644 --- a/.yamllint.yaml +++ b/.yamllint.yaml @@ -3,6 +3,7 @@ extends: default ignore: - components/etcdbackup/templates/ - charts/argocd-understack/templates/ + - charts/kea-dhcp/templates/ - charts/nautobot-api-tokens/templates/ - charts/nautobot-job-queues/templates/ - charts/site-workflows/templates/ diff --git a/charts/kea-dhcp/.helmignore b/charts/kea-dhcp/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/charts/kea-dhcp/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/charts/kea-dhcp/Chart.yaml b/charts/kea-dhcp/Chart.yaml new file mode 100644 index 000000000..68cfcda65 --- /dev/null +++ b/charts/kea-dhcp/Chart.yaml @@ -0,0 +1,33 @@ +apiVersion: v2 +name: kea-dhcp +description: Helm chart for kea-dhcp + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.8.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "2.5.8" +home: https://www.isc.org/kea/ +icon: https://raw.githubusercontent.com/isc-projects/kea/3eee114614162b087e415e0a6ce1057d422c8174/doc/images/kea-logo-100x70.png +maintainers: + - name: Mark Glants + email: m@gearage.ru +annotations: + artifacthub.io/changes: | + - kind: changed + description: allow to use custom interface diff --git a/charts/kea-dhcp/LICENSE b/charts/kea-dhcp/LICENSE new file mode 100644 index 000000000..683c9d50b --- /dev/null +++ b/charts/kea-dhcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Jack Maloney + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/charts/kea-dhcp/README.md b/charts/kea-dhcp/README.md new file mode 100644 index 000000000..b39c28af9 --- /dev/null +++ b/charts/kea-dhcp/README.md @@ -0,0 +1,7 @@ +# kea-dhcp + +This is a Helm chart adapted from https://github.com/Turgon37/mglants-charts/tree/main + +Credits: +- original chart https://github.com/mglants/charts +- Turgon37's fixes - https://github.com/Turgon37/mglants-charts/tree/main diff --git a/charts/kea-dhcp/README.md.gotmpl b/charts/kea-dhcp/README.md.gotmpl new file mode 100644 index 000000000..25992625f --- /dev/null +++ b/charts/kea-dhcp/README.md.gotmpl @@ -0,0 +1,161 @@ +{{- define "custom.repository.organization" -}} +mglants +{{- end -}} + +{{- define "custom.repository.url" -}} +https://github.com/MGlants/charts +{{- end -}} + +{{- define "custom.helm.url" -}} +http://charts.glants.xyz +{{- end -}} + +{{- define "custom.helm.path" -}} +{{ template "custom.repository.organization" . }}/{{ template "chart.name" . }} +{{- end -}} + +{{- define "custom.notes" -}} +**This chart is not maintained by the upstream project and any issues with the chart should be raised [here](https://github.com/MGlants/charts/issues/new/choose)** +**More than 1 replicas is not supported by now** +{{- end -}} + +{{- define "custom.requirements" -}} +## Requirements + +{{ template "chart.kubeVersionLine" . }} +{{- end -}} + +{{- define "custom.dependencies" -}} +## Dependencies + +{{ template "chart.requirementsTable" . }} +{{- end -}} + +{{- define "custom.install.tldr" -}} +## TL;DR + +```console +helm repo add {{ template "custom.repository.organization" . }} {{ template "custom.helm.url" . }} +helm repo update +helm install {{ template "chart.name" . }} {{ template "custom.helm.path" . }} +``` +{{- end -}} + +{{- define "custom.install" -}} +## Installing the Chart + +To install the chart with the release name `{{ template "chart.name" . }}` + +```console +helm install {{ template "chart.name" . }} {{ template "custom.helm.path" . }} +``` +{{- end -}} + +{{- define "custom.uninstall" -}} +## Uninstalling the Chart + +To uninstall the `{{ template "chart.name" . }}` deployment + +```console +helm uninstall {{ template "chart.name" . }} +``` + +The command removes all the Kubernetes components associated with the chart **including persistent volumes** and deletes the release. +{{- end -}} + +{{- define "custom.configuration.header" -}} +## Configuration +{{- end -}} + +{{- define "custom.configuration.readValues" -}} +Read through the [values.yaml](./values.yaml) file. It has several commented out suggested values. +{{- end -}} + +{{- define "custom.configuration.example.set" -}} +Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. + +```console +helm install {{ template "chart.name" . }} \ + --set env.TZ="America/New York" \ + {{ template "custom.helm.path" . }} +``` +{{- end -}} + +{{- define "custom.configuration.example.file" -}} +Alternatively, a YAML file that specifies the values for the above parameters can be provided while installing the chart. + +```console +helm install {{ template "chart.name" . }} {{ template "custom.helm.path" . }} -f values.yaml +``` +{{- end -}} + +{{- define "custom.valuesSection" -}} +## Values + +{{ template "chart.valuesTable" . }} +{{- end -}} + +{{- define "custom.changelog" -}} +{{ $changeTypes := list "added" "changed" "fixed" }} +## Changelog + +### Version {{ template "chart.version" . }} + +{{ range $changeType := $changeTypes }} + {{- with (index $.Annotations "artifacthub.io/changes") }} + {{- print "#### " ($changeType | title) | nindent 0 }} + {{- print "" | nindent 0 }} + {{- $changesFound := false }} + {{- range (print "changes:\n" . | fromYaml).changes }} + {{- if eq .kind $changeType }} + {{- print "* " .description | nindent 0 }} + {{- $changesFound = true }} + {{- end }} + {{- end }} + {{- if not $changesFound }} + {{- print "N/A" | nindent 0 }} + {{- end }} + {{- print "" | nindent 0 }} + {{- end }} +{{- end }} + +### Older versions + +A historical overview of changes can be found on [ArtifactHUB](https://artifacthub.io/packages/helm/mglants/{{- template "chart.name" . }}?modal=changelog) + +{{- end -}} + +{{ template "chart.header" . }} + +{{ template "chart.versionBadge" . }}{{ template "chart.typeBadge" . }}{{ template "chart.appVersionBadge" . }} + +{{ template "chart.description" . }} + +{{ template "custom.notes" . }} + +{{ template "chart.sourcesSection" . }} + +{{ template "custom.requirements" . }} + +{{ template "custom.dependencies" . }} + +{{ template "custom.install.tldr" . }} + +{{ template "custom.install" . }} + +{{ template "custom.uninstall" . }} + +{{ template "custom.configuration.header" . }} + +{{ template "custom.configuration.readValues" . }} + +{{ template "custom.configuration.example.set" . }} + +{{ template "custom.configuration.example.file" . }} + +{{ template "custom.valuesSection" . }} + +{{ template "custom.changelog" . }} + +{{ template "helm-docs.versionFooter" . }} +{{ "" }} diff --git a/charts/kea-dhcp/README.orig.md b/charts/kea-dhcp/README.orig.md new file mode 100644 index 000000000..90db2b707 --- /dev/null +++ b/charts/kea-dhcp/README.orig.md @@ -0,0 +1,156 @@ +# kea-dhcp + +![Version: 0.6.1](https://img.shields.io/badge/Version-0.6.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.1.2](https://img.shields.io/badge/AppVersion-2.1.2-informational?style=flat-square) + +Helm chart for kea-dhcp + +**This chart is not maintained by the upstream project and any issues with the chart should be raised [here](https://github.com/MGlants/charts/issues/new/choose)** +**More than 1 replicas is not supported by now** + +## Requirements + +## Dependencies + +| Repository | Name | Version | +|------------|------|---------| + +## TL;DR + +```console +helm repo add mglants http://charts.glants.xyz +helm repo update +helm install kea-dhcp mglants/kea-dhcp +``` + +## Installing the Chart + +To install the chart with the release name `kea-dhcp` + +```console +helm install kea-dhcp mglants/kea-dhcp +``` + +## Uninstalling the Chart + +To uninstall the `kea-dhcp` deployment + +```console +helm uninstall kea-dhcp +``` + +The command removes all the Kubernetes components associated with the chart **including persistent volumes** and deletes the release. + +## Configuration + +Read through the [values.yaml](./values.yaml) file. It has several commented out suggested values. + +Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. + +```console +helm install kea-dhcp \ + --set env.TZ="America/New York" \ + mglants/kea-dhcp +``` + +Alternatively, a YAML file that specifies the values for the above parameters can be provided while installing the chart. + +```console +helm install kea-dhcp mglants/kea-dhcp -f values.yaml +``` + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| affinity | object | `{}` | | +| clusterDomain | string | `"cluster.local"` | | +| fullnameOverride | string | `""` | | +| image.pullPolicy | string | `"IfNotPresent"` | | +| image.repository | string | `"smailkoz/kea-dhcp"` | | +| image.tag | string | `""` | | +| imagePullSecrets | list | `[]` | | +| kea.ctrlagent.enabled | bool | `false` | | +| kea.ctrlagent.loglevel | string | `"DEBUG"` | | +| kea.ddns.enabled | bool | `false` | | +| kea.ddns.forward | object | `{}` | | +| kea.ddns.loglevel | string | `"DEBUG"` | | +| kea.ddns.prefix | string | `"myhost"` | | +| kea.ddns.reverse | object | `{}` | - name: home.arpa. key-name: '' dns-servers: - ip-address: 172.16.32.2 port: 53 | +| kea.ddns.suffix | string | `"home.arpa."` | | +| kea.dhcp4.enabled | bool | `true` | | +| kea.dhcp4.loglevel | string | `"DEBUG"` | | +| kea.dhcp4.options | object | `{}` | | +| kea.dhcp4.rebindtimer | string | `"2000"` | | +| kea.dhcp4.renewtimer | string | `"1000"` | | +| kea.dhcp4.reservations | object | `{}` | data: 192.168.1.2 - name: domain-name data: local - name: domain-search data: local - code: 66 data: 192.168.1.2 name: tftp-server-name | +| kea.dhcp4.subnets | object | `{}` | hw-address: "aa:aa:aa:aa:aa:aa" hostname: "hostname" option-data: - name: "tftp-servers" data: "10.1.1.202,10.1.1.203" | +| kea.dhcp4.validlifetime | string | `"4000"` | | +| kea.dhcp6.enabled | bool | `false` | | +| kea.dhcp6.loglevel | string | `"DEBUG"` | | +| kea.dhcp6.options | object | `{}` | | +| kea.dhcp6.rebindtimer | string | `"2000"` | | +| kea.dhcp6.renewtimer | string | `"1000"` | | +| kea.dhcp6.reservations | object | `{}` | | +| kea.dhcp6.subnets | object | `{}` | duid: "aa:aa:aa:aa:aa:aa" hostname: "hostname" option-data: - name: "dns-servers" data: "2001:db8:2::45, 2001:db8:2::100" | +| kea.dhcp6.validlifetime | string | `"4000"` | | +| livenessProbe.enabled | bool | `true` | | +| metrics.enabled | bool | `false` | | +| metrics.service.annotations | object | `{"prometheus.io/port":"9547","prometheus.io/scrape":"true"}` | loadBalancerIP: | +| metrics.service.labels | object | `{}` | | +| metrics.service.type | string | `"ClusterIP"` | | +| nameOverride | string | `""` | | +| nodeSelector | object | `{}` | | +| persistence.enabled | bool | `false` | | +| persistence.size | string | `"100Mi"` | | +| podAnnotations | object | `{}` | | +| podSecurityContext | object | `{}` | | +| readinessProbe.enabled | bool | `true` | | +| replicaCount | int | `1` | | +| resources.limits.cpu | string | `"200m"` | | +| resources.limits.memory | string | `"256Mi"` | | +| resources.requests.cpu | string | `"100m"` | | +| resources.requests.memory | string | `"128Mi"` | | +| role.annotations | object | `{}` | | +| roleBinding.annotations | object | `{}` | | +| securityContext | object | `{}` | | +| service.ctrl.annotations | object | `{}` | nodePort: | +| service.ctrl.loadBalancerIP | string | `nil` | | +| service.ctrl.port | int | `8000` | | +| service.ctrl.type | string | `"ClusterIP"` | | +| service.dhcp.annotations | object | `{}` | nodePort: | +| service.dhcp.loadBalancerIP | string | `nil` | | +| service.dhcp.port | int | `67` | | +| service.dhcp.type | string | `"ClusterIP"` | | +| serviceAccount.annotations | object | `{}` | | +| serviceAccount.name | string | `""` | If not set and create is true, a name is generated using the fullname template | +| sidecar.image.pullPolicy | string | `"IfNotPresent"` | | +| sidecar.image.repository | string | `"smailkoz/kea-dhcp-sidecar"` | | +| sidecar.image.tag | string | `""` | | +| sidecar.resources.limits.cpu | string | `"50m"` | | +| sidecar.resources.limits.memory | string | `"128Mi"` | | +| sidecar.resources.requests.cpu | string | `"10m"` | | +| sidecar.resources.requests.memory | string | `"32Mi"` | | +| tolerations | list | `[]` | | + +## Changelog + +### Version 0.6.1 + +#### Added + +N/A + +#### Changed + +N/A + +#### Fixed + +* probes values fixed + +### Older versions + +A historical overview of changes can be found on [ArtifactHUB](https://artifacthub.io/packages/helm/mglants/kea-dhcp?modal=changelog) + +---------------------------------------------- +Autogenerated from chart metadata using [helm-docs v0.1.1](https://github.com/k8s-at-home/helm-docs/releases/v0.1.1) diff --git a/charts/kea-dhcp/templates/_helpers.tpl b/charts/kea-dhcp/templates/_helpers.tpl new file mode 100644 index 000000000..e7e908b0a --- /dev/null +++ b/charts/kea-dhcp/templates/_helpers.tpl @@ -0,0 +1,58 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "kea-dhcp.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "kea-dhcp.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "kea-dhcp.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "kea-dhcp.labels" -}} +helm.sh/chart: {{ include "kea-dhcp.chart" . }} +{{ include "kea-dhcp.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "kea-dhcp.selectorLabels" -}} +app.kubernetes.io/name: {{ include "kea-dhcp.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "kea-dhcp.serviceAccountName" -}} +{{- default (include "kea-dhcp.fullname" .) .Values.serviceAccount.name }} +{{- end }} diff --git a/charts/kea-dhcp/templates/configmap.yaml b/charts/kea-dhcp/templates/configmap.yaml new file mode 100644 index 000000000..abd0f9efd --- /dev/null +++ b/charts/kea-dhcp/templates/configmap.yaml @@ -0,0 +1,280 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: {{ include "kea-dhcp.fullname" . }} +data: + kea-dhcp4.conf: | + { + {{- if .Values.kea.dhcp4.enabled }} + "Dhcp4": { + "lease-database": { + "type": "memfile", + "persist": true, + "name": "/data/dhcp4.leases" + }, + "interfaces-config": { + "interfaces": [ "INTERFACE" ], + "service-sockets-require-all": true, + "dhcp-socket-type": "{{ .Values.kea.dhcp4.dhcpSocketType }}" + }, + "valid-lifetime": {{ .Values.kea.dhcp4.validlifetime }}, + "renew-timer": {{ .Values.kea.dhcp4.renewtimer }}, + "rebind-timer": {{ .Values.kea.dhcp4.rebindtimer }}, + "reservations-global": true, + "reservations-in-subnet": false, + "control-socket": { + "socket-type": "unix", + "socket-name": "/run/kea/kea-dhcp4-ctrl.sock" + }, + {{- if .Values.kea.ddns.enabled }} + "dhcp-ddns" : { + "enable-updates" : true, + "server-ip" : "127.0.0.1", + "server-port" : 53001, + "max-queue-size" : 2048, + "ncr-protocol" : "UDP", + "ncr-format" : "JSON" + }, + "ddns-send-updates" : true, + "ddns-override-no-update" : true, + "ddns-override-client-update" : true, + "ddns-replace-client-name" : "when-not-present", + "ddns-generated-prefix": {{ .Values.kea.ddns.prefix | quote }}, + "ddns-qualifying-suffix" : {{ .Values.kea.ddns.suffix | quote }}, + "ddns-update-on-renew" : false, + "ddns-use-conflict-resolution" : true, + "hostname-char-set": "[^A-Za-z0-9.-]", + "hostname-char-replacement": "", + {{- end }} + {{- if .Values.kea.dhcp4.options }} + "option-data": {{ .Values.kea.dhcp4.options | toJson }}, + {{- end }} + {{- if .Values.kea.dhcp4.reservations }} + "reservations": {{ .Values.kea.dhcp4.reservations | toJson }}, + {{- end }} + {{- if .Values.kea.dhcp4.subnets }} + "subnet4": {{ .Values.kea.dhcp4.subnets | toJson }}, + {{- end }} + {{- if .Values.kea.dhcp4.sharedNetworks }} + "shared-networks": {{ .Values.kea.dhcp4.sharedNetworks | toJson }}, + {{- end }} + {{- if .Values.kea.dhcp4.clientClasses }} + "client-classes": {{ .Values.kea.dhcp4.clientClasses | toJson }}, + {{- end }} + {{ if gt .Values.replicaCount 1.0 }} + + {{- end }} + "loggers": [{ + "name": "kea-dhcp4", + "output_options": [ + { + "output": "stdout" + } + ], + {{- if eq .Values.kea.dhcp4.loglevel "DEBUG" }} + "debuglevel": {{ .Values.kea.dhcp4.debugLevel }}, + {{- end }} + "severity": "{{ .Values.kea.dhcp4.loglevel }}" + }] + } + {{- end }} + } + kea-dhcp6.conf: | + { + {{- if .Values.kea.dhcp6.enabled }} + "Dhcp6": { + "lease-database": { + "type": "memfile", + "persist": true, + "name": "/data/dhcp6.leases" + }, + "interfaces-config": { + "interfaces": [ "INTERFACE" ], + "service-sockets-require-all": true, + "dhcp-socket-type": "{{ .Values.kea.dhcp6.dhcpSocketType }}" + }, + "valid-lifetime": {{ .Values.kea.dhcp6.validlifetime }}, + "renew-timer": {{ .Values.kea.dhcp6.renewtimer }}, + "rebind-timer": {{ .Values.kea.dhcp6.rebindtimer }}, + "reservations-global": true, + "reservations-in-subnet": false, + "control-socket": { + "socket-type": "unix", + "socket-name": "/run/kea/kea-dhcp6-ctrl.sock" + }, + {{- if .Values.kea.ddns.enabled }} + "dhcp-ddns" : { + "enable-updates" : true, + "server-ip" : "::1", + "server-port" : 53001, + "max-queue-size" : 2048, + "ncr-protocol" : "UDP", + "ncr-format" : "JSON" + }, + "ddns-send-updates" : true, + "ddns-override-no-update" : true, + "ddns-override-client-update" : true, + "ddns-replace-client-name" : "when-not-present", + "ddns-generated-prefix": {{ .Values.kea.ddns.prefix | quote }}, + "ddns-qualifying-suffix" : {{ .Values.kea.ddns.suffix | quote }}, + "ddns-update-on-renew" : false, + "ddns-use-conflict-resolution" : true, + "hostname-char-set": "[^A-Za-z0-9.-]", + "hostname-char-replacement": "", + {{- end }} + {{- if .Values.kea.dhcp6.options }} + "option-data": {{ .Values.kea.dhcp6.options | toJson }}, + {{- end }} + {{- if .Values.kea.dhcp6.reservations }} + "reservations": {{ .Values.kea.dhcp6.reservations | toJson }}, + {{- end }} + {{- if .Values.kea.dhcp6.subnets }} + "subnet6": {{ .Values.kea.dhcp6.subnets | toJson }}, + {{- end }} + {{- if .Values.kea.dhcp6.clientClasses }} + "client-classes": {{ .Values.kea.dhcp6.clientClasses | toJson }}, + {{- end }} + {{ if gt .Values.replicaCount 1.0 }} + + {{- end }} + "loggers": [{ + "name": "kea-dhcp6", + "output_options": [ + { + "output": "stdout" + } + ], + {{- if eq .Values.kea.dhcp6.loglevel "DEBUG" }} + "debuglevel": {{ .Values.kea.dhcp6.debugLevel }}, + {{- end }} + "severity": "{{ .Values.kea.dhcp6.loglevel }}" + }] + } + {{- end }} + } + kea-dhcp-ddns.conf: | + { + {{- if .Values.kea.ddns.enabled }} + "DhcpDdns": { + "forward-ddns": {{ .Values.kea.ddns.forward | toJson }}, + "reverse-ddns": {{ .Values.kea.ddns.reverse | toJson }}, + "control-socket": { + "socket-type": "unix", + "socket-name": "/run/kea/kea-dhcp-ddns-ctrl.sock" + }, + "loggers": [ + { + "name": "kea-dhcp-ddns", + "output_options": [ + { + "output": "stdout" + } + ], + "severity": "{{ .Values.kea.ddns.loglevel }}" + } + ] + } + {{- end }} + } + kea-ctrl-agent.conf: | + { + {{- if .Values.kea.ctrlagent.enabled }} + "Control-agent": { + "http-host": "0.0.0.0", + "http-port": 8000, + "control-sockets": { + "dhcp4": { + "socket-type": "unix", + "socket-name": "/run/kea/kea-dhcp4-ctrl.sock" + }, + "dhcp6": { + "socket-type": "unix", + "socket-name": "/run/kea/kea-dhcp6-ctrl.sock" + }, + "d2": { + "socket-type": "unix", + "socket-name": "/run/kea/kea-dhcp-ddns-ctrl.sock" + } + }, + "loggers": [ + { + "name": "kea-ctrl-agent", + "output_options": [ + { + "output": "stdout" + } + ], + "severity": "{{ .Values.kea.ctrlagent.loglevel }}" + } + ] + } + {{- end}} + } + keactrl.conf: | + #!/bin/sh + + # This is a configuration file for keactrl script which controls + # the startup, shutdown, reconfiguration and gathering the status + # of the Kea's processes. + + # Note that control agent must be launched after servers and netconf last. + + # shellcheck disable=SC2034 + # SC2034: ... appears unused. Verify use (or export if used externally). + + # prefix holds the location where the Kea is installed. + prefix="/usr/local" + + # Location of Kea configuration files. + kea_dhcp4_config_file="/config/kea/kea-dhcp4.conf" + kea_dhcp6_config_file="/config/kea/kea-dhcp6.conf" + kea_dhcp_ddns_config_file="/config/kea/kea-dhcp-ddns.conf" + kea_ctrl_agent_config_file="/config/kea/kea-ctrl-agent.conf" + kea_netconf_config_file="/config/kea/kea-netconf.conf" + + # Location of Kea binaries. + exec_prefix="${prefix}" + dhcp4_srv="${exec_prefix}/sbin/kea-dhcp4" + dhcp6_srv="${exec_prefix}/sbin/kea-dhcp6" + dhcp_ddns_srv="${exec_prefix}/sbin/kea-dhcp-ddns" + ctrl_agent_srv="${exec_prefix}/sbin/kea-ctrl-agent" + netconf_srv="${exec_prefix}/sbin/kea-netconf" + + # Start DHCPv4 server? + dhcp4={{- if .Values.kea.dhcp4.enabled }}yes{{- else }}no{{- end }} + + # Start DHCPv6 server? + dhcp6={{- if .Values.kea.dhcp6.enabled }}yes{{- else }}no{{- end }} + + # Start DHCP DDNS server? + dhcp_ddns={{- if .Values.kea.ddns.enabled }}yes{{- else }}no{{- end }} + + # Start Control Agent? + ctrl_agent={{- if .Values.kea.ctrlagent.enabled }}yes{{- else }}no{{- end }} + + # Start Netconf? + netconf=no + + # Be verbose? + kea_verbose=no + {{ if gt .Values.replicaCount 1.0 }} + ha-init.json: | + + "hooks-libraries": [{ + "library": "/usr/local/lib/kea/hooks/libdhcp_lease_cmds.so", + "parameters": { } + }, { + "library": "/usr/local/lib/kea/hooks/libdhcp_ha.so", + "parameters": { + "high-availability": [{ + "this-server-name": "HOSTNAME", + "mode": "load-balancing", + "heartbeat-delay": 10000, + "max-response-delay": 10000, + "max-ack-delay": 5000, + "max-unacked-clients": 5, + + }] + } + }], + {{- end }} diff --git a/charts/kea-dhcp/templates/metrics-service.yaml b/charts/kea-dhcp/templates/metrics-service.yaml new file mode 100644 index 000000000..a67122886 --- /dev/null +++ b/charts/kea-dhcp/templates/metrics-service.yaml @@ -0,0 +1,30 @@ +{{- if .Values.metrics.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ template "kea-dhcp.fullname" . }}-metrics + labels: + app.kubernetes.io/name: {{ include "kea-dhcp.name" . }} + helm.sh/chart: {{ include "kea-dhcp.chart" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + {{- if .Values.metrics.service.labels -}} + {{ toYaml .Values.metrics.service.labels | nindent 4 }} + {{- end -}} + {{- if .Values.metrics.service.annotations }} + annotations: {{ toYaml .Values.metrics.service.annotations | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.metrics.service.type }} + {{ if eq .Values.metrics.service.type "LoadBalancer" -}} {{ if .Values.metrics.service.loadBalancerIP }} + loadBalancerIP: {{ .Values.metrics.service.loadBalancerIP }} + {{ end -}} + {{- end -}} + ports: + - name: metrics + port: 9547 + targetPort: metrics + selector: + app.kubernetes.io/name: {{ include "kea-dhcp.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/charts/kea-dhcp/templates/role.yaml b/charts/kea-dhcp/templates/role.yaml new file mode 100644 index 000000000..5c89292f0 --- /dev/null +++ b/charts/kea-dhcp/templates/role.yaml @@ -0,0 +1,16 @@ +{{ if gt .Values.replicaCount 1.0 }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "kea-dhcp.fullname" . }} + labels: + {{- include "kea-dhcp.labels" . | nindent 4 }} + {{- with .Values.role.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +rules: +- apiGroups: [""] + resources: ["pods", "services"] + verbs: ["get", "list", "watch"] +{{- end }} diff --git a/charts/kea-dhcp/templates/rolebinding.yaml b/charts/kea-dhcp/templates/rolebinding.yaml new file mode 100644 index 000000000..93b8b88e0 --- /dev/null +++ b/charts/kea-dhcp/templates/rolebinding.yaml @@ -0,0 +1,19 @@ +{{ if gt .Values.replicaCount 1.0 }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "kea-dhcp.fullname" . }} + labels: + {{- include "kea-dhcp.labels" . | nindent 4 }} + {{- with .Values.roleBinding.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +subjects: +- kind: ServiceAccount + name: {{ include "kea-dhcp.serviceAccountName" . }} +roleRef: + kind: Role + name: {{ include "kea-dhcp.fullname" . }} + apiGroup: rbac.authorization.k8s.io +{{- end }} diff --git a/charts/kea-dhcp/templates/service.yaml b/charts/kea-dhcp/templates/service.yaml new file mode 100644 index 000000000..3b16d482c --- /dev/null +++ b/charts/kea-dhcp/templates/service.yaml @@ -0,0 +1,112 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kea-dhcp.fullname" . }}-headless + labels: + {{- include "kea-dhcp.labels" . | nindent 4 }} +spec: + type: ClusterIP + clusterIP: None + ports: + - port: 67 + targetPort: dhcp + protocol: UDP + name: dhcp + - port: 8080 + targetPort: control-agent + protocol: TCP + name: control-agent + selector: + {{- include "kea-dhcp.selectorLabels" . | nindent 4 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kea-dhcp.fullname" . }} + labels: + {{- include "kea-dhcp.labels" . | nindent 4 }} + {{- if .Values.service.dhcp.annotations }} + {{- with .Values.service.dhcp.annotations }} + annotations: + {{- toYaml . | indent 4 }} + {{- end }} + {{- end }} +spec: +{{- if (or (eq .Values.service.dhcp.type "ClusterIP") (empty .Values.service.dhcp.type)) }} + type: ClusterIP + {{- if .Values.service.dhcp.clusterIP }} + clusterIP: {{ .Values.service.dhcp.clusterIP }} + {{end}} +{{- else if eq .Values.service.dhcp.type "LoadBalancer" }} + type: {{ .Values.service.dhcp.type }} + {{- if .Values.service.dhcp.loadBalancerIP }} + loadBalancerIP: {{ .Values.service.dhcp.loadBalancerIP }} + {{- end }} + {{- if .Values.service.dhcp.loadBalancerSourceRanges }} + loadBalancerSourceRanges: +{{ toYaml .Values.service.dhcp.loadBalancerSourceRanges | indent 4 }} + {{- end -}} +{{- else }} + type: {{ .Values.service.dhcp.type }} +{{- end }} +{{- if .Values.service.dhcp.externalIPs }} + externalIPs: +{{ toYaml .Values.service.dhcp.externalIPs | indent 4 }} +{{- end }} + {{- if .Values.service.dhcp.externalTrafficPolicy }} + externalTrafficPolicy: {{ .Values.service.dhcp.externalTrafficPolicy }} + {{- end }} + ports: + - port: {{ .Values.service.dhcp.port }} + targetPort: dhcp + protocol: UDP + name: dhcp + selector: + {{- include "kea-dhcp.selectorLabels" . | nindent 4 }} +--- +{{- if .Values.kea.ctrlagent.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kea-dhcp.fullname" . }}-ctrl + labels: + {{- include "kea-dhcp.labels" . | nindent 4 }} + {{- if .Values.service.ctrl.annotations }} + {{- with .Values.service.ctrl.annotations }} + annotations: + {{- toYaml . | indent 4 }} + {{- end }} + {{- end }} +spec: +{{- if (or (eq .Values.service.ctrl.type "ClusterIP") (empty .Values.service.ctrl.type)) }} + type: ClusterIP + {{- if .Values.service.ctrl.clusterIP }} + clusterIP: {{ .Values.service.ctrl.clusterIP }} + {{end}} +{{- else if eq .Values.service.ctrl.type "LoadBalancer" }} + type: {{ .Values.service.ctrl.type }} + {{- if .Values.service.ctrl.loadBalancerIP }} + loadBalancerIP: {{ .Values.service.ctrl.loadBalancerIP }} + {{- end }} + {{- if .Values.service.ctrl.loadBalancerSourceRanges }} + loadBalancerSourceRanges: +{{ toYaml .Values.service.ctrl.loadBalancerSourceRanges | indent 4 }} + {{- end -}} +{{- else }} + type: {{ .Values.service.ctrl.type }} +{{- end }} +{{- if .Values.service.ctrl.externalIPs }} + externalIPs: +{{ toYaml .Values.service.ctrl.externalIPs | indent 4 }} +{{- end }} + {{- if .Values.service.ctrl.externalTrafficPolicy }} + externalTrafficPolicy: {{ .Values.service.ctrl.externalTrafficPolicy }} + {{- end }} + ports: + - port: {{ .Values.service.ctrl.port }} + targetPort: control-agent + protocol: TCP + name: control-agent + selector: + {{- include "kea-dhcp.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/charts/kea-dhcp/templates/serviceaccount.yaml b/charts/kea-dhcp/templates/serviceaccount.yaml new file mode 100644 index 000000000..2f8e9016c --- /dev/null +++ b/charts/kea-dhcp/templates/serviceaccount.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "kea-dhcp.serviceAccountName" . }} + labels: + {{- include "kea-dhcp.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} diff --git a/charts/kea-dhcp/templates/servicemonitor.yaml b/charts/kea-dhcp/templates/servicemonitor.yaml new file mode 100644 index 000000000..31389052e --- /dev/null +++ b/charts/kea-dhcp/templates/servicemonitor.yaml @@ -0,0 +1,15 @@ +{{- if .Values.metrics.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "kea-dhcp.fullname" . }} + labels: + {{- include "kea-dhcp.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + app.kubernetes.io/name: {{ include "kea-dhcp.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + endpoints: + - port: metrics +{{- end }} diff --git a/charts/kea-dhcp/templates/statefulset.yaml b/charts/kea-dhcp/templates/statefulset.yaml new file mode 100644 index 000000000..dd3bc6c7d --- /dev/null +++ b/charts/kea-dhcp/templates/statefulset.yaml @@ -0,0 +1,201 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "kea-dhcp.fullname" . }} + labels: + {{- include "kea-dhcp.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + serviceName: {{ template "kea-dhcp.fullname" . }}-headless + selector: + matchLabels: + {{- include "kea-dhcp.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "kea-dhcp.selectorLabels" . | nindent 8 }} + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum | trunc 63}} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "kea-dhcp.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + initContainers: + - name: init-kea + securityContext: + {{- toYaml .Values.securityContext | nindent 10 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + command: + - sh + - "-c" + - | + set -ex + cp /mnt/config-map/* /mnt/kea/ + {{- if .Values.kea.interface }} + interface={{ .Values.kea.interface }} + {{- else }} + echo "Finding interface..." + interface=$(route | grep '^default' | grep -o '[^ ]*$') + {{- end }} + echo "Selected interface $interface" + sed -i "s/INTERFACE/${interface}/g" /mnt/kea/kea-dhcp4.conf + sed -i "s/INTERFACE/${interface}/g" /mnt/kea/kea-dhcp6.conf + sed -i "s/HOSTNAME/${HOSTNAME}/g" /mnt/kea/ha-init.json || true + touch /mnt/kea/ha.json + volumeMounts: + - name: kea-config + mountPath: /mnt/kea + - name: kea-configmap + mountPath: /mnt/config-map + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: dhcp + containerPort: 67 + protocol: UDP + - name: control-agent + containerPort: 8000 + protocol: TCP + {{- if .Values.metrics.enabled }} + - name: metrics + containerPort: 9547 + protocol: TCP + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- if .Values.readinessProbe.enabled }} + lifecycle: + preStop: + exec: + command: ["/usr/bin/env", "keactrl", "stop", "-c", "/config/kea/keactrl.conf"] + readinessProbe: + exec: + command: + - /bin/sh + - -c + - > + {{- if .Values.kea.dhcp4.enabled }} + if keactrl status | grep -q 'DHCPv4 server: active'; then + exit 0; + else + exit 1; + fi + {{- end }} + {{- if .Values.kea.dhcp6.enabled }} + if keactrl status | grep -q 'DHCPv6 server: active'; then + exit 0; + else + exit 1; + fi + {{- end }} + initialDelaySeconds: 10 + periodSeconds: 10 + {{- end }} + {{- if .Values.livenessProbe.enabled }} + livenessProbe: + exec: + command: + - /bin/sh + - -c + - > + {{- if .Values.kea.dhcp4.enabled }} + if keactrl status | grep -q 'DHCPv4 server: active'; then + exit 0; + else + exit 1; + fi + {{- end }} + {{- if .Values.kea.dhcp6.enabled }} + if keactrl status | grep -q 'DHCPv6 server: active'; then + exit 0; + else + exit 1; + fi + {{- end }} + initialDelaySeconds: 60 + periodSeconds: 20 + {{- end }} + volumeMounts: + - name: data + mountPath: /data + - name: kea-run + mountPath: /run/kea + # Prevent error Unable to use interprocess sync lockfile (No such file or directory): /data/run/kea/logger_lockfile + - name: kea-run + mountPath: /data/run/kea + - name: kea-config + mountPath: /config/kea + {{ if gt .Values.replicaCount 1.0 }} + - name: {{ .Chart.Name }}-sidecar + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.sidecar.image.repository }}:{{ .Values.sidecar.image.tag | default "latest" }}" + imagePullPolicy: {{ .Values.sidecar.image.pullPolicy }} + resources: + {{- toYaml .Values.sidecar.resources | nindent 12 }} + env: + - name: STATEFULSET_LABEL + valueFrom: + fieldRef: + fieldPath: metadata.labels['app.kubernetes.io/instance'] + volumeMounts: + - name: kea-config + mountPath: /config/kea + {{- end }} + volumes: + - name: kea-config + emptyDir: {} + - name: kea-run + emptyDir: {} + - name: kea-configmap + configMap: + name: "{{ include "kea-dhcp.fullname" . }}" +{{- if .Values.persistence }} +{{- if .Values.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + annotations: + {{- range $key, $value := .Values.persistence.annotations }} + {{ $key }}: {{ $value }} + {{- end }} + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ .Values.persistence.size | quote }} + {{- if .Values.persistence.storageClass }} + {{- if (eq "-" .Values.persistence.storageClass) }} + storageClassName: "" + {{- else }} + storageClassName: "{{ .Values.persistence.storageClass }}" + {{- end }} + {{- end }} +{{- else }} + - name: data + emptyDir: {} +{{- end }} +{{- end }} diff --git a/charts/kea-dhcp/values.schema.json b/charts/kea-dhcp/values.schema.json new file mode 100644 index 000000000..e160142aa --- /dev/null +++ b/charts/kea-dhcp/values.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "replicaCount": { + "type": "integer", + "maximum": 2 + } + } +} diff --git a/charts/kea-dhcp/values.yaml b/charts/kea-dhcp/values.yaml new file mode 100644 index 000000000..3e5fe17e6 --- /dev/null +++ b/charts/kea-dhcp/values.yaml @@ -0,0 +1,215 @@ +# Default values for kea-dhcp. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# More replicas is not available for now due Kea implementation of peers url should be ipv4 or ipv6 addresses instead of dnsname +replicaCount: 1 + +image: + repository: ghcr.io/mglants/kea-dhcp + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" +sidecar: + image: + repository: ghcr.io/mglants/kea-dhcp-sidecar + pullPolicy: IfNotPresent + # Overrides the image tag whose default is latest + tag: v0.0.2 + resources: + limits: + cpu: 50m + memory: 128Mi + requests: + cpu: 10m + memory: 32Mi +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" +clusterDomain: "cluster.local" +kea: + # interface: eth1 - specify interface if needed for multus + dhcp4: + enabled: true + dhcpSocketType: raw + validlifetime: "4000" + renewtimer: "1000" + rebindtimer: "2000" + loglevel: "DEBUG" + debugLevel: 0 + options: [] + # - name: domain-name-servers + # data: 192.168.1.2 + # - name: domain-name + # data: local + # - name: domain-search + # data: local + # - code: 66 + # data: 192.168.1.2 + # name: tftp-server-name + reservations: [] + # - ip-address: "192.168.1.2" + # hw-address: "aa:aa:aa:aa:aa:aa" + # hostname: "hostname" + # option-data: + # - name: "tftp-servers" + # data: "10.1.1.202,10.1.1.203" + subnets: [] + # - subnet: "192.168.1.0/24" + # pools: + # - pool: "192.168.1.10-192.168.1.254" + # option-data: + # - name: "routers" + # data: "172.16.10.1" + dhcp6: + enabled: false + dhcpSocketType: raw + validlifetime: "4000" + renewtimer: "1000" + rebindtimer: "2000" + loglevel: "DEBUG" + debugLevel: 0 + options: [] + reservations: [] + # - ip-address: "2001:db8:1::100" + # duid: "aa:aa:aa:aa:aa:aa" + # hostname: "hostname" + # option-data: + # - name: "dns-servers" + # data: "2001:db8:2::45, 2001:db8:2::100" + subnets: [] + # - subnet: "2001:db8:1::/48" + # pools: + # - pool: "2001:db8:1::/80" + ddns: + enabled: false + loglevel: "DEBUG" + prefix: "myhost" + suffix: "home.arpa." + forward: {} + # ddns-domains: + # - name: home.arpa. + # key-name: '' + # dns-servers: + # - ip-address: 172.16.32.2 + # port: 53 + reverse: {} + # ddns-domains: + # - name: 168.192.in-addr.arpa. + # key-name: '' + # dns-servers: + # - ip-address: 172.16.32.2 + # port: 53 + ctrlagent: + # Needed for HA, monitoring and stork + enabled: false + loglevel: "DEBUG" +metrics: + enabled: false + service: + type: ClusterIP + ## Use serviceLoadBalancerIP to request a specific static IP, + ## otherwise leave blank + # loadBalancerIP: + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9547" + labels: {} +serviceAccount: + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +role: + # Annotations to add to the Role + annotations: {} + +roleBinding: + # Annotations to add to the RoleBinding + annotations: {} + +podAnnotations: {} + +podSecurityContext: {} +# fsGroup: 2000 + +securityContext: {} +# capabilities: +# drop: +# - ALL +# readOnlyRootFilesystem: true +# runAsNonRoot: true +# runAsUser: 1000 + +service: + dhcp: + type: ClusterIP + port: 67 + ## Specify the nodePort value for the LoadBalancer and NodePort service types. + ## ref: https://kubernetes.io/docs/concepts/services-networking/service/#type-nodeport + ## + # nodePort: + ## Provide any additional annotations which may be required. This can be used to + ## set the LoadBalancer service type to internal only. + ## ref: https://kubernetes.io/docs/concepts/services-networking/service/#internal-load-balancer + ## + annotations: {} + ## Use loadBalancerIP to request a specific static IP, + ## otherwise leave blank + ## + loadBalancerIP: + # loadBalancerSourceRanges: [] + ## Set the externalTrafficPolicy in the Service to either Cluster or Local + # externalTrafficPolicy: Cluster + + ctrl: + type: ClusterIP + port: 8000 + ## Specify the nodePort value for the LoadBalancer and NodePort service types. + ## ref: https://kubernetes.io/docs/concepts/services-networking/service/#type-nodeport + ## + # nodePort: + ## Provide any additional annotations which may be required. This can be used to + ## set the LoadBalancer service type to internal only. + ## ref: https://kubernetes.io/docs/concepts/services-networking/service/#internal-load-balancer + ## + annotations: {} + ## Use loadBalancerIP to request a specific static IP, + ## otherwise leave blank + ## + loadBalancerIP: + # loadBalancerSourceRanges: [] + ## Set the externalTrafficPolicy in the Service to either Cluster or Local + # externalTrafficPolicy: Cluster +resources: + limits: + cpu: 200m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi +readinessProbe: + enabled: true +livenessProbe: + enabled: true +nodeSelector: {} + +tolerations: [] + +affinity: {} +# affinity: +# podAntiAffinity: +# requiredDuringSchedulingIgnoredDuringExecution: +# - labelSelector: +# matchExpressions: +# - key: app.kubernetes.io/name +# operator: In +# values: +# - kea-dhcp +# topologyKey: kubernetes.io/hostname + +persistence: + enabled: false + size: 100Mi From 26ca532a2454393b925441214399793c08500b55 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 6 Jul 2026 16:25:51 +0100 Subject: [PATCH 22/48] feat: use local kea-dhcp chart fork instead of upstream repo --- .../argocd-understack/templates/application-kea.yaml | 10 ++++------ charts/argocd-understack/values.yaml | 3 --- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/charts/argocd-understack/templates/application-kea.yaml b/charts/argocd-understack/templates/application-kea.yaml index 494bbd173..832c2442d 100644 --- a/charts/argocd-understack/templates/application-kea.yaml +++ b/charts/argocd-understack/templates/application-kea.yaml @@ -16,18 +16,16 @@ spec: server: {{ $.Values.cluster_server }} project: understack sources: - - chart: kea-dhcp + - ref: understack + path: 'charts/kea-dhcp' + repoURL: {{ include "understack.understack_url" $ }} + targetRevision: {{ include "understack.understack_ref" $ }} helm: ignoreMissingValueFiles: true releaseName: kea valueFiles: - $understack/components/kea/values.yaml - $deploy/{{ include "understack.deploy_path" $ }}/kea/values.yaml - repoURL: https://mglants.github.io/charts - targetRevision: {{ $.Values.site.kea.chartVersion }} - - ref: understack - repoURL: {{ include "understack.understack_url" $ }} - targetRevision: {{ include "understack.understack_ref" $ }} - path: {{ include "understack.deploy_path" $ }}/kea ref: deploy repoURL: {{ include "understack.deploy_url" $ }} diff --git a/charts/argocd-understack/values.yaml b/charts/argocd-understack/values.yaml index a801d691a..858fa008a 100644 --- a/charts/argocd-understack/values.yaml +++ b/charts/argocd-understack/values.yaml @@ -480,9 +480,6 @@ site: # -- Enable/disable deploying Kea DHCP # @default -- false enabled: false - # -- Chart version for Kea DHCP - # renovate: datasource=helm depName=kea-dhcp registryUrl=https://mglants.github.io/charts - chartVersion: "0.7.1" # -- External DNS operator external_dns: From d041e5b7dfa5b5a7b6d5910808c125d7ff37b172 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Mon, 6 Jul 2026 18:10:49 +0100 Subject: [PATCH 23/48] change reservations in kea to include client classes --- .../ironic_understack/dhcp/kea.py | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 073514b4f..07c0a3976 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -1,9 +1,10 @@ +from urllib.parse import urlparse + import requests from ironic import objects from ironic.common import exception from ironic.dhcp import base from oslo_log import log as logging -from urllib.parse import urlparse from ironic_understack.conf import CONF @@ -74,7 +75,7 @@ def get_statistics(self, name=None): return self._make_request("statistic-get", {"name": name}) return self._make_request("statistic-get-all", {}) - def _update_host_reservation(self, hw_address, boot_file_name=None, remove=False): + def _update_host_reservation(self, hw_address, kea_options=None, remove=False): """Modify a host reservation in the Kea config file or hosts database.""" # TODO(cid) add support/replace with the host database configuration # option in a central database managed by Ironic; the commands to have @@ -84,23 +85,38 @@ def _update_host_reservation(self, hw_address, boot_file_name=None, remove=False config = self.get_config() config["arguments"].pop("hash", None) dhcp4_config = config["arguments"]["Dhcp4"] - next_server = urlparse(boot_file_name).hostname reservations = dhcp4_config.get("reservations", []) found = False for reservation in reservations: if reservation.get("hw-address") == hw_address: - reservation["boot-file-name"] = boot_file_name - reservation["next-server"] = next_server + if "httpclient" in reservation.get("client-classes", []): + boot_file_name = kea_options["http"]["boot_file_name"] + reservation["boot-file-name"] = boot_file_name + next_server = urlparse(boot_file_name).hostname + reservation["next-server"] = next_server + elif "pxeclient" in reservation.get("client-classes", []): + boot_file_name = kea_options["pxe"]["boot_file_name"] + reservation["boot-file-name"] = boot_file_name + next_server = urlparse(boot_file_name).hostname + reservation["next-server"] = next_server found = True - break - if not found: + boot_file_name = kea_options["http"]["boot_file_name"] + reservations.append( + { + "hw-address": hw_address, + "boot-file-name": kea_options["http"]["boot_file_name"], + "next-server": urlparse(boot_file_name).hostname, + "client-classes": ["httpclient"], + } + ) reservations.append( { "hw-address": hw_address, - "boot-file-name": boot_file_name, - "next-server": next_server, + "boot-file-name": kea_options["pxe"]["boot_file_name"], + "next-server": urlparse(boot_file_name).hostname, + "client-classes": ["pxeclient"], } ) dhcp4_config["reservations"] = reservations @@ -116,13 +132,20 @@ def update_port_dhcp_opts(self, port_id, dhcp_options, context=None): """Update DHCP options for a specific port in Kea.""" port = objects.Port.get(context, port_id) - boot_file_name = "" + kea_options = {} print(f"DHCP_OPTIONS: {dhcp_options}") for opt in dhcp_options: - if opt["opt_name"].startswith("!"): - boot_file_name = opt["opt_value"] - break - return self._update_host_reservation(port.address, boot_file_name) + if opt["opt_name"] == "!175,67": + kea_options["http"] = { + "boot_file_name": opt["opt_value"], + "client_class": "httpclient", + } + elif opt["opt_name"] == "67": + kea_options["pxe"] = { + "boot_file_name": opt["opt_value"], + "client_class": "pxeclient", + } + return self._update_host_reservation(port.address, kea_options) def update_dhcp_opts(self, task, options, vifs=None): """Update DHCP options for all ports associated with a node.""" From 82eaddc4ec74f70b0ec7201feae8447415023d60 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 7 Jul 2026 11:55:20 +0100 Subject: [PATCH 24/48] feat(kea-dhcp): add support for optionDef --- charts/kea-dhcp/templates/configmap.yaml | 3 +++ charts/kea-dhcp/values.yaml | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/charts/kea-dhcp/templates/configmap.yaml b/charts/kea-dhcp/templates/configmap.yaml index abd0f9efd..be9f33d5a 100644 --- a/charts/kea-dhcp/templates/configmap.yaml +++ b/charts/kea-dhcp/templates/configmap.yaml @@ -49,6 +49,9 @@ data: {{- if .Values.kea.dhcp4.options }} "option-data": {{ .Values.kea.dhcp4.options | toJson }}, {{- end }} + {{- if .Values.kea.dhcp4.optionDef }} + "option-def": {{ .Values.kea.dhcp4.optionDef | toJson }}, + {{- end }} {{- if .Values.kea.dhcp4.reservations }} "reservations": {{ .Values.kea.dhcp4.reservations | toJson }}, {{- end }} diff --git a/charts/kea-dhcp/values.yaml b/charts/kea-dhcp/values.yaml index 3e5fe17e6..63f6e1da6 100644 --- a/charts/kea-dhcp/values.yaml +++ b/charts/kea-dhcp/values.yaml @@ -47,6 +47,11 @@ kea: # - code: 66 # data: 192.168.1.2 # name: tftp-server-name + optionDef: [] + # - name: "boot-file-base" + # code: "234" + # type: "string" + # space: "dhcp4" reservations: [] # - ip-address: "192.168.1.2" # hw-address: "aa:aa:aa:aa:aa:aa" From 2aeac6e197e8f56a137999e9ced641f93ba4efce Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 7 Jul 2026 12:06:39 +0100 Subject: [PATCH 25/48] feat(kea-dhcp): add support for hooks libraries --- charts/kea-dhcp/templates/configmap.yaml | 3 +++ charts/kea-dhcp/values.yaml | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/charts/kea-dhcp/templates/configmap.yaml b/charts/kea-dhcp/templates/configmap.yaml index be9f33d5a..954b2cf63 100644 --- a/charts/kea-dhcp/templates/configmap.yaml +++ b/charts/kea-dhcp/templates/configmap.yaml @@ -64,6 +64,9 @@ data: {{- if .Values.kea.dhcp4.clientClasses }} "client-classes": {{ .Values.kea.dhcp4.clientClasses | toJson }}, {{- end }} + {{- if .Values.kea.dhcp4.hooksLibraries }} + "hooks-libraries": {{ .Values.kea.dhcp4.hooksLibraries | toJson }}, + {{- end }} {{ if gt .Values.replicaCount 1.0 }} {{- end }} diff --git a/charts/kea-dhcp/values.yaml b/charts/kea-dhcp/values.yaml index 63f6e1da6..1eab293e7 100644 --- a/charts/kea-dhcp/values.yaml +++ b/charts/kea-dhcp/values.yaml @@ -52,6 +52,14 @@ kea: # code: "234" # type: "string" # space: "dhcp4" + hooksLibraries: [] + # - library: /usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_flex_option.so + # parameters: + # options: + # - code: 67 + # space: dhcp4 + # csv-format: true + # supersede: myvalue reservations: [] # - ip-address: "192.168.1.2" # hw-address: "aa:aa:aa:aa:aa:aa" From 13556f10df608f36edbd5af39e3c358a9b143f8f Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Tue, 7 Jul 2026 12:42:10 +0100 Subject: [PATCH 26/48] adjust kea reservations --- .../ironic_understack/dhcp/kea.py | 53 ++++++------------- 1 file changed, 15 insertions(+), 38 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 07c0a3976..cf3ba9e3a 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -75,7 +75,7 @@ def get_statistics(self, name=None): return self._make_request("statistic-get", {"name": name}) return self._make_request("statistic-get-all", {}) - def _update_host_reservation(self, hw_address, kea_options=None, remove=False): + def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=False): """Modify a host reservation in the Kea config file or hosts database.""" # TODO(cid) add support/replace with the host database configuration # option in a central database managed by Ironic; the commands to have @@ -84,44 +84,30 @@ def _update_host_reservation(self, hw_address, kea_options=None, remove=False): try: config = self.get_config() config["arguments"].pop("hash", None) - dhcp4_config = config["arguments"]["Dhcp4"] + dhcp4_config = config["arguments"]["Dhcp4"]["subnet4"][0] reservations = dhcp4_config.get("reservations", []) found = False for reservation in reservations: if reservation.get("hw-address") == hw_address: - if "httpclient" in reservation.get("client-classes", []): - boot_file_name = kea_options["http"]["boot_file_name"] - reservation["boot-file-name"] = boot_file_name - next_server = urlparse(boot_file_name).hostname - reservation["next-server"] = next_server - elif "pxeclient" in reservation.get("client-classes", []): - boot_file_name = kea_options["pxe"]["boot_file_name"] - reservation["boot-file-name"] = boot_file_name - next_server = urlparse(boot_file_name).hostname - reservation["next-server"] = next_server + reservation["option-data"] = [ + {"name": "boot-file-base", "data": boot_file_prefix} + ] found = True + break if not found: - boot_file_name = kea_options["http"]["boot_file_name"] reservations.append( { "hw-address": hw_address, - "boot-file-name": kea_options["http"]["boot_file_name"], - "next-server": urlparse(boot_file_name).hostname, - "client-classes": ["httpclient"], - } - ) - reservations.append( - { - "hw-address": hw_address, - "boot-file-name": kea_options["pxe"]["boot_file_name"], - "next-server": urlparse(boot_file_name).hostname, - "client-classes": ["pxeclient"], + "option-data": [ + {"name": "boot-file-base", "data": boot_file_prefix}, + ], } ) dhcp4_config["reservations"] = reservations - config["arguments"]["Dhcp4"] = dhcp4_config + print(dhcp4_config) + config["arguments"]["Dhcp4"]["subnet4"][0] = dhcp4_config self.set_config(config["arguments"]) return True except Exception as e: @@ -132,20 +118,11 @@ def update_port_dhcp_opts(self, port_id, dhcp_options, context=None): """Update DHCP options for a specific port in Kea.""" port = objects.Port.get(context, port_id) - kea_options = {} - print(f"DHCP_OPTIONS: {dhcp_options}") for opt in dhcp_options: - if opt["opt_name"] == "!175,67": - kea_options["http"] = { - "boot_file_name": opt["opt_value"], - "client_class": "httpclient", - } - elif opt["opt_name"] == "67": - kea_options["pxe"] = { - "boot_file_name": opt["opt_value"], - "client_class": "pxeclient", - } - return self._update_host_reservation(port.address, kea_options) + if opt["opt_name"] == "67": + parsed_url = urlparse(opt["opt_value"]) + boot_file_prefix = f"{parsed_url.scheme}://{parsed_url.netloc}/" + return self._update_host_reservation(port.address, boot_file_prefix) def update_dhcp_opts(self, task, options, vifs=None): """Update DHCP options for all ports associated with a node.""" From 55142c0e1c91ac2028cab80befdf59f830fee4f2 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 7 Jul 2026 15:28:11 +0100 Subject: [PATCH 27/48] feat(kea-dhcp): add support for overriding loggers --- charts/kea-dhcp/templates/configmap.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/charts/kea-dhcp/templates/configmap.yaml b/charts/kea-dhcp/templates/configmap.yaml index 954b2cf63..cb8c783ce 100644 --- a/charts/kea-dhcp/templates/configmap.yaml +++ b/charts/kea-dhcp/templates/configmap.yaml @@ -70,6 +70,9 @@ data: {{ if gt .Values.replicaCount 1.0 }} {{- end }} + {{- if .Values.kea.dhcp4.loggers }} + "loggers": {{ .Values.kea.dhcp4.loggers | toJson }} + {{- else}} "loggers": [{ "name": "kea-dhcp4", "output_options": [ @@ -82,6 +85,7 @@ data: {{- end }} "severity": "{{ .Values.kea.dhcp4.loglevel }}" }] + {{- end }} } {{- end }} } From f96159b1228caab0bd44c40b5721556f45bcf4d1 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Tue, 7 Jul 2026 16:00:03 +0100 Subject: [PATCH 28/48] add always-send: true to kea options --- .../ironic-understack/ironic_understack/dhcp/kea.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index cf3ba9e3a..e8c9bb09e 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -91,7 +91,11 @@ def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=Fal for reservation in reservations: if reservation.get("hw-address") == hw_address: reservation["option-data"] = [ - {"name": "boot-file-base", "data": boot_file_prefix} + { + "name": "boot-file-base", + "data": boot_file_prefix, + "always-send": True, + } ] found = True break @@ -100,7 +104,11 @@ def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=Fal { "hw-address": hw_address, "option-data": [ - {"name": "boot-file-base", "data": boot_file_prefix}, + { + "name": "boot-file-base", + "data": boot_file_prefix, + "always-send": True, + }, ], } ) From 6ce15cba2279eeb7f0d892897477915521022962 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Tue, 7 Jul 2026 16:41:06 +0100 Subject: [PATCH 29/48] send only client-classes for kea options --- .../ironic_understack/dhcp/kea.py | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index e8c9bb09e..175670c19 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -90,26 +90,28 @@ def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=Fal found = False for reservation in reservations: if reservation.get("hw-address") == hw_address: - reservation["option-data"] = [ - { - "name": "boot-file-base", - "data": boot_file_prefix, - "always-send": True, - } - ] + # reservation["option-data"] = [ + # { + # "name": "boot-file-base", + # "data": boot_file_prefix, + # "always-send": True, + # } + # ] + reservation["client-classes"] = ["BOOTSRV_A"] found = True break if not found: reservations.append( { "hw-address": hw_address, - "option-data": [ - { - "name": "boot-file-base", - "data": boot_file_prefix, - "always-send": True, - }, - ], + # "option-data": [ + # { + # "name": "boot-file-base", + # "data": boot_file_prefix, + # "always-send": True, + # }, + # ], + "client-classes": ["BOOTSRV_A"], } ) dhcp4_config["reservations"] = reservations From 705cc8f43737574a22a9d0a1fc129cd8af5f83d5 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Tue, 7 Jul 2026 16:57:58 +0100 Subject: [PATCH 30/48] move kea reservations to global scope --- python/ironic-understack/ironic_understack/dhcp/kea.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 175670c19..2036cd946 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -84,7 +84,7 @@ def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=Fal try: config = self.get_config() config["arguments"].pop("hash", None) - dhcp4_config = config["arguments"]["Dhcp4"]["subnet4"][0] + dhcp4_config = config["arguments"]["Dhcp4"] reservations = dhcp4_config.get("reservations", []) found = False @@ -117,7 +117,7 @@ def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=Fal dhcp4_config["reservations"] = reservations print(dhcp4_config) - config["arguments"]["Dhcp4"]["subnet4"][0] = dhcp4_config + config["arguments"]["Dhcp4"] = dhcp4_config self.set_config(config["arguments"]) return True except Exception as e: From ab84f1a2c096ac78dca8869a2b5475ab0d965e78 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 7 Jul 2026 16:59:31 +0100 Subject: [PATCH 31/48] kea: change early-global-reservations-lookup to true --- charts/kea-dhcp/templates/configmap.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/charts/kea-dhcp/templates/configmap.yaml b/charts/kea-dhcp/templates/configmap.yaml index cb8c783ce..c02f357e3 100644 --- a/charts/kea-dhcp/templates/configmap.yaml +++ b/charts/kea-dhcp/templates/configmap.yaml @@ -22,6 +22,7 @@ data: "rebind-timer": {{ .Values.kea.dhcp4.rebindtimer }}, "reservations-global": true, "reservations-in-subnet": false, + "early-global-reservations-lookup": true, "control-socket": { "socket-type": "unix", "socket-name": "/run/kea/kea-dhcp4-ctrl.sock" From 1310e6209313ee46b376f152ff9d51cf18e7ec4b Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Tue, 7 Jul 2026 17:27:19 +0100 Subject: [PATCH 32/48] add options-data to kea in global reservations --- .../ironic_understack/dhcp/kea.py | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 2036cd946..7ff49c154 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -90,28 +90,26 @@ def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=Fal found = False for reservation in reservations: if reservation.get("hw-address") == hw_address: - # reservation["option-data"] = [ - # { - # "name": "boot-file-base", - # "data": boot_file_prefix, - # "always-send": True, - # } - # ] - reservation["client-classes"] = ["BOOTSRV_A"] + reservation["option-data"] = [ + { + "name": "boot-file-base", + "data": boot_file_prefix, + "always-send": True, + } + ] found = True break if not found: reservations.append( { "hw-address": hw_address, - # "option-data": [ - # { - # "name": "boot-file-base", - # "data": boot_file_prefix, - # "always-send": True, - # }, - # ], - "client-classes": ["BOOTSRV_A"], + "option-data": [ + { + "name": "boot-file-base", + "data": boot_file_prefix, + "always-send": True, + }, + ], } ) dhcp4_config["reservations"] = reservations From 5715578cf647555689711eabc559923d7bc9f3b6 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 7 Jul 2026 18:16:23 +0100 Subject: [PATCH 33/48] Revert "add options-data to kea in global reservations" This reverts commit 1310e6209313ee46b376f152ff9d51cf18e7ec4b. We cannot use option 234, because the flex_option expression only has access to the DHCPDISCOVER, not reservations. --- .../ironic_understack/dhcp/kea.py | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 7ff49c154..2036cd946 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -90,26 +90,28 @@ def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=Fal found = False for reservation in reservations: if reservation.get("hw-address") == hw_address: - reservation["option-data"] = [ - { - "name": "boot-file-base", - "data": boot_file_prefix, - "always-send": True, - } - ] + # reservation["option-data"] = [ + # { + # "name": "boot-file-base", + # "data": boot_file_prefix, + # "always-send": True, + # } + # ] + reservation["client-classes"] = ["BOOTSRV_A"] found = True break if not found: reservations.append( { "hw-address": hw_address, - "option-data": [ - { - "name": "boot-file-base", - "data": boot_file_prefix, - "always-send": True, - }, - ], + # "option-data": [ + # { + # "name": "boot-file-base", + # "data": boot_file_prefix, + # "always-send": True, + # }, + # ], + "client-classes": ["BOOTSRV_A"], } ) dhcp4_config["reservations"] = reservations From 5c198815344a49ad03bc6203e41d498e174195f8 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Wed, 8 Jul 2026 12:07:54 +0100 Subject: [PATCH 34/48] fix cleanup for kea --- .../ironic_understack/dhcp/kea.py | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 2036cd946..8efb234d4 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -90,33 +90,24 @@ def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=Fal found = False for reservation in reservations: if reservation.get("hw-address") == hw_address: - # reservation["option-data"] = [ - # { - # "name": "boot-file-base", - # "data": boot_file_prefix, - # "always-send": True, - # } - # ] - reservation["client-classes"] = ["BOOTSRV_A"] + if not remove: + reservation["client-classes"] = ["BOOTSRV_A"] + else: + reservations.pop(reservation) found = True break if not found: - reservations.append( - { - "hw-address": hw_address, - # "option-data": [ - # { - # "name": "boot-file-base", - # "data": boot_file_prefix, - # "always-send": True, - # }, - # ], - "client-classes": ["BOOTSRV_A"], - } - ) - dhcp4_config["reservations"] = reservations + if not remove: + reservations.append( + { + "hw-address": hw_address, + "client-classes": ["BOOTSRV_A"], + } + ) + dhcp4_config["reservations"] = reservations + else: + return True - print(dhcp4_config) config["arguments"]["Dhcp4"] = dhcp4_config self.set_config(config["arguments"]) return True From 5bb0ae95e57e78d8562d49c68c08b06e786a8cb1 Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Wed, 8 Jul 2026 13:52:09 +0100 Subject: [PATCH 35/48] convert dnsmasq to serve dns only --- components/ironic/dnsmasq-cm.yaml | 12 +- .../{dnsmasq-ss.yaml => dnsmasq-deploy.yaml} | 25 +-- components/ironic/dnsmasq-pvc.yaml | 29 --- components/ironic/kustomization.yaml | 4 +- components/ironic/values.yaml | 10 - containers/dnsmasq/Dockerfile | 51 +----- .../dnsmasq/dnsmasq/dhcp-allowed-srvids.patch | 173 ------------------ containers/dnsmasq/dnsmasq/dnsmasq.conf.j2 | 114 +----------- containers/dnsmasq/dnsmasq/entry-point.sh | 2 - 9 files changed, 16 insertions(+), 404 deletions(-) rename components/ironic/{dnsmasq-ss.yaml => dnsmasq-deploy.yaml} (66%) delete mode 100644 components/ironic/dnsmasq-pvc.yaml delete mode 100644 containers/dnsmasq/dnsmasq/dhcp-allowed-srvids.patch diff --git a/components/ironic/dnsmasq-cm.yaml b/components/ironic/dnsmasq-cm.yaml index 8a9046c89..20919c21f 100644 --- a/components/ironic/dnsmasq-cm.yaml +++ b/components/ironic/dnsmasq-cm.yaml @@ -3,19 +3,11 @@ kind: ConfigMap metadata: name: ironic-dnsmasq data: - # interface to listen on the host - PROVISIONER_INTERFACE: "eth0" - # common separated list of DHCP tagged configs - DHCP_TAGS: tag1 - # When defining the IP address range, make sure to include subnet - # information, especially for the pools serving relayed requests - DHCP_RANGE_TAG1: 192.168.200.4,192.168.200.12,255.255.255.0,192.168.200.255,30m - DHCP_OPTION_TAG1_ROUTER: 192.168.200.1 # external IP address of Ingress. Used to populate DNS A records for the # Understack components INGRESS_IP: 192.168.1.177 + # DNS zone suffix for wildcard A record pointing to INGRESS_IP + DNS_ZONE: understack.local DNS_PORT: '53' - LOG_DNS_QUERIES: 'true' - LOG_DHCP_QUERIES: 'true' # If you want to print rendered dnsmasq.conf in the logs # DEBUG_DNSMASQ_CONF: "yes" diff --git a/components/ironic/dnsmasq-ss.yaml b/components/ironic/dnsmasq-deploy.yaml similarity index 66% rename from components/ironic/dnsmasq-ss.yaml rename to components/ironic/dnsmasq-deploy.yaml index 9a03dc53e..ad95c66ea 100644 --- a/components/ironic/dnsmasq-ss.yaml +++ b/components/ironic/dnsmasq-deploy.yaml @@ -10,20 +10,16 @@ spec: - name: dns port: 53 protocol: UDP - - name: dhcp - port: 67 - protocol: UDP clusterIP: None selector: application: ironic-dnsmasq --- apiVersion: apps/v1 -kind: StatefulSet +kind: Deployment metadata: name: ironic-dnsmasq namespace: openstack spec: - serviceName: ironic-dnsmasq replicas: 1 selector: matchLabels: @@ -33,8 +29,6 @@ spec: labels: application: ironic-dnsmasq spec: - nodeSelector: - dhcp_role: server hostNetwork: true containers: - name: dnsmasq @@ -43,8 +37,7 @@ spec: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: - # needed for privileged ports and to send ICMP packets - add: ["NET_ADMIN", "NET_RAW"] + add: ["NET_BIND_SERVICE"] args: - /usr/sbin/dnsmasq - '--no-daemon' @@ -56,25 +49,13 @@ spec: - name: dns containerPort: 53 protocol: UDP - - name: dhcp - containerPort: 67 - protocol: UDP - - name: tftp - containerPort: 69 - protocol: UDP volumeMounts: - name: pod-tmp mountPath: /tmp - name: pod-dnsmasq-d mountPath: /etc/dnsmasq.d - - name: pod-dhcp - mountPath: /var/lib/misc volumes: - name: pod-tmp emptyDir: {} - name: pod-dnsmasq-d - persistentVolumeClaim: - claimName: dnsmasq-ironic - - name: pod-dhcp - persistentVolumeClaim: - claimName: dnsmasq-dhcp + emptyDir: {} diff --git a/components/ironic/dnsmasq-pvc.yaml b/components/ironic/dnsmasq-pvc.yaml deleted file mode 100644 index 63e0b350e..000000000 --- a/components/ironic/dnsmasq-pvc.yaml +++ /dev/null @@ -1,29 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - labels: - undercloud.local/purpose: dnsmasq-etc - name: dnsmasq-ironic - namespace: openstack -spec: - accessModes: - - ReadWriteMany - storageClassName: ceph-fs-ec - resources: - requests: - storage: 16Mi ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - labels: - undercloud.local/purpose: dnsmasq-dhcp - name: dnsmasq-dhcp - namespace: openstack -spec: - accessModes: - - ReadWriteMany - storageClassName: ceph-fs-ec - resources: - requests: - storage: 16Mi diff --git a/components/ironic/kustomization.yaml b/components/ironic/kustomization.yaml index 4998ef477..b60f8eeb9 100644 --- a/components/ironic/kustomization.yaml +++ b/components/ironic/kustomization.yaml @@ -5,8 +5,8 @@ kind: Kustomization resources: - ironic-mariadb-db.yaml - ironic-rabbitmq-queue.yaml - - dnsmasq-pvc.yaml -# - dnsmasq-ss.yaml + - dnsmasq-cm.yaml + - dnsmasq-deploy.yaml - ironic-ks-user-baremetal.yaml # less than ideal addition but necessary so that we can have the ironic.conf.d loading # working due to the way the chart hardcodes the config-file parameter which then diff --git a/components/ironic/values.yaml b/components/ironic/values.yaml index 2e0ff8e60..973f2794f 100644 --- a/components/ironic/values.yaml +++ b/components/ironic/values.yaml @@ -242,22 +242,12 @@ pod: ironic_conductor: ironic_conductor: volumeMounts: - - name: dnsmasq-ironic - mountPath: /etc/dnsmasq.d/ - - name: dnsmasq-dhcp - mountPath: /var/lib/dnsmasq/ - name: device-types mountPath: /var/lib/understack/device-types - name: ironic-inspection-rules mountPath: /etc/ironic/inspection-rules/ readOnly: true volumes: - - name: dnsmasq-ironic - persistentVolumeClaim: - claimName: dnsmasq-ironic - - name: dnsmasq-dhcp - persistentVolumeClaim: - claimName: dnsmasq-dhcp - name: device-types configMap: name: device-types diff --git a/containers/dnsmasq/Dockerfile b/containers/dnsmasq/Dockerfile index dbfa5ad34..cf7de89c0 100644 --- a/containers/dnsmasq/Dockerfile +++ b/containers/dnsmasq/Dockerfile @@ -1,55 +1,12 @@ # syntax=docker/dockerfile:1 -FROM debian:bookworm-20240408-slim AS builder +FROM debian:forky-slim AS prod -ENV DEBFULLNAME="Marek Skrobacki" -ENV DEBEMAIL="marek.skrobacki@rackspace.co.uk" -ENV QUILT_PATCHES=debian/patches - -RUN echo "deb-src http://deb.debian.org/debian bookworm main" > /etc/apt/sources.list -RUN apt-get update && apt-get -y install \ - build-essential \ - devscripts \ - fakeroot \ - debhelper \ - automake \ - autotools-dev \ - pkg-config \ - ca-certificates \ - quilt \ - ipxe \ - --no-install-recommends -WORKDIR /src -RUN apt-get -y build-dep dnsmasq -RUN apt-get -y source dnsmasq -# copy in our patch -COPY containers/dnsmasq/dnsmasq/dhcp-allowed-srvids.patch /src/ -# setup the patch to be built into the quilt file -# set our version number to a local override suffixed by '.uc1' -RUN cd /src/dnsmasq-* && \ - mkdir -p debian/patches && \ - mv /src/dhcp-allowed-srvids.patch debian/patches && \ - dch -l .uc "patched for dhcp-allowed-srvids" && \ - echo dhcp-allowed-srvids.patch >> debian/patches/series && \ - quilt push -a -# build it -RUN cd /src/dnsmasq-* && dpkg-buildpackage -rfakeroot - - -FROM debian:bookworm-20240408-slim AS prod - -LABEL org.opencontainers.image.description="dnsmasq for Understack's Ironic" - -COPY --from=builder /src/dnsmasq-base_*_amd64.deb /tmp -COPY --from=builder /src/dnsmasq_*_all.deb /tmp +LABEL org.opencontainers.image.description="dnsmasq DNS-only for Understack" RUN apt-get update && \ - apt-get -y install --no-install-recommends python3-jinja2 ipxe && \ - apt-get -y --fix-broken install --no-install-recommends /tmp/dnsmasq-base_*_amd64.deb /tmp/dnsmasq_*_all.deb && \ + apt-get -y install --no-install-recommends dnsmasq-base python3-jinja2 && \ apt-get clean && \ - rm -rf /var/lib/apt/lists/* /tmp/*.deb - -RUN mkdir -p /var/lib/openstack-helm/tftpboot && \ - cp /usr/lib/ipxe/snponly.efi /var/lib/openstack-helm/tftpboot/ + rm -rf /var/lib/apt/lists/* COPY containers/dnsmasq/common/helpers.sh /helpers.sh COPY containers/dnsmasq/dnsmasq/entry-point.sh /entry-point.sh diff --git a/containers/dnsmasq/dnsmasq/dhcp-allowed-srvids.patch b/containers/dnsmasq/dnsmasq/dhcp-allowed-srvids.patch deleted file mode 100644 index bb9ace558..000000000 --- a/containers/dnsmasq/dnsmasq/dhcp-allowed-srvids.patch +++ /dev/null @@ -1,173 +0,0 @@ -From a3bfd99772edea6a7b78e30b7f3d4a80997323bd Mon Sep 17 00:00:00 2001 -From: Marek Skrobacki -Date: Wed, 15 Jan 2025 17:10:57 -0600 -Subject: [PATCH] dd support for dhcp-allowed-srvids - ---- - man/dnsmasq.8 | 19 +++++++++++++++++++ - src/dnsmasq.h | 2 ++ - src/option.c | 15 +++++++++++++++ - src/rfc2131.c | 46 ++++++++++++++++++++++++++++++++++++++-------- - 4 files changed, 74 insertions(+), 8 deletions(-) - -diff --git a/man/dnsmasq.8 b/man/dnsmasq.8 -index 32bdeff..8a6cb3e 100644 ---- a/man/dnsmasq.8 -+++ b/man/dnsmasq.8 -@@ -2026,6 +2026,25 @@ form is used, there must be a route to all of the addresses configured on the in - The two-address form of shared-network is also usable with a DHCP relay: the first address - is the address of the relay and the second, as before, specifies an extra subnet which - addresses may be allocated from. -+.TP -+.B --dhcp-allowed-srvids[=] -+(IPv4 Only) If the DHCP server is running inside a container or behind a load balancer, the -+DHCPREQUEST arriving at dnsmasq for processing may have a Server ID (option 54) -+configured with an IP address that is not assigned to the local interface. In -+this case, dnsmasq will check if the 'Server Identifier Override' option was -+set in the incoming packet. -+.IP -+If it was not set, the packet is dropped. -+.IP -+If it was set, dnsmasq evaluates the Server ID against the value provided in \'Server ID Override\' suboption -+11, as outlined in RFC5107. -+.IP -+In both cases, there is no match against the \'backend\' IP address configured on the interface. -+.IP -+The \fB\-\-dhcp\-allowed\-srvids\fP option allows disabling this security -+mechanism for specific address(es). When enabled, the incoming DHCPREQUEST is -+evaluated against the provided value(s) instead of the addresses configured on -+the local interfaces. - - .TP - .B \-s, --domain=[[,
[,local]]|] -diff --git a/src/dnsmasq.h b/src/dnsmasq.h -index e455c3f..6f52e3e 100644 ---- a/src/dnsmasq.h -+++ b/src/dnsmasq.h -@@ -1206,11 +1206,13 @@ extern struct daemon { - struct pxe_service *pxe_services; - struct tag_if *tag_if; - struct addr_list *override_relays; -+ struct addr_list *allowed_srvids; - struct dhcp_relay *relay4, *relay6; - struct delay_config *delay_conf; - int override; - int enable_pxe; - int doing_ra, doing_dhcp6; -+ int allowing_custom_srvids; - struct dhcp_netid_list *dhcp_ignore, *dhcp_ignore_names, *dhcp_gen_names; - struct dhcp_netid_list *force_broadcast, *bootp_dynamic; - struct hostsfile *dhcp_hosts_file, *dhcp_opts_file; -diff --git a/src/option.c b/src/option.c -index f4ff7c0..eafcf54 100644 ---- a/src/option.c -+++ b/src/option.c -@@ -192,6 +192,7 @@ struct myoption { - #define LOPT_NO_DHCP4 383 - #define LOPT_MAX_PROCS 384 - #define LOPT_DNSSEC_LIMITS 385 -+#define LOPT_DHCP_AL_SVID 386 - - #ifdef HAVE_GETOPT_LONG - static const struct option opts[] = -@@ -388,6 +389,7 @@ static const struct myoption opts[] = - { "use-stale-cache", 2, 0 , LOPT_STALE_CACHE }, - { "no-ident", 0, 0, LOPT_NO_IDENT }, - { "max-tcp-connections", 1, 0, LOPT_MAX_PROCS }, -+ { "dhcp-allowed-srvids", 1, 0, LOPT_DHCP_AL_SVID }, - { NULL, 0, 0, 0 } - }; - -@@ -591,6 +593,7 @@ static struct { - { LOPT_NO_IDENT, OPT_NO_IDENT, NULL, gettext_noop("Do not add CHAOS TXT records."), NULL }, - { LOPT_CACHE_RR, ARG_DUP, "", gettext_noop("Cache this DNS resource record type."), NULL }, - { LOPT_MAX_PROCS, ARG_ONE, "", gettext_noop("Maximum number of concurrent tcp connections."), NULL }, -+ { LOPT_DHCP_AL_SVID, ARG_DUP, "[=]...", gettext_noop("Allow these ServerIDs"), NULL }, - { 0, 0, NULL, NULL, NULL } - }; - -@@ -4720,6 +4723,18 @@ static int one_opt(int option, char *arg, char *errstr, char *gen_err, int comma - - break; - } -+ case LOPT_DHCP_AL_SVID: -+ daemon->allowing_custom_srvids = 1; -+ while (arg) { -+ struct addr_list *new = opt_malloc(sizeof(struct addr_list)); -+ comma = split(arg); -+ if (!(inet_pton(AF_INET, arg, &new->addr) > 0)) -+ ret_err_free(_("bad dhcp-allowed-srvids address"), new); -+ new->next = daemon->allowed_srvids; -+ daemon->allowed_srvids = new; -+ arg = comma; -+ } -+ break; - - #endif - -diff --git a/src/rfc2131.c b/src/rfc2131.c -index 68834ea..fd24654 100644 ---- a/src/rfc2131.c -+++ b/src/rfc2131.c -@@ -1202,8 +1202,22 @@ size_t dhcp_reply(struct dhcp_context *context, char *iface_name, int int_index, - - if (override.s_addr != 0) - { -- if (option_addr(opt).s_addr != override.s_addr) -- return 0; -+ if (option_addr(opt).s_addr != override.s_addr) { -+ if (daemon->allowing_custom_srvids) { -+ struct addr_list *l; -+ for (l = daemon->allowed_srvids; l; l = l->next) -+ if (l->addr.s_addr == option_addr(opt).s_addr) { -+ inet_ntop(AF_INET, &l->addr.s_addr, daemon->addrbuff, ADDRSTRLEN); -+ my_syslog(MS_DHCP | LOG_DEBUG, _("ServerID %s is explicitly allowed."), -+ daemon->addrbuff); -+ break; -+ } -+ if (!l) -+ return 0; -+ } -+ else -+ return 0; -+ } - } - else - { -@@ -1230,12 +1244,28 @@ size_t dhcp_reply(struct dhcp_context *context, char *iface_name, int int_index, - override = intr->addr.in.sin_addr; - else - { -- /* In auth mode, a REQUEST sent to the wrong server -- should be faulted, so that the client establishes -- communication with us, otherwise, silently ignore. */ -- if (!option_bool(OPT_AUTHORITATIVE)) -- return 0; -- message = _("wrong server-ID"); -+ if (daemon->allowing_custom_srvids) { -+ my_syslog(MS_DHCP | LOG_DEBUG, _("checking allowed custom serverids")); -+ struct addr_list *l; -+ for (l = daemon->allowed_srvids; l; l = l->next) -+ if (l->addr.s_addr == option_addr(opt).s_addr) -+ break; -+ if (l) { -+ inet_ntop(AF_INET, &l->addr.s_addr, daemon->addrbuff, ADDRSTRLEN); -+ my_syslog(MS_DHCP | LOG_DEBUG, _("ServerID %s is explicitly allowed."), -+ daemon->addrbuff); -+ override = option_addr(opt); -+ } -+ else -+ { -+ /* In auth mode, a REQUEST sent to the wrong server -+ should be faulted, so that the client establishes -+ communication with us, otherwise, silently ignore. */ -+ if (!option_bool(OPT_AUTHORITATIVE)) -+ return 0; -+ message = _("wrong server-ID"); -+ } -+ } - } - } - } --- -2.39.5 (Apple Git-154) diff --git a/containers/dnsmasq/dnsmasq/dnsmasq.conf.j2 b/containers/dnsmasq/dnsmasq/dnsmasq.conf.j2 index f79da23e4..4f12c792f 100644 --- a/containers/dnsmasq/dnsmasq/dnsmasq.conf.j2 +++ b/containers/dnsmasq/dnsmasq/dnsmasq.conf.j2 @@ -1,5 +1,5 @@ # -# UnderStack dnsmasq configuration for OpenStack Ironic +# UnderStack dnsmasq configuration - DNS only # # This config should be processed with jinja # @@ -10,21 +10,15 @@ log-facility=- # configs conf-dir=/etc/dnsmasq.d -{% if env.PROVISIONER_INTERFACE is defined -%} -interface={{ env.PROVISIONER_INTERFACE }} -bind-dynamic -{%- else -%} except-interface=lo bind-interfaces -{%- endif %} -# DNS port to listen on, set to 0 to disable +# DNS port to listen on port={{ env.DNS_PORT | default(53) }} -{% if env.LOG_DNS_QUERIES | default(False, True) %} # enable DNS logging log-queries=extra -{% endif %} +log-debug # for now, do not set any resolv settings which results in us # forwarding requests to kubedns within the cluster @@ -32,108 +26,10 @@ log-queries=extra # don't advertise /etc/hosts entries no-hosts -{% macro dhcp_config(name) -%} -# common DHCP config -# the environment variables will be named like: -{% set dhcp_circuitid = "DHCP_SETTAG_" ~ name|upper ~ "_CIRCUITID" -%} -# {{ dhcp_circuitid }} -{% set dhcp_range = "DHCP_RANGE_" ~ name|upper -%} -# {{ dhcp_range }} -{% set dhcp_proxy = "DHCP_PROXY_" ~ name|upper -%} -# {{ dhcp_proxy }} -{% set dhcp_option = "DHCP_OPTION_" ~ name|upper ~ "_" -%} -# {{ dhcp_option }}$OPTION where $OPTION is any valid dnsmasq option. underscores will be converted to hyphens -{% set dhcp_allowed = "DHCP_ALLOWED_SRVIDS_" ~ name|upper -%} -# {{ dhcp_allowed }} -{% if env[dhcp_circuitid] is defined and env[dhcp_circuitid] -%} -# tag the traffic with the PXE relay MAC -{% set tag = name|lower ~ "," -%} -dhcp-circuitid=set:{{ tag }}{{ env[dhcp_circuitid] }} -{% else -%} -# no tag by the PXE relay MAC -{% set tag = "" -%} -{%- endif %} -dhcp-range={{ tag }}{{ env[dhcp_range] }} -shared-network={{ env.PROVISIONER_INTERFACE | default("eth0") }},{{ env[dhcp_range].split(',')[0] }} -{% if env[dhcp_proxy] is defined and env[dhcp_proxy] -%} -shared-network={{ env.PROVISIONER_INTERFACE | default("eth0") }},{{ env[dhcp_proxy] }} -{{ dhcp_proxy_list.append(env[dhcp_proxy]) or '' }} -{%- endif %} -{% for key, value in env.items() if key.startswith(dhcp_option) -%} -{% set option = key|replace(dhcp_option, '') -%} -{% if option|int(-1) != -1 -%} -{% set option_prefix = '' -%} -{% else -%} -{% set option_prefix = 'option:' -%} -{% endif -%} -dhcp-option={{ tag }}{{ option_prefix }}{{ option|replace('_', '-')|lower }},{{ value }} -{% endfor %} -{% if env[dhcp_allowed] is defined and env[dhcp_allowed] -%} -{{ dhcp_allowed_srvids_list.append(env[dhcp_allowed]) or '' }} -{%- endif %} -{%- endmacro %} - -# This DHCP server is allowed to send NAK responses: -dhcp-authoritative -# This DHCP server will issue a single IP per client MAC address. The client-id -# can be used to differentiate VMs sharing a NIC (and thus requiring multiple -# IPs for a single MAC address). We don't have that requirement, and in fact in -# our case it causes us to assign additional IPs to every server because the -# different stages of boot (iPXE, linux) use different client-ids: -dhcp-ignore-clid -# DHCP ranges to hand out -{% set dhcp_proxy_list = [] -%} -{% set dhcp_allowed_srvids_list = [] -%} -{% if env.POD_IP is defined and env.POD_IP -%} -{{ dhcp_proxy_list.append(env.POD_IP) or '' }} -{%- endif %} -{% set dhcp_tags_str = env.DHCP_TAGS|default('default') -%} -{% set dhcp_tags = dhcp_tags_str.split(',') -%} -{% for name in dhcp_tags -%} -# pool {{ name }} -{{ dhcp_config(name) }} -{% endfor %} -# dhcp_proxy_list '{{ dhcp_proxy_list }}' -{% if dhcp_proxy_list | length > 0 -%} -dhcp-proxy={{ dhcp_proxy_list|join(',') }} -{% endif %} -{% if dhcp_allowed_srvids_list | length > 0 %} -dhcp-allowed-srvids={{ dhcp_allowed_srvids_list|join(',') }} -{% endif %} -enable-tftp -tftp-no-fail -tftp-root={{ env['TFTP_DIR'] | default('/var/lib/openstack-helm/tftpboot') }} - -# don't set to enable logging -{% if env.LOG_DHCP_QUERIES | default(False, True) %} -# enable DNS logging -log-dhcp -log-debug -{% endif %} - -# check if client is already running iPXE -dhcp-match=ipxe,175 - -# Detect PXE architecture -dhcp-match=set:efi,option:client-arch,7 -dhcp-match=set:efi,option:client-arch,9 -dhcp-match=set:efi,option:client-arch,11 -dhcp-match=set:efi,option:client-arch,16 -# dhcpv6.option: Client System Architecture Type (61) -dhcp-match=set:efi6,option6:61,0007 -dhcp-match=set:efi6,option6:61,0009 -dhcp-match=set:efi6,option6:61,0011 -dhcp-match=set:efi6,option6:61,0016 - -# provide PXE services to HTTPClient machines as well -dhcp-pxe-vendor=PXEClient,HTTPClient - -# conductor will write configs here -dhcp-hostsdir=/etc/dnsmasq.d/hostsdir.d -dhcp-optsdir=/etc/dnsmasq.d/optsdir.d +# disable DHCP and TFTP +no-dhcp-interface= # static DNS entries to reach UnderStack components address=/.{{ env.DNS_ZONE }}/{{ env.INGRESS_IP }} -dhcp-option=option:dns-server,{{ env.get('DNS_IP', env['INGRESS_IP']) }} # end of template diff --git a/containers/dnsmasq/dnsmasq/entry-point.sh b/containers/dnsmasq/dnsmasq/entry-point.sh index 02b979862..c9fd51b3d 100644 --- a/containers/dnsmasq/dnsmasq/entry-point.sh +++ b/containers/dnsmasq/dnsmasq/entry-point.sh @@ -7,8 +7,6 @@ set -eux . /helpers.sh mkdir -p /etc/dnsmasq.d/ -mkdir -p /etc/dnsmasq.d/hostsdir.d -mkdir -p /etc/dnsmasq.d/optsdir.d render_j2_file /etc/dnsmasq.conf.j2 /etc/dnsmasq.conf From e70bce2485c5217c4e218574b83c4dc559cf72db Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Wed, 8 Jul 2026 15:16:26 +0100 Subject: [PATCH 36/48] adjust dnsmasq image tag for test --- components/ironic/dnsmasq-deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ironic/dnsmasq-deploy.yaml b/components/ironic/dnsmasq-deploy.yaml index ad95c66ea..6ebadca2b 100644 --- a/components/ironic/dnsmasq-deploy.yaml +++ b/components/ironic/dnsmasq-deploy.yaml @@ -32,7 +32,7 @@ spec: hostNetwork: true containers: - name: dnsmasq - image: ghcr.io/rackerlabs/understack/dnsmasq:latest + image: ghcr.io/rackerlabs/understack/dnsmasq:pr-2111 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true From 977b3aa3320d1533f6368572efb49e20f1b9b647 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 8 Jul 2026 17:00:13 +0100 Subject: [PATCH 37/48] kea: try changing path to ha.json --- charts/kea-dhcp/templates/configmap.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/kea-dhcp/templates/configmap.yaml b/charts/kea-dhcp/templates/configmap.yaml index c02f357e3..756b09f74 100644 --- a/charts/kea-dhcp/templates/configmap.yaml +++ b/charts/kea-dhcp/templates/configmap.yaml @@ -69,7 +69,7 @@ data: "hooks-libraries": {{ .Values.kea.dhcp4.hooksLibraries | toJson }}, {{- end }} {{ if gt .Values.replicaCount 1.0 }} - + {{- end }} {{- if .Values.kea.dhcp4.loggers }} "loggers": {{ .Values.kea.dhcp4.loggers | toJson }} From 4c781cf1ddc7e09bba76d7fe2db8896d6ad91c7e Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Wed, 8 Jul 2026 17:11:57 +0100 Subject: [PATCH 38/48] add print statements for kea cleaning tshooting --- python/ironic-understack/ironic_understack/dhcp/kea.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 8efb234d4..259377966 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -93,6 +93,7 @@ def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=Fal if not remove: reservation["client-classes"] = ["BOOTSRV_A"] else: + print(f"REMOVING RESERVATION: {reservation}") reservations.pop(reservation) found = True break @@ -106,6 +107,7 @@ def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=Fal ) dhcp4_config["reservations"] = reservations else: + print(f"NO RESERVATION FOUND IN {reservations}") return True config["arguments"]["Dhcp4"] = dhcp4_config @@ -138,6 +140,7 @@ def update_dhcp_opts(self, task, options, vifs=None): def clean_dhcp_opts(self, task): """Remove DHCP options for all ports associated with a node.""" + print("STARTING DHCP CLEANING") success = True for port in task.ports: if not self._update_host_reservation(port.address, remove=True): From 856e6938d38017ff8de829e9eff9953a93e1e5a8 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 8 Jul 2026 18:45:45 +0100 Subject: [PATCH 39/48] feat(kea driver): support HA endpoints Instead of sending a request to a traditional kubernetes Service, we must send the request to all of the pods. In order to do that, we make a DNS lookup on the headless service and send the request to all of the pods using the service's port. --- .../ironic_understack/dhcp/kea.py | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 259377966..3a8041055 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -1,4 +1,6 @@ +import socket from urllib.parse import urlparse +from urllib.parse import urlunparse import requests from ironic import objects @@ -25,6 +27,15 @@ def __init__(self): if not CONF.ironic_understack.kea_url: raise DHCPConfigurationError("Kea URL must be specified in configuration") + def _lookup_api_urls(self): + service_url = CONF.ironic_understack.kea_url + parsed = urlparse(service_url) + + results = socket.getaddrinfo(parsed.hostname, None) + ips = sorted({r[4][0] for r in results}) + + return [urlunparse(parsed._replace(netloc=f"{ip}:{parsed.port}")) for ip in ips] + def _make_request(self, command, arguments, services=None): payload = { "command": command, @@ -33,14 +44,16 @@ def _make_request(self, command, arguments, services=None): } for attempt in range(self.max_retries): + results = [] try: - response = requests.post( - CONF.ironic_understack.kea_url, - json=payload, - timeout=CONF.ironic_understack.kea_request_timeout, - ) - response.raise_for_status() - return response.json() + for url in self._lookup_api_urls(): + response = requests.post( + url, + json=payload, + timeout=CONF.ironic_understack.kea_request_timeout, + ) + response.raise_for_status() + results.append(response) except requests.exceptions.Timeout: LOG.warning( "Timeout on attempt %d/%d for command %s", @@ -60,6 +73,7 @@ def _make_request(self, command, arguments, services=None): self.max_retries, e, ) + return results[0].json() def get_config(self): """Retrieve current Kea configuration.""" From f47fbc4bb66c4ad06b3a2c95deea817631a2b24c Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 9 Jul 2026 09:28:16 +0100 Subject: [PATCH 40/48] feat: add debug log for Ironic sending requests to Kea --- python/ironic-understack/ironic_understack/dhcp/kea.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 3a8041055..4a1d78483 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -47,6 +47,10 @@ def _make_request(self, command, arguments, services=None): results = [] try: for url in self._lookup_api_urls(): + LOG.debug( + "Sending %(command)s request to Kea API %(url)s", + {"command": command, "url": url}, + ) response = requests.post( url, json=payload, From 64462f6be10095eff84f60fc23f8f9f579040382 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 9 Jul 2026 09:34:56 +0100 Subject: [PATCH 41/48] fix(kea driver): fix broken retry logic in _make_request The success return was outside the try/except but inside the attempt loop, so it always returned (or raised IndexError) after the first attempt regardless of success. Retries never actually ran, silently dropping reservations for ports that hit a transient request failure. --- .../ironic_understack/dhcp/kea.py | 22 ++++-- .../ironic_understack/tests/test_dhcp_kea.py | 70 +++++++++++++++++++ 2 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 4a1d78483..8a71c9f6f 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -43,6 +43,7 @@ def _make_request(self, command, arguments, services=None): "arguments": arguments, } + last_exception = None for attempt in range(self.max_retries): results = [] try: @@ -58,7 +59,9 @@ def _make_request(self, command, arguments, services=None): ) response.raise_for_status() results.append(response) - except requests.exceptions.Timeout: + return results[0].json() + except requests.exceptions.Timeout as e: + last_exception = e LOG.warning( "Timeout on attempt %d/%d for command %s", attempt + 1, @@ -66,18 +69,23 @@ def _make_request(self, command, arguments, services=None): command, ) except requests.exceptions.RequestException as e: - if attempt == self.max_retries - 1: - LOG.error("Failed to execute command %s: %s", command, e) - raise DHCPConfigurationError( - f"Failed to execute {command}: {e}" - ) from e + last_exception = e LOG.warning( "Request failed on attempt %d/%d: %s", attempt + 1, self.max_retries, e, ) - return results[0].json() + + LOG.error( + "Failed to execute command %s after %d attempts: %s", + command, + self.max_retries, + last_exception, + ) + raise DHCPConfigurationError( + f"Failed to execute {command}: {last_exception}" + ) from last_exception def get_config(self): """Retrieve current Kea configuration.""" diff --git a/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py b/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py new file mode 100644 index 000000000..458273923 --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py @@ -0,0 +1,70 @@ +from unittest.mock import Mock + +import pytest +import requests + +from ironic_understack.dhcp.kea import DHCPConfigurationError +from ironic_understack.dhcp.kea import KeaDHCPApi + + +@pytest.fixture +def kea(mocker): + api = KeaDHCPApi() + api.max_retries = 3 + mocker.patch.object(api, "_lookup_api_urls", return_value=["http://kea"]) + return api + + +def _response(payload): + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = payload + return response + + +def test_make_request_retries_after_transient_timeout(kea, mocker): + post = mocker.patch( + "ironic_understack.dhcp.kea.requests.post", + side_effect=[requests.exceptions.Timeout(), _response({"result": 0})], + ) + + result = kea._make_request("config-get", {}) + + assert result == {"result": 0} + assert post.call_count == 2 + + +def test_make_request_raises_after_exhausting_retries(kea, mocker): + post = mocker.patch( + "ironic_understack.dhcp.kea.requests.post", + side_effect=requests.exceptions.ConnectionError("boom"), + ) + + with pytest.raises(DHCPConfigurationError): + kea._make_request("config-get", {}) + + assert post.call_count == kea.max_retries + + +def test_make_request_raises_after_exhausting_timeouts(kea, mocker): + post = mocker.patch( + "ironic_understack.dhcp.kea.requests.post", + side_effect=requests.exceptions.Timeout(), + ) + + with pytest.raises(DHCPConfigurationError): + kea._make_request("config-get", {}) + + assert post.call_count == kea.max_retries + + +def test_make_request_succeeds_on_first_attempt(kea, mocker): + post = mocker.patch( + "ironic_understack.dhcp.kea.requests.post", + return_value=_response({"result": 0}), + ) + + result = kea._make_request("config-get", {}) + + assert result == {"result": 0} + assert post.call_count == 1 From 1343496aaec61797d97b1cb2bb178f91b032f7cc Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 9 Jul 2026 09:37:24 +0100 Subject: [PATCH 42/48] fix(kea driver): fix reservation removal using list.pop instead of remove list.pop() expects an index, not a value; passing the reservation dict raised TypeError, silently swallowed by the broad except clause, so clean_dhcp_opts never actually removed reservations. --- .../ironic_understack/dhcp/kea.py | 2 +- .../ironic_understack/tests/test_dhcp_kea.py | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 8a71c9f6f..2cabdd5fd 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -120,7 +120,7 @@ def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=Fal reservation["client-classes"] = ["BOOTSRV_A"] else: print(f"REMOVING RESERVATION: {reservation}") - reservations.pop(reservation) + reservations.remove(reservation) found = True break if not found: diff --git a/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py b/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py index 458273923..51e6e61a6 100644 --- a/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py +++ b/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py @@ -68,3 +68,28 @@ def test_make_request_succeeds_on_first_attempt(kea, mocker): assert result == {"result": 0} assert post.call_count == 1 + + +def test_update_host_reservation_removes_existing_entry(kea, mocker): + other_reservation = { + "hw-address": "aa:aa:aa:aa:aa:aa", + "client-classes": ["BOOTSRV_A"], + } + target_reservation = { + "hw-address": "bb:bb:bb:bb:bb:bb", + "client-classes": ["BOOTSRV_A"], + } + config = { + "arguments": { + "hash": "somehash", + "Dhcp4": {"reservations": [other_reservation, target_reservation]}, + } + } + mocker.patch.object(kea, "get_config", return_value=config) + set_config = mocker.patch.object(kea, "set_config") + + result = kea._update_host_reservation("bb:bb:bb:bb:bb:bb", remove=True) + + assert result is True + sent_config = set_config.call_args[0][0] + assert sent_config["Dhcp4"]["reservations"] == [other_reservation] From 2301b9abac9937aa0d3724821e4b5fa9bd2b0527 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 9 Jul 2026 09:40:35 +0100 Subject: [PATCH 43/48] refactor(kea driver): replace debug print statements with LOG.debug These prints were left over from troubleshooting the Kea cleaning path and would spam stdout in production; route them through the standard oslo logger instead. --- python/ironic-understack/ironic_understack/dhcp/kea.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 2cabdd5fd..4ffd186c7 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -119,7 +119,7 @@ def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=Fal if not remove: reservation["client-classes"] = ["BOOTSRV_A"] else: - print(f"REMOVING RESERVATION: {reservation}") + LOG.debug("Removing reservation: %s", reservation) reservations.remove(reservation) found = True break @@ -133,7 +133,7 @@ def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=Fal ) dhcp4_config["reservations"] = reservations else: - print(f"NO RESERVATION FOUND IN {reservations}") + LOG.debug("No reservation found in %s", reservations) return True config["arguments"]["Dhcp4"] = dhcp4_config @@ -166,7 +166,7 @@ def update_dhcp_opts(self, task, options, vifs=None): def clean_dhcp_opts(self, task): """Remove DHCP options for all ports associated with a node.""" - print("STARTING DHCP CLEANING") + LOG.debug("Starting DHCP cleaning") success = True for port in task.ports: if not self._update_host_reservation(port.address, remove=True): From 5704ecfe0d9d770b3f68eadcfe4c51badf8475c8 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 9 Jul 2026 09:56:39 +0100 Subject: [PATCH 44/48] fix(kea driver): use headless service for endpoint disco --- python/ironic-understack/ironic_understack/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/ironic-understack/ironic_understack/conf.py b/python/ironic-understack/ironic_understack/conf.py index b7296b23f..ed8ebe5d8 100644 --- a/python/ironic-understack/ironic_understack/conf.py +++ b/python/ironic-understack/ironic_understack/conf.py @@ -28,7 +28,7 @@ def setup_conf(): ), cfg.StrOpt( "kea_url", - default="http://kea-kea-dhcp-ctrl.openstack.svc.cluster.local:8000", + default="http://kea-kea-dhcp-headless.openstack.svc.cluster.local:8000", help="URL of the Kea DHCP server's HTTP API endpoint. " "This endpoint is used for managing DHCP " "configuration, reservations, leases and subnet " From 4909091985bf9449a58e8399f6be1da7cec4ae67 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 9 Jul 2026 11:32:01 +0100 Subject: [PATCH 45/48] feat(kea-dhcp): configurable Kea API requests logging --- .../ironic-understack/ironic_understack/conf.py | 12 +++++++++++- .../ironic_understack/dhcp/kea.py | 15 +++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/python/ironic-understack/ironic_understack/conf.py b/python/ironic-understack/ironic_understack/conf.py index ed8ebe5d8..55fed3099 100644 --- a/python/ironic-understack/ironic_understack/conf.py +++ b/python/ironic-understack/ironic_understack/conf.py @@ -34,6 +34,16 @@ def setup_conf(): "configuration, reservations, leases and subnet " "operations through Kea's HTTP API interface.", ), + cfg.StrOpt( + "kea_log_requests", + default="false", + help="Enable logging of Kea API requests and responses.", + ), + cfg.StrOpt( + "kea_log_requests_body", + default="false", + help="Enable logging of the body of Kea API requests and responses.", + ), cfg.IntOpt( "kea_request_timeout", default=10, @@ -42,7 +52,7 @@ def setup_conf(): cfg.IntOpt( "kea_max_retries", default=3, - help="Maximum number of retry attempts for failed " "requests.", + help="Maximum number of retry attempts for failed requests.", ), ] cfg.CONF.register_group(grp) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 4ffd186c7..91a05dc14 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -48,10 +48,17 @@ def _make_request(self, command, arguments, services=None): results = [] try: for url in self._lookup_api_urls(): - LOG.debug( - "Sending %(command)s request to Kea API %(url)s", - {"command": command, "url": url}, - ) + if CONF.ironic_understack.kea_log_requests: + LOG.debug( + "Sending %(command)s request to Kea API %(url)s", + {"command": command, "url": url}, + ) + if CONF.ironic_understack.kea_log_requests_body: + LOG.debug( + "Sending %(command)s request body to Kea API" + " %(url)s: %(payload)s", + {"command": command, "url": url, "payload": payload}, + ) response = requests.post( url, json=payload, From 537bccb8f9887639bac6984ce29d8473fc613e10 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 9 Jul 2026 11:38:29 +0100 Subject: [PATCH 46/48] feat(kea driver): save config after updates or deletions --- python/ironic-understack/ironic_understack/dhcp/kea.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 91a05dc14..81e0851b3 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -102,6 +102,10 @@ def set_config(self, config): """Update Kea configuration.""" return self._make_request("config-set", config) + def save_config(self): + """Save the current configuration to disk.""" + return self._make_request("config-write", {}) + def get_statistics(self, name=None): """Retrieve DHCP server statistics.""" if name: @@ -169,6 +173,7 @@ def update_dhcp_opts(self, task, options, vifs=None): if not self.update_port_dhcp_opts(port.uuid, options): success = False LOG.error("Failed to update DHCP options for port %s", port.uuid) + self.save_config() return success def clean_dhcp_opts(self, task): @@ -179,6 +184,7 @@ def clean_dhcp_opts(self, task): if not self._update_host_reservation(port.address, remove=True): success = False LOG.error("Failed to clean DHCP options for port %s", port.uuid) + self.save_config() return success def get_ip_addresses(self, task): From e799bf2d3f57d73c564f21dfa8a52c6c70343807 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 13 Jul 2026 21:09:17 +0100 Subject: [PATCH 47/48] feat(kea driver): introduce kea_proxy service between Ironic and Kea Centralizes all direct Kea Control Agent communication (endpoint discovery, retries, config read-modify-write) behind a single in-process lock in a new kea_proxy REST service, so concurrent reservation updates/deletes from Ironic can no longer race and clobber each other's changes. dhcp/kea.py becomes a thin HTTP client of this proxy instead of talking to Kea directly. Adds the ironic-understack-kea-proxy console-script, a single-replica Deployment/Service manifest in components/ironic, and moves/rewrites the driver's test suite to match. --- components/ironic/kea-proxy-deploy.yaml | 68 ++++++ components/ironic/kustomization.yaml | 1 + .../ironic_understack/conf.py | 16 ++ .../ironic_understack/dhcp/kea.py | 196 ++++++------------ .../ironic_understack/kea_proxy/__init__.py | 0 .../ironic_understack/kea_proxy/app.py | 40 ++++ .../kea_proxy/controllers.py | 67 ++++++ .../ironic_understack/kea_proxy/kea_client.py | 176 ++++++++++++++++ .../ironic_understack/tests/test_dhcp_kea.py | 166 ++++++++++----- .../tests/test_kea_proxy_app.py | 95 +++++++++ .../tests/test_kea_proxy_kea_client.py | 182 ++++++++++++++++ python/ironic-understack/pyproject.toml | 7 + python/ironic-understack/uv.lock | 53 +++++ 13 files changed, 879 insertions(+), 188 deletions(-) create mode 100644 components/ironic/kea-proxy-deploy.yaml create mode 100644 python/ironic-understack/ironic_understack/kea_proxy/__init__.py create mode 100644 python/ironic-understack/ironic_understack/kea_proxy/app.py create mode 100644 python/ironic-understack/ironic_understack/kea_proxy/controllers.py create mode 100644 python/ironic-understack/ironic_understack/kea_proxy/kea_client.py create mode 100644 python/ironic-understack/ironic_understack/tests/test_kea_proxy_app.py create mode 100644 python/ironic-understack/ironic_understack/tests/test_kea_proxy_kea_client.py diff --git a/components/ironic/kea-proxy-deploy.yaml b/components/ironic/kea-proxy-deploy.yaml new file mode 100644 index 000000000..43239f630 --- /dev/null +++ b/components/ironic/kea-proxy-deploy.yaml @@ -0,0 +1,68 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: ironic-kea-proxy +data: + # Only the [ironic_understack] settings kea_proxy actually needs. + # kea_proxy does not consume the same ironic.conf.d assembly as + # ironic-api/ironic-conductor, so if kea_url (or any other kea_* option) + # is ever overridden in components/ironic/values.yaml's conf.ironic + # section, mirror that override here too. + kea_proxy.conf: | + [ironic_understack] + kea_proxy_listen_host = 0.0.0.0 + kea_proxy_listen_port = 9080 +--- +apiVersion: v1 +kind: Service +metadata: + name: ironic-kea-proxy + labels: + application: ironic-kea-proxy +spec: + type: ClusterIP + ports: + - name: http + port: 9080 + targetPort: 9080 + selector: + application: ironic-kea-proxy +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ironic-kea-proxy + namespace: openstack +spec: + # Exactly one replica is a hard correctness requirement, not just a + # default: kea_proxy serializes concurrent Kea config writes with an + # in-process threading.Lock (ironic_understack/kea_proxy/kea_client.py). + # A second replica would run its own independent lock and could race + # against the first, corrupting Kea reservations. Do not scale this up. + replicas: 1 + selector: + matchLabels: + application: ironic-kea-proxy + template: + metadata: + labels: + application: ironic-kea-proxy + spec: + containers: + - name: kea-proxy + image: ghcr.io/rackerlabs/understack/ironic:pr-2111 + command: + - ironic-understack-kea-proxy + ports: + - name: http + containerPort: 9080 + volumeMounts: + - name: kea-proxy-conf + mountPath: /etc/ironic/ironic.conf.d/kea_proxy.conf + subPath: kea_proxy.conf + readOnly: true + volumes: + - name: kea-proxy-conf + configMap: + name: ironic-kea-proxy diff --git a/components/ironic/kustomization.yaml b/components/ironic/kustomization.yaml index b60f8eeb9..f7fc3da47 100644 --- a/components/ironic/kustomization.yaml +++ b/components/ironic/kustomization.yaml @@ -7,6 +7,7 @@ resources: - ironic-rabbitmq-queue.yaml - dnsmasq-cm.yaml - dnsmasq-deploy.yaml + - kea-proxy-deploy.yaml - ironic-ks-user-baremetal.yaml # less than ideal addition but necessary so that we can have the ironic.conf.d loading # working due to the way the chart hardcodes the config-file parameter which then diff --git a/python/ironic-understack/ironic_understack/conf.py b/python/ironic-understack/ironic_understack/conf.py index 55fed3099..f9872bcda 100644 --- a/python/ironic-understack/ironic_understack/conf.py +++ b/python/ironic-understack/ironic_understack/conf.py @@ -54,6 +54,22 @@ def setup_conf(): default=3, help="Maximum number of retry attempts for failed requests.", ), + cfg.StrOpt( + "kea_proxy_listen_host", + default="0.0.0.0", # noqa: S104 -- binds within the pod network namespace + help="Host/address the kea_proxy service binds to.", + ), + cfg.IntOpt( + "kea_proxy_listen_port", + default=9080, + help="Port the kea_proxy service binds to.", + ), + cfg.StrOpt( + "kea_proxy_url", + default="http://ironic-kea-proxy.openstack.svc.cluster.local:9080", + help="URL of the kea_proxy service used by the DHCP driver to " + "manage reservations and leases on its behalf.", + ), ] cfg.CONF.register_group(grp) cfg.CONF.register_opts(opts, group=grp) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 81e0851b3..4f41a3327 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -1,7 +1,3 @@ -import socket -from urllib.parse import urlparse -from urllib.parse import urlunparse - import requests from ironic import objects from ironic.common import exception @@ -12,6 +8,8 @@ LOG = logging.getLogger(__name__) +DEFAULT_CLIENT_CLASS = "BOOTSRV_A" + class DHCPConfigurationError(exception.IronicException): """Raised when there is an error in configuring DHCP.""" @@ -20,60 +18,44 @@ class DHCPConfigurationError(exception.IronicException): class KeaDHCPApi(base.BaseDHCP): + """Thin HTTP client for the kea_proxy service. + + All direct Kea Control Agent communication (endpoint discovery, + retries, config read-modify-write, locking) lives in kea_proxy; this + driver only talks to that proxy's REST API. + """ + def __init__(self): super().__init__() self.max_retries = CONF.ironic_understack.kea_max_retries - if not CONF.ironic_understack.kea_url: - raise DHCPConfigurationError("Kea URL must be specified in configuration") - - def _lookup_api_urls(self): - service_url = CONF.ironic_understack.kea_url - parsed = urlparse(service_url) - - results = socket.getaddrinfo(parsed.hostname, None) - ips = sorted({r[4][0] for r in results}) + if not CONF.ironic_understack.kea_proxy_url: + raise DHCPConfigurationError( + "kea_proxy_url must be specified in configuration" + ) - return [urlunparse(parsed._replace(netloc=f"{ip}:{parsed.port}")) for ip in ips] - - def _make_request(self, command, arguments, services=None): - payload = { - "command": command, - "service": services or ["dhcp4"], - "arguments": arguments, - } + def _request(self, method, path, **kwargs): + url = f"{CONF.ironic_understack.kea_proxy_url}{path}" last_exception = None for attempt in range(self.max_retries): - results = [] try: - for url in self._lookup_api_urls(): - if CONF.ironic_understack.kea_log_requests: - LOG.debug( - "Sending %(command)s request to Kea API %(url)s", - {"command": command, "url": url}, - ) - if CONF.ironic_understack.kea_log_requests_body: - LOG.debug( - "Sending %(command)s request body to Kea API" - " %(url)s: %(payload)s", - {"command": command, "url": url, "payload": payload}, - ) - response = requests.post( - url, - json=payload, - timeout=CONF.ironic_understack.kea_request_timeout, - ) - response.raise_for_status() - results.append(response) - return results[0].json() + response = requests.request( + method, + url, + timeout=CONF.ironic_understack.kea_request_timeout, + **kwargs, + ) + response.raise_for_status() + return response.json() except requests.exceptions.Timeout as e: last_exception = e LOG.warning( - "Timeout on attempt %d/%d for command %s", + "Timeout on attempt %d/%d for %s %s", attempt + 1, self.max_retries, - command, + method, + url, ) except requests.exceptions.RequestException as e: last_exception = e @@ -85,84 +67,33 @@ def _make_request(self, command, arguments, services=None): ) LOG.error( - "Failed to execute command %s after %d attempts: %s", - command, + "Failed to execute %s %s after %d attempts: %s", + method, + url, self.max_retries, last_exception, ) raise DHCPConfigurationError( - f"Failed to execute {command}: {last_exception}" + f"Failed to execute {method} {url}: {last_exception}" ) from last_exception - def get_config(self): - """Retrieve current Kea configuration.""" - return self._make_request("config-get", {})[0] - - def set_config(self, config): - """Update Kea configuration.""" - return self._make_request("config-set", config) - - def save_config(self): - """Save the current configuration to disk.""" - return self._make_request("config-write", {}) - - def get_statistics(self, name=None): - """Retrieve DHCP server statistics.""" - if name: - return self._make_request("statistic-get", {"name": name}) - return self._make_request("statistic-get-all", {}) - - def _update_host_reservation(self, hw_address, boot_file_prefix=None, remove=False): - """Modify a host reservation in the Kea config file or hosts database.""" - # TODO(cid) add support/replace with the host database configuration - # option in a central database managed by Ironic; the commands to have - # Kea manage it at runtime without restarting the server is a premium - # offering - try: - config = self.get_config() - config["arguments"].pop("hash", None) - dhcp4_config = config["arguments"]["Dhcp4"] - - reservations = dhcp4_config.get("reservations", []) - found = False - for reservation in reservations: - if reservation.get("hw-address") == hw_address: - if not remove: - reservation["client-classes"] = ["BOOTSRV_A"] - else: - LOG.debug("Removing reservation: %s", reservation) - reservations.remove(reservation) - found = True - break - if not found: - if not remove: - reservations.append( - { - "hw-address": hw_address, - "client-classes": ["BOOTSRV_A"], - } - ) - dhcp4_config["reservations"] = reservations - else: - LOG.debug("No reservation found in %s", reservations) - return True - - config["arguments"]["Dhcp4"] = dhcp4_config - self.set_config(config["arguments"]) - return True - except Exception as e: - LOG.error("Failed to update reservation for %s: %s", hw_address, e) - return False - def update_port_dhcp_opts(self, port_id, dhcp_options, context=None): """Update DHCP options for a specific port in Kea.""" port = objects.Port.get(context, port_id) - for opt in dhcp_options: - if opt["opt_name"] == "67": - parsed_url = urlparse(opt["opt_value"]) - boot_file_prefix = f"{parsed_url.scheme}://{parsed_url.netloc}/" - return self._update_host_reservation(port.address, boot_file_prefix) + try: + self._request( + "POST", + "/v1/update_reservation", + json={ + "hw-address": port.address, + "client_class": DEFAULT_CLIENT_CLASS, + }, + ) + return True + except DHCPConfigurationError as e: + LOG.error("Failed to update reservation for %s: %s", port.address, e) + return False def update_dhcp_opts(self, task, options, vifs=None): """Update DHCP options for all ports associated with a node.""" @@ -173,7 +104,6 @@ def update_dhcp_opts(self, task, options, vifs=None): if not self.update_port_dhcp_opts(port.uuid, options): success = False LOG.error("Failed to update DHCP options for port %s", port.uuid) - self.save_config() return success def clean_dhcp_opts(self, task): @@ -181,36 +111,30 @@ def clean_dhcp_opts(self, task): LOG.debug("Starting DHCP cleaning") success = True for port in task.ports: - if not self._update_host_reservation(port.address, remove=True): + try: + self._request( + "DELETE", + "/v1/leases", + json={"hw-address": port.address}, + ) + except DHCPConfigurationError as e: success = False - LOG.error("Failed to clean DHCP options for port %s", port.uuid) - self.save_config() + LOG.error("Failed to clean DHCP options for port %s: %s", port.uuid, e) return success def get_ip_addresses(self, task): """Retrieve IP addresses for all ports associated to a node.""" addresses = [] for port in task.ports: - for command, service in [("lease4-get", "dhcp4"), ("lease6-get", "dhcp6")]: - try: - response = self._make_request( - command, {"hw-address": port.address}, services=[service] - ) - leases = response.get("arguments", {}).get("leases", []) - if not leases: - LOG.warning("No leases found for port %s", port.address) - if service == "dhcp4": - addresses.extend([lease["ip-address"] for lease in leases]) - else: - for lease in leases: - addresses.extend(lease.get("ip-addresses", [])) - except DHCPConfigurationError as e: - LOG.warning( - "Failed to fetch %s addresses for port %s: %s", - service, - port.address, - e, - ) + try: + response = self._request( + "GET", "/v1/leases", params={"mac": port.address} + ) + addresses.extend(response.get("addresses", [])) + except DHCPConfigurationError as e: + LOG.warning( + "Failed to fetch addresses for port %s: %s", port.address, e + ) return addresses def supports_ipxe_tag(self): diff --git a/python/ironic-understack/ironic_understack/kea_proxy/__init__.py b/python/ironic-understack/ironic_understack/kea_proxy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/ironic-understack/ironic_understack/kea_proxy/app.py b/python/ironic-understack/ironic_understack/kea_proxy/app.py new file mode 100644 index 000000000..aa6ad88af --- /dev/null +++ b/python/ironic-understack/ironic_understack/kea_proxy/app.py @@ -0,0 +1,40 @@ +"""WSGI application and console-script entry point for kea_proxy.""" + +import sys + +import pecan +from cheroot import wsgi +from ironic.common import service +from oslo_log import log as logging + +from ironic_understack.conf import CONF +from ironic_understack.kea_proxy.controllers import RootController + +LOG = logging.getLogger(__name__) + + +def make_app(): + return pecan.make_app(RootController(), debug=False) + + +def main(): + """Console-script entry point: `ironic-understack-kea-proxy`.""" + service.prepare_command(sys.argv[1:]) + + app = make_app() + bind_addr = ( + CONF.ironic_understack.kea_proxy_listen_host, + CONF.ironic_understack.kea_proxy_listen_port, + ) + server = wsgi.Server(bind_addr=bind_addr, wsgi_app=app, server_name="kea_proxy") + + LOG.info("Starting kea_proxy on %s:%s", *bind_addr) + server.prepare() + try: + server.serve() + finally: + server.stop() + + +if __name__ == "__main__": + main() diff --git a/python/ironic-understack/ironic_understack/kea_proxy/controllers.py b/python/ironic-understack/ironic_understack/kea_proxy/controllers.py new file mode 100644 index 000000000..8c0cc3aff --- /dev/null +++ b/python/ironic-understack/ironic_understack/kea_proxy/controllers.py @@ -0,0 +1,67 @@ +"""REST controllers for the kea_proxy service.""" + +import pecan +from oslo_log import log as logging +from pecan import expose +from pecan import rest + +from ironic_understack.kea_proxy import kea_client + +LOG = logging.getLogger(__name__) + + +class ReservationsController(rest.RestController): + @expose("json") + def post(self): + body = pecan.request.json + hw_address = body.get("hw-address") + client_class = body.get("client_class") + if not hw_address or not client_class: + pecan.response.status = 400 + return {"error": "hw-address and client_class are required"} + + try: + kea_client.update_reservation(hw_address, client_class) + except kea_client.KeaRequestError as e: + LOG.error("Failed to update reservation for %s: %s", hw_address, e) + pecan.response.status = 500 + return {"error": str(e)} + + return {"result": "ok"} + + +class LeasesController(rest.RestController): + @expose("json") + def get_all(self): + mac = pecan.request.GET.get("mac") + if not mac: + pecan.response.status = 400 + return {"error": "mac query parameter is required"} + + return {"addresses": kea_client.get_leases(mac)} + + @expose("json") + def delete(self): + body = pecan.request.json + hw_address = body.get("hw-address") + if not hw_address: + pecan.response.status = 400 + return {"error": "hw-address is required"} + + try: + kea_client.delete_reservation(hw_address) + except kea_client.KeaRequestError as e: + LOG.error("Failed to delete reservation for %s: %s", hw_address, e) + pecan.response.status = 500 + return {"error": str(e)} + + return {"result": "ok"} + + +class V1Controller: + update_reservation = ReservationsController() + leases = LeasesController() + + +class RootController: + v1 = V1Controller() diff --git a/python/ironic-understack/ironic_understack/kea_proxy/kea_client.py b/python/ironic-understack/ironic_understack/kea_proxy/kea_client.py new file mode 100644 index 000000000..326f911af --- /dev/null +++ b/python/ironic-understack/ironic_understack/kea_proxy/kea_client.py @@ -0,0 +1,176 @@ +"""HTTP client for the Kea Control Agent, used by the kea_proxy service.""" + +import socket +import threading +from urllib.parse import urlparse +from urllib.parse import urlunparse + +import requests +from oslo_log import log as logging + +from ironic_understack.conf import CONF + +LOG = logging.getLogger(__name__) + +# Serializes all reservation reads/writes across every request handled by +# this process. Correct only because kea_proxy runs as a single process +# (see components/ironic/kea-proxy-deploy.yaml) -- it does not coordinate +# across multiple replicas. +_lock = threading.Lock() + + +class KeaRequestError(Exception): + """Raised when a request to a Kea Control Agent endpoint fails.""" + + +def _lookup_api_urls(): + service_url = CONF.ironic_understack.kea_url + parsed = urlparse(service_url) + + results = socket.getaddrinfo(parsed.hostname, None) + ips = sorted({r[4][0] for r in results}) + + return [urlunparse(parsed._replace(netloc=f"{ip}:{parsed.port}")) for ip in ips] + + +def _make_request(command, arguments, services=None): + payload = { + "command": command, + "service": services or ["dhcp4"], + "arguments": arguments, + } + + max_retries = CONF.ironic_understack.kea_max_retries + last_exception = None + for attempt in range(max_retries): + results = [] + try: + for url in _lookup_api_urls(): + if CONF.ironic_understack.kea_log_requests: + LOG.debug( + "Sending %(command)s request to Kea API %(url)s", + {"command": command, "url": url}, + ) + if CONF.ironic_understack.kea_log_requests_body: + LOG.debug( + "Sending %(command)s request body to Kea API" + " %(url)s: %(payload)s", + {"command": command, "url": url, "payload": payload}, + ) + response = requests.post( + url, + json=payload, + timeout=CONF.ironic_understack.kea_request_timeout, + ) + response.raise_for_status() + results.append(response) + return results[0].json() + except requests.exceptions.Timeout as e: + last_exception = e + LOG.warning( + "Timeout on attempt %d/%d for command %s", + attempt + 1, + max_retries, + command, + ) + except requests.exceptions.RequestException as e: + last_exception = e + LOG.warning( + "Request failed on attempt %d/%d: %s", + attempt + 1, + max_retries, + e, + ) + + LOG.error( + "Failed to execute command %s after %d attempts: %s", + command, + max_retries, + last_exception, + ) + raise KeaRequestError( + f"Failed to execute {command}: {last_exception}" + ) from last_exception + + +def get_config(): + """Retrieve current Kea configuration.""" + return _make_request("config-get", {})[0] + + +def set_config(config): + """Update Kea configuration.""" + return _make_request("config-set", config) + + +def save_config(): + """Save the current configuration to disk.""" + return _make_request("config-write", {}) + + +def update_reservation(hw_address, client_class): + """Create or update a host reservation for hw_address.""" + with _lock: + config = get_config() + config["arguments"].pop("hash", None) + dhcp4_config = config["arguments"]["Dhcp4"] + + reservations = dhcp4_config.get("reservations", []) + for reservation in reservations: + if reservation.get("hw-address") == hw_address: + reservation["client-classes"] = [client_class] + break + else: + reservations.append( + {"hw-address": hw_address, "client-classes": [client_class]} + ) + dhcp4_config["reservations"] = reservations + + config["arguments"]["Dhcp4"] = dhcp4_config + set_config(config["arguments"]) + save_config() + + +def delete_reservation(hw_address): + """Remove a host reservation for hw_address, if one exists.""" + with _lock: + config = get_config() + config["arguments"].pop("hash", None) + dhcp4_config = config["arguments"]["Dhcp4"] + + reservations = dhcp4_config.get("reservations", []) + for reservation in reservations: + if reservation.get("hw-address") == hw_address: + LOG.debug("Removing reservation: %s", reservation) + reservations.remove(reservation) + break + else: + LOG.debug("No reservation found for %s", hw_address) + return + + config["arguments"]["Dhcp4"] = dhcp4_config + set_config(config["arguments"]) + save_config() + + +def get_leases(hw_address): + """Retrieve IPv4/IPv6 lease addresses for hw_address.""" + addresses = [] + for command, service in [("lease4-get", "dhcp4"), ("lease6-get", "dhcp6")]: + try: + response = _make_request( + command, {"hw-address": hw_address}, services=[service] + ) + leases = response.get("arguments", {}).get("leases", []) + if not leases: + LOG.warning("No leases found for %s", hw_address) + if service == "dhcp4": + addresses.extend([lease["ip-address"] for lease in leases]) + else: + for lease in leases: + addresses.extend(lease.get("ip-addresses", [])) + except KeaRequestError as e: + LOG.warning( + "Failed to fetch %s addresses for %s: %s", service, hw_address, e + ) + return addresses diff --git a/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py b/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py index 51e6e61a6..46720c9dd 100644 --- a/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py +++ b/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py @@ -2,94 +2,156 @@ import pytest import requests +from ironic import objects +from ironic_understack.conf import CONF +from ironic_understack.dhcp.kea import DEFAULT_CLIENT_CLASS from ironic_understack.dhcp.kea import DHCPConfigurationError from ironic_understack.dhcp.kea import KeaDHCPApi +# objects.Port is only registered as an attribute once ironic.objects.port +# has been imported (see ironic.objects.register_all). +objects.register_all() + +PROXY_URL = "http://kea-proxy:9080" + + +@pytest.fixture(autouse=True) +def _proxy_url(): + CONF.set_override("kea_proxy_url", PROXY_URL, group="ironic_understack") + yield + CONF.clear_override("kea_proxy_url", group="ironic_understack") + @pytest.fixture -def kea(mocker): +def kea(): api = KeaDHCPApi() api.max_retries = 3 - mocker.patch.object(api, "_lookup_api_urls", return_value=["http://kea"]) return api -def _response(payload): +def _response(status_code=200, payload=None): response = Mock() - response.raise_for_status.return_value = None - response.json.return_value = payload + if status_code >= 400: + response.raise_for_status.side_effect = requests.exceptions.HTTPError( + response=Mock(status_code=status_code) + ) + else: + response.raise_for_status.return_value = None + response.json.return_value = payload or {} return response -def test_make_request_retries_after_transient_timeout(kea, mocker): - post = mocker.patch( - "ironic_understack.dhcp.kea.requests.post", - side_effect=[requests.exceptions.Timeout(), _response({"result": 0})], +def test_request_retries_after_transient_timeout(kea, mocker): + request = mocker.patch( + "ironic_understack.dhcp.kea.requests.request", + side_effect=[ + requests.exceptions.Timeout(), + _response(payload={"result": "ok"}), + ], ) - result = kea._make_request("config-get", {}) + result = kea._request("POST", "/v1/update_reservation", json={}) - assert result == {"result": 0} - assert post.call_count == 2 + assert result == {"result": "ok"} + assert request.call_count == 2 -def test_make_request_raises_after_exhausting_retries(kea, mocker): - post = mocker.patch( - "ironic_understack.dhcp.kea.requests.post", +def test_request_raises_after_exhausting_retries(kea, mocker): + request = mocker.patch( + "ironic_understack.dhcp.kea.requests.request", side_effect=requests.exceptions.ConnectionError("boom"), ) with pytest.raises(DHCPConfigurationError): - kea._make_request("config-get", {}) + kea._request("GET", "/v1/leases") - assert post.call_count == kea.max_retries + assert request.call_count == kea.max_retries -def test_make_request_raises_after_exhausting_timeouts(kea, mocker): - post = mocker.patch( - "ironic_understack.dhcp.kea.requests.post", - side_effect=requests.exceptions.Timeout(), +def test_update_port_dhcp_opts_posts_reservation(kea, mocker): + port = Mock(address="aa:bb:cc:dd:ee:ff") + mocker.patch("ironic_understack.dhcp.kea.objects.Port.get", return_value=port) + request = mocker.patch( + "ironic_understack.dhcp.kea.requests.request", + return_value=_response(payload={"result": "ok"}), ) - with pytest.raises(DHCPConfigurationError): - kea._make_request("config-get", {}) + result = kea.update_port_dhcp_opts("port-id", []) - assert post.call_count == kea.max_retries + assert result is True + args, kwargs = request.call_args + assert args == ("POST", f"{PROXY_URL}/v1/update_reservation") + assert kwargs["json"] == { + "hw-address": "aa:bb:cc:dd:ee:ff", + "client_class": DEFAULT_CLIENT_CLASS, + } -def test_make_request_succeeds_on_first_attempt(kea, mocker): - post = mocker.patch( - "ironic_understack.dhcp.kea.requests.post", - return_value=_response({"result": 0}), +def test_update_port_dhcp_opts_returns_false_on_failure(kea, mocker): + port = Mock(address="aa:bb:cc:dd:ee:ff") + mocker.patch("ironic_understack.dhcp.kea.objects.Port.get", return_value=port) + mocker.patch( + "ironic_understack.dhcp.kea.requests.request", + side_effect=requests.exceptions.ConnectionError("boom"), ) - result = kea._make_request("config-get", {}) + assert kea.update_port_dhcp_opts("port-id", []) is False - assert result == {"result": 0} - assert post.call_count == 1 +def test_clean_dhcp_opts_deletes_leases_for_each_port(kea, mocker): + task = Mock() + task.ports = [Mock(address="aa:bb:cc:dd:ee:ff", uuid="port-1")] + request = mocker.patch( + "ironic_understack.dhcp.kea.requests.request", + return_value=_response(payload={"result": "ok"}), + ) -def test_update_host_reservation_removes_existing_entry(kea, mocker): - other_reservation = { - "hw-address": "aa:aa:aa:aa:aa:aa", - "client-classes": ["BOOTSRV_A"], - } - target_reservation = { - "hw-address": "bb:bb:bb:bb:bb:bb", - "client-classes": ["BOOTSRV_A"], - } - config = { - "arguments": { - "hash": "somehash", - "Dhcp4": {"reservations": [other_reservation, target_reservation]}, - } - } - mocker.patch.object(kea, "get_config", return_value=config) - set_config = mocker.patch.object(kea, "set_config") + assert kea.clean_dhcp_opts(task) is True + args, kwargs = request.call_args + assert args == ("DELETE", f"{PROXY_URL}/v1/leases") + assert kwargs["json"] == {"hw-address": "aa:bb:cc:dd:ee:ff"} - result = kea._update_host_reservation("bb:bb:bb:bb:bb:bb", remove=True) - assert result is True - sent_config = set_config.call_args[0][0] - assert sent_config["Dhcp4"]["reservations"] == [other_reservation] +def test_clean_dhcp_opts_returns_false_on_failure(kea, mocker): + task = Mock() + task.ports = [Mock(address="aa:bb:cc:dd:ee:ff", uuid="port-1")] + mocker.patch( + "ironic_understack.dhcp.kea.requests.request", + side_effect=requests.exceptions.ConnectionError("boom"), + ) + + assert kea.clean_dhcp_opts(task) is False + + +def test_get_ip_addresses_merges_ports(kea, mocker): + task = Mock() + task.ports = [Mock(address="aa:bb:cc:dd:ee:ff")] + request = mocker.patch( + "ironic_understack.dhcp.kea.requests.request", + return_value=_response(payload={"addresses": ["10.0.0.5", "fd00::5"]}), + ) + + assert kea.get_ip_addresses(task) == ["10.0.0.5", "fd00::5"] + args, kwargs = request.call_args + assert args == ("GET", f"{PROXY_URL}/v1/leases") + assert kwargs["params"] == {"mac": "aa:bb:cc:dd:ee:ff"} + + +def test_get_ip_addresses_tolerates_port_failure(kea, mocker): + task = Mock() + task.ports = [Mock(address="aa:bb:cc:dd:ee:ff")] + mocker.patch( + "ironic_understack.dhcp.kea.requests.request", + side_effect=requests.exceptions.ConnectionError("boom"), + ) + + assert kea.get_ip_addresses(task) == [] + + +def test_init_requires_kea_proxy_url(): + CONF.clear_override("kea_proxy_url", group="ironic_understack") + CONF.set_override("kea_proxy_url", "", group="ironic_understack") + + with pytest.raises(DHCPConfigurationError): + KeaDHCPApi() diff --git a/python/ironic-understack/ironic_understack/tests/test_kea_proxy_app.py b/python/ironic-understack/ironic_understack/tests/test_kea_proxy_app.py new file mode 100644 index 000000000..ccb1ef37c --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_kea_proxy_app.py @@ -0,0 +1,95 @@ +import pytest +from webtest import TestApp + +from ironic_understack.kea_proxy import app +from ironic_understack.kea_proxy import kea_client + + +@pytest.fixture +def client(): + return TestApp(app.make_app()) + + +def test_update_reservation_success(client, mocker): + update_reservation = mocker.patch.object(kea_client, "update_reservation") + + response = client.post_json( + "/v1/update_reservation", + {"hw-address": "aa:bb:cc:dd:ee:ff", "client_class": "BOOTSRV_A"}, + ) + + assert response.status_int == 200 + assert response.json == {"result": "ok"} + update_reservation.assert_called_once_with("aa:bb:cc:dd:ee:ff", "BOOTSRV_A") + + +def test_update_reservation_missing_fields(client): + response = client.post_json("/v1/update_reservation", {}, expect_errors=True) + + assert response.status_int == 400 + assert "error" in response.json + + +def test_update_reservation_kea_failure(client, mocker): + mocker.patch.object( + kea_client, + "update_reservation", + side_effect=kea_client.KeaRequestError("boom"), + ) + + response = client.post_json( + "/v1/update_reservation", + {"hw-address": "aa:bb:cc:dd:ee:ff", "client_class": "BOOTSRV_A"}, + expect_errors=True, + ) + + assert response.status_int == 500 + assert response.json == {"error": "boom"} + + +def test_get_leases_success(client, mocker): + mocker.patch.object(kea_client, "get_leases", return_value=["10.0.0.5"]) + + response = client.get("/v1/leases", params={"mac": "aa:bb:cc:dd:ee:ff"}) + + assert response.status_int == 200 + assert response.json == {"addresses": ["10.0.0.5"]} + + +def test_get_leases_missing_mac(client): + response = client.get("/v1/leases", expect_errors=True) + + assert response.status_int == 400 + assert "error" in response.json + + +def test_delete_lease_success(client, mocker): + delete_reservation = mocker.patch.object(kea_client, "delete_reservation") + + response = client.delete_json("/v1/leases", {"hw-address": "aa:bb:cc:dd:ee:ff"}) + + assert response.status_int == 200 + assert response.json == {"result": "ok"} + delete_reservation.assert_called_once_with("aa:bb:cc:dd:ee:ff") + + +def test_delete_lease_missing_hw_address(client): + response = client.delete_json("/v1/leases", {}, expect_errors=True) + + assert response.status_int == 400 + assert "error" in response.json + + +def test_delete_lease_kea_failure(client, mocker): + mocker.patch.object( + kea_client, + "delete_reservation", + side_effect=kea_client.KeaRequestError("boom"), + ) + + response = client.delete_json( + "/v1/leases", {"hw-address": "aa:bb:cc:dd:ee:ff"}, expect_errors=True + ) + + assert response.status_int == 500 + assert response.json == {"error": "boom"} diff --git a/python/ironic-understack/ironic_understack/tests/test_kea_proxy_kea_client.py b/python/ironic-understack/ironic_understack/tests/test_kea_proxy_kea_client.py new file mode 100644 index 000000000..1bb8505ba --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_kea_proxy_kea_client.py @@ -0,0 +1,182 @@ +from unittest.mock import Mock + +import pytest +import requests + +from ironic_understack.kea_proxy import kea_client + + +@pytest.fixture(autouse=True) +def _max_retries(): + kea_client.CONF.set_override("kea_max_retries", 3, group="ironic_understack") + yield + kea_client.CONF.clear_override("kea_max_retries", group="ironic_understack") + + +@pytest.fixture +def lookup_urls(mocker): + return mocker.patch.object( + kea_client, "_lookup_api_urls", return_value=["http://kea"] + ) + + +def _response(payload): + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = payload + return response + + +def test_make_request_retries_after_transient_timeout(lookup_urls, mocker): + post = mocker.patch( + "ironic_understack.kea_proxy.kea_client.requests.post", + side_effect=[requests.exceptions.Timeout(), _response({"result": 0})], + ) + + result = kea_client._make_request("config-get", {}) + + assert result == {"result": 0} + assert post.call_count == 2 + + +def test_make_request_raises_after_exhausting_retries(lookup_urls, mocker): + post = mocker.patch( + "ironic_understack.kea_proxy.kea_client.requests.post", + side_effect=requests.exceptions.ConnectionError("boom"), + ) + + with pytest.raises(kea_client.KeaRequestError): + kea_client._make_request("config-get", {}) + + assert post.call_count == 3 + + +def test_make_request_raises_after_exhausting_timeouts(lookup_urls, mocker): + post = mocker.patch( + "ironic_understack.kea_proxy.kea_client.requests.post", + side_effect=requests.exceptions.Timeout(), + ) + + with pytest.raises(kea_client.KeaRequestError): + kea_client._make_request("config-get", {}) + + assert post.call_count == 3 + + +def test_make_request_succeeds_on_first_attempt(lookup_urls, mocker): + post = mocker.patch( + "ironic_understack.kea_proxy.kea_client.requests.post", + return_value=_response({"result": 0}), + ) + + result = kea_client._make_request("config-get", {}) + + assert result == {"result": 0} + assert post.call_count == 1 + + +def test_update_reservation_updates_existing_entry(mocker): + other_reservation = { + "hw-address": "aa:aa:aa:aa:aa:aa", + "client-classes": ["BOOTSRV_A"], + } + target_reservation = { + "hw-address": "bb:bb:bb:bb:bb:bb", + "client-classes": ["BOOTSRV_A"], + } + config = { + "arguments": { + "hash": "somehash", + "Dhcp4": {"reservations": [other_reservation, target_reservation]}, + } + } + mocker.patch.object(kea_client, "get_config", return_value=config) + set_config = mocker.patch.object(kea_client, "set_config") + save_config = mocker.patch.object(kea_client, "save_config") + + kea_client.update_reservation("bb:bb:bb:bb:bb:bb", "BOOTSRV_B") + + sent_config = set_config.call_args[0][0] + assert sent_config["Dhcp4"]["reservations"] == [ + other_reservation, + {"hw-address": "bb:bb:bb:bb:bb:bb", "client-classes": ["BOOTSRV_B"]}, + ] + save_config.assert_called_once() + + +def test_update_reservation_creates_new_entry(mocker): + config = {"arguments": {"hash": "somehash", "Dhcp4": {"reservations": []}}} + mocker.patch.object(kea_client, "get_config", return_value=config) + set_config = mocker.patch.object(kea_client, "set_config") + mocker.patch.object(kea_client, "save_config") + + kea_client.update_reservation("cc:cc:cc:cc:cc:cc", "BOOTSRV_A") + + sent_config = set_config.call_args[0][0] + assert sent_config["Dhcp4"]["reservations"] == [ + {"hw-address": "cc:cc:cc:cc:cc:cc", "client-classes": ["BOOTSRV_A"]} + ] + + +def test_delete_reservation_removes_existing_entry(mocker): + other_reservation = { + "hw-address": "aa:aa:aa:aa:aa:aa", + "client-classes": ["BOOTSRV_A"], + } + target_reservation = { + "hw-address": "bb:bb:bb:bb:bb:bb", + "client-classes": ["BOOTSRV_A"], + } + config = { + "arguments": { + "hash": "somehash", + "Dhcp4": {"reservations": [other_reservation, target_reservation]}, + } + } + mocker.patch.object(kea_client, "get_config", return_value=config) + set_config = mocker.patch.object(kea_client, "set_config") + save_config = mocker.patch.object(kea_client, "save_config") + + kea_client.delete_reservation("bb:bb:bb:bb:bb:bb") + + sent_config = set_config.call_args[0][0] + assert sent_config["Dhcp4"]["reservations"] == [other_reservation] + save_config.assert_called_once() + + +def test_delete_reservation_missing_entry_is_noop(mocker): + config = {"arguments": {"hash": "somehash", "Dhcp4": {"reservations": []}}} + mocker.patch.object(kea_client, "get_config", return_value=config) + set_config = mocker.patch.object(kea_client, "set_config") + save_config = mocker.patch.object(kea_client, "save_config") + + kea_client.delete_reservation("dd:dd:dd:dd:dd:dd") + + set_config.assert_not_called() + save_config.assert_not_called() + + +def test_get_leases_merges_v4_and_v6(mocker): + def fake_make_request(command, arguments, services=None): + if command == "lease4-get": + return {"arguments": {"leases": [{"ip-address": "10.0.0.5"}]}} + return {"arguments": {"leases": [{"ip-addresses": ["fd00::5"]}]}} + + mocker.patch.object(kea_client, "_make_request", side_effect=fake_make_request) + + addresses = kea_client.get_leases("aa:bb:cc:dd:ee:ff") + + assert addresses == ["10.0.0.5", "fd00::5"] + + +def test_get_leases_tolerates_family_failure(mocker): + def fake_make_request(command, arguments, services=None): + if command == "lease4-get": + return {"arguments": {"leases": [{"ip-address": "10.0.0.5"}]}} + raise kea_client.KeaRequestError("dhcp6 unavailable") + + mocker.patch.object(kea_client, "_make_request", side_effect=fake_make_request) + + addresses = kea_client.get_leases("aa:bb:cc:dd:ee:ff") + + assert addresses == ["10.0.0.5"] diff --git a/python/ironic-understack/pyproject.toml b/python/ironic-understack/pyproject.toml index 74d0adae6..bdbe28552 100644 --- a/python/ironic-understack/pyproject.toml +++ b/python/ironic-understack/pyproject.toml @@ -13,8 +13,14 @@ license = "MIT" dependencies = [ "ironic>=35.0,<36", "pyyaml~=6.0", + "pecan>=1.7.0,<2", + "webob>=1.8.9,<2", + "cheroot>=11.1.2,<12", ] +[project.scripts] +ironic-understack-kea-proxy = "ironic_understack.kea_proxy.app:main" + # REMOVE ME: delete this once we move away from patching a venv or we upgrade to a newer ironic base version [project.entry-points."ironic.console.container"] kubernetes = "ironic.console.container.kubernetes:KubernetesConsoleContainer" @@ -45,6 +51,7 @@ test = [ "pytest-cov>=7,<8", "pytest-github-actions-annotate-failures", "pytest-mock>=3.15.1", + "webtest>=3.0.0,<4", ] [tool.uv] diff --git a/python/ironic-understack/uv.lock b/python/ironic-understack/uv.lock index c77ef06e3..7f76bcece 100644 --- a/python/ironic-understack/uv.lock +++ b/python/ironic-understack/uv.lock @@ -88,6 +88,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + [[package]] name = "cachetools" version = "6.2.2" @@ -446,8 +459,11 @@ name = "ironic-understack" version = "0.0.0" source = { editable = "." } dependencies = [ + { name = "cheroot" }, { name = "ironic" }, + { name = "pecan" }, { name = "pyyaml" }, + { name = "webob" }, ] [package.dev-dependencies] @@ -456,12 +472,16 @@ test = [ { name = "pytest-cov" }, { name = "pytest-github-actions-annotate-failures" }, { name = "pytest-mock" }, + { name = "webtest" }, ] [package.metadata] requires-dist = [ + { name = "cheroot", specifier = ">=11.1.2,<12" }, { name = "ironic", specifier = ">=35.0,<36" }, + { name = "pecan", specifier = ">=1.7.0,<2" }, { name = "pyyaml", specifier = "~=6.0" }, + { name = "webob", specifier = ">=1.8.9,<2" }, ] [package.metadata.requires-dev] @@ -470,6 +490,7 @@ test = [ { name = "pytest-cov", specifier = ">=7,<8" }, { name = "pytest-github-actions-annotate-failures" }, { name = "pytest-mock", specifier = ">=3.15.1" }, + { name = "webtest", specifier = ">=3.0.0,<4" }, ] [[package]] @@ -1497,6 +1518,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.44" @@ -1656,6 +1686,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/a8/8f9cc6749331186e6a513bfe3745454f81d25f6e34c6024f88f80c71ed28/voluptuous-0.15.2-py3-none-any.whl", hash = "sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566", size = 31349, upload-time = "2024-07-02T19:09:58.125Z" }, ] +[[package]] +name = "waitress" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, +] + [[package]] name = "wcwidth" version = "0.2.14" @@ -1689,6 +1728,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/e8/6476cd85a71f7cfb04ca1e199809b75bc8580d075d4338ffcde4c761d620/websockify-0.13.0-py3-none-any.whl", hash = "sha256:ac497a8dafc7f51d28ca989f01cdf5f8add663a497d425a56b159dcb8d6a314c", size = 41537, upload-time = "2025-05-26T14:40:12.915Z" }, ] +[[package]] +name = "webtest" +version = "3.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "waitress" }, + { name = "webob" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/85/73877efb10ba4ef02364d5671724b1326d482dc928eace5fe114215443b7/webtest-3.0.7.tar.gz", hash = "sha256:7aeab50f970d46c068e7a36dd162cb242591edf72a1d04efd21374772b931741", size = 80260, upload-time = "2025-10-06T20:38:59.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/74/cb337bb1758254589b6fdc0aeeb91fa987ac1e6877a79b75edbd214fc0a9/webtest-3.0.7-py3-none-any.whl", hash = "sha256:2f51a0844f3a8beaef89bc23d225fe05ad816f7e429ffcc655a13013a799ac6c", size = 32393, upload-time = "2025-10-06T20:38:57.728Z" }, +] + [[package]] name = "wrapt" version = "2.0.1" From f3af6a628942549b5f822e23dfc9c08fa2557ad5 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 13 Jul 2026 22:14:28 +0100 Subject: [PATCH 48/48] refactor(kea driver): factor out the retry logic tenacity is already used as transient dependency for ironic, so let's use it. --- .../ironic_understack/dhcp/kea.py | 82 +++++++------- .../ironic_understack/kea_proxy/kea_client.py | 105 +++++++++--------- .../ironic_understack/tests/test_dhcp_kea.py | 13 ++- python/ironic-understack/pyproject.toml | 1 + python/ironic-understack/uv.lock | 2 + 5 files changed, 109 insertions(+), 94 deletions(-) diff --git a/python/ironic-understack/ironic_understack/dhcp/kea.py b/python/ironic-understack/ironic_understack/dhcp/kea.py index 4f41a3327..81f7fd0c3 100644 --- a/python/ironic-understack/ironic_understack/dhcp/kea.py +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -1,4 +1,5 @@ import requests +import tenacity from ironic import objects from ironic.common import exception from ironic.dhcp import base @@ -17,6 +18,21 @@ class DHCPConfigurationError(exception.IronicException): _msg_fmt = "DHCP configuration error: %(reason)s" +def _retry_stop(retry_state): + return retry_state.attempt_number >= CONF.ironic_understack.kea_max_retries + + +def _log_retry(retry_state): + _self, method, path = retry_state.args[:3] + LOG.warning( + "Request to kea_proxy failed on attempt %d for %s %s: %s", + retry_state.attempt_number, + method, + path, + retry_state.outcome.exception(), + ) + + class KeaDHCPApi(base.BaseDHCP): """Thin HTTP client for the kea_proxy service. @@ -27,55 +43,43 @@ class KeaDHCPApi(base.BaseDHCP): def __init__(self): super().__init__() - self.max_retries = CONF.ironic_understack.kea_max_retries if not CONF.ironic_understack.kea_proxy_url: raise DHCPConfigurationError( "kea_proxy_url must be specified in configuration" ) - def _request(self, method, path, **kwargs): + @tenacity.retry( + stop=_retry_stop, + retry=tenacity.retry_if_exception_type(requests.exceptions.RequestException), + before_sleep=_log_retry, + reraise=True, + ) + def _send(self, method, path, **kwargs): url = f"{CONF.ironic_understack.kea_proxy_url}{path}" - - last_exception = None - for attempt in range(self.max_retries): - try: - response = requests.request( - method, - url, - timeout=CONF.ironic_understack.kea_request_timeout, - **kwargs, - ) - response.raise_for_status() - return response.json() - except requests.exceptions.Timeout as e: - last_exception = e - LOG.warning( - "Timeout on attempt %d/%d for %s %s", - attempt + 1, - self.max_retries, - method, - url, - ) - except requests.exceptions.RequestException as e: - last_exception = e - LOG.warning( - "Request failed on attempt %d/%d: %s", - attempt + 1, - self.max_retries, - e, - ) - - LOG.error( - "Failed to execute %s %s after %d attempts: %s", + response = requests.request( method, url, - self.max_retries, - last_exception, + timeout=CONF.ironic_understack.kea_request_timeout, + **kwargs, ) - raise DHCPConfigurationError( - f"Failed to execute {method} {url}: {last_exception}" - ) from last_exception + response.raise_for_status() + return response.json() + + def _request(self, method, path, **kwargs): + try: + return self._send(method, path, **kwargs) + except requests.exceptions.RequestException as e: + LOG.error( + "Failed to execute %s %s after %d attempts: %s", + method, + path, + self._send.statistics.get("attempt_number"), + e, + ) + raise DHCPConfigurationError( + f"Failed to execute {method} {path}: {e}" + ) from e def update_port_dhcp_opts(self, port_id, dhcp_options, context=None): """Update DHCP options for a specific port in Kea.""" diff --git a/python/ironic-understack/ironic_understack/kea_proxy/kea_client.py b/python/ironic-understack/ironic_understack/kea_proxy/kea_client.py index 326f911af..342b613fc 100644 --- a/python/ironic-understack/ironic_understack/kea_proxy/kea_client.py +++ b/python/ironic-understack/ironic_understack/kea_proxy/kea_client.py @@ -6,6 +6,7 @@ from urllib.parse import urlunparse import requests +import tenacity from oslo_log import log as logging from ironic_understack.conf import CONF @@ -33,6 +34,49 @@ def _lookup_api_urls(): return [urlunparse(parsed._replace(netloc=f"{ip}:{parsed.port}")) for ip in ips] +def _retry_stop(retry_state): + return retry_state.attempt_number >= CONF.ironic_understack.kea_max_retries + + +def _log_retry(retry_state): + command = retry_state.args[0] if retry_state.args else None + LOG.warning( + "Request to Kea failed on attempt %d for command %s: %s", + retry_state.attempt_number, + command, + retry_state.outcome.exception(), + ) + + +@tenacity.retry( + stop=_retry_stop, + retry=tenacity.retry_if_exception_type(requests.exceptions.RequestException), + before_sleep=_log_retry, + reraise=True, +) +def _send_to_kea(command, payload): + results = [] + for url in _lookup_api_urls(): + if CONF.ironic_understack.kea_log_requests: + LOG.debug( + "Sending %(command)s request to Kea API %(url)s", + {"command": command, "url": url}, + ) + if CONF.ironic_understack.kea_log_requests_body: + LOG.debug( + "Sending %(command)s request body to Kea API %(url)s: %(payload)s", + {"command": command, "url": url, "payload": payload}, + ) + response = requests.post( + url, + json=payload, + timeout=CONF.ironic_understack.kea_request_timeout, + ) + response.raise_for_status() + results.append(response) + return results[0].json() + + def _make_request(command, arguments, services=None): payload = { "command": command, @@ -40,57 +84,16 @@ def _make_request(command, arguments, services=None): "arguments": arguments, } - max_retries = CONF.ironic_understack.kea_max_retries - last_exception = None - for attempt in range(max_retries): - results = [] - try: - for url in _lookup_api_urls(): - if CONF.ironic_understack.kea_log_requests: - LOG.debug( - "Sending %(command)s request to Kea API %(url)s", - {"command": command, "url": url}, - ) - if CONF.ironic_understack.kea_log_requests_body: - LOG.debug( - "Sending %(command)s request body to Kea API" - " %(url)s: %(payload)s", - {"command": command, "url": url, "payload": payload}, - ) - response = requests.post( - url, - json=payload, - timeout=CONF.ironic_understack.kea_request_timeout, - ) - response.raise_for_status() - results.append(response) - return results[0].json() - except requests.exceptions.Timeout as e: - last_exception = e - LOG.warning( - "Timeout on attempt %d/%d for command %s", - attempt + 1, - max_retries, - command, - ) - except requests.exceptions.RequestException as e: - last_exception = e - LOG.warning( - "Request failed on attempt %d/%d: %s", - attempt + 1, - max_retries, - e, - ) - - LOG.error( - "Failed to execute command %s after %d attempts: %s", - command, - max_retries, - last_exception, - ) - raise KeaRequestError( - f"Failed to execute {command}: {last_exception}" - ) from last_exception + try: + return _send_to_kea(command, payload) + except requests.exceptions.RequestException as e: + LOG.error( + "Failed to execute command %s after %d attempts: %s", + command, + _send_to_kea.statistics.get("attempt_number"), + e, + ) + raise KeaRequestError(f"Failed to execute {command}: {e}") from e def get_config(): diff --git a/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py b/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py index 46720c9dd..ed03964ac 100644 --- a/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py +++ b/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py @@ -23,11 +23,16 @@ def _proxy_url(): CONF.clear_override("kea_proxy_url", group="ironic_understack") +@pytest.fixture(autouse=True) +def _max_retries(): + CONF.set_override("kea_max_retries", 3, group="ironic_understack") + yield + CONF.clear_override("kea_max_retries", group="ironic_understack") + + @pytest.fixture def kea(): - api = KeaDHCPApi() - api.max_retries = 3 - return api + return KeaDHCPApi() def _response(status_code=200, payload=None): @@ -66,7 +71,7 @@ def test_request_raises_after_exhausting_retries(kea, mocker): with pytest.raises(DHCPConfigurationError): kea._request("GET", "/v1/leases") - assert request.call_count == kea.max_retries + assert request.call_count == 3 def test_update_port_dhcp_opts_posts_reservation(kea, mocker): diff --git a/python/ironic-understack/pyproject.toml b/python/ironic-understack/pyproject.toml index bdbe28552..95b021d83 100644 --- a/python/ironic-understack/pyproject.toml +++ b/python/ironic-understack/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pecan>=1.7.0,<2", "webob>=1.8.9,<2", "cheroot>=11.1.2,<12", + "tenacity>=8.0.0", ] [project.scripts] diff --git a/python/ironic-understack/uv.lock b/python/ironic-understack/uv.lock index 7f76bcece..ddf2e5ab3 100644 --- a/python/ironic-understack/uv.lock +++ b/python/ironic-understack/uv.lock @@ -463,6 +463,7 @@ dependencies = [ { name = "ironic" }, { name = "pecan" }, { name = "pyyaml" }, + { name = "tenacity" }, { name = "webob" }, ] @@ -481,6 +482,7 @@ requires-dist = [ { name = "ironic", specifier = ">=35.0,<36" }, { name = "pecan", specifier = ">=1.7.0,<2" }, { name = "pyyaml", specifier = "~=6.0" }, + { name = "tenacity", specifier = ">=8.0.0" }, { name = "webob", specifier = ">=1.8.9,<2" }, ]