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: "*" 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..494bbd173 --- /dev/null +++ b/charts/argocd-understack/templates/application-kea.yaml @@ -0,0 +1,47 @@ +{{- 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: + # TODO: revert to `kea` once that namespace is deployable again + namespace: openstack + 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/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/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 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: diff --git a/components/kea/values.yaml b/components/kea/values.yaml new file mode 100644 index 000000000..87badda9e --- /dev/null +++ b/components/kea/values.yaml @@ -0,0 +1,13 @@ +# Default values for kea-dhcp. Using upstream chart defaults for now. +# +# 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). + +kea: + ctrlagent: + #Needed for HA, monitoring and stork + enabled: true + loglevel: "DEBUG" 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 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..1bfb8647b --- /dev/null +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -0,0 +1,175 @@ +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 + +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.ironic_understack.kea_max_retries + + if not CONF.ironic_understack.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, + } + print(f"PAYLOAD: {payload}") if command == "config-set" else 0 + + for attempt in range(self.max_retries): + 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() + 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", {})[0] + + def set_config(self, config): + """Update Kea configuration.""" + return self._make_request("config-set", 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, 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 + # 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"] + 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, + "next-server": next_server, + } + ) + 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) + + boot_file_name = "" + 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) + + 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 False 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"