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/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..832c2442d --- /dev/null +++ b/charts/argocd-understack/templates/application-kea.yaml @@ -0,0 +1,45 @@ +{{- 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: + - 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 + - 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..858fa008a 100644 --- a/charts/argocd-understack/values.yaml +++ b/charts/argocd-understack/values.yaml @@ -475,6 +475,12 @@ site: # @default -- false enabled: false + # -- Kea DHCP server + kea: + # -- Enable/disable deploying Kea DHCP + # @default -- false + enabled: false + # -- External DNS operator external_dns: # -- Enable/disable deploying External DNS 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..756b09f74 --- /dev/null +++ b/charts/kea-dhcp/templates/configmap.yaml @@ -0,0 +1,291 @@ +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, + "early-global-reservations-lookup": true, + "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.optionDef }} + "option-def": {{ .Values.kea.dhcp4.optionDef | 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 .Values.kea.dhcp4.hooksLibraries }} + "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 }} + {{- else}} + "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 }} + } + {{- 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..1eab293e7 --- /dev/null +++ b/charts/kea-dhcp/values.yaml @@ -0,0 +1,228 @@ +# 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 + optionDef: [] + # - name: "boot-file-base" + # 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" + # 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 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/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 63% rename from components/ironic/dnsmasq-ss.yaml rename to components/ironic/dnsmasq-deploy.yaml index 9a03dc53e..6ebadca2b 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,18 +29,15 @@ spec: labels: application: ironic-dnsmasq spec: - nodeSelector: - dhcp_role: server 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 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/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 c413942f7..f7fc3da47 100644 --- a/components/ironic/kustomization.yaml +++ b/components/ironic/kustomization.yaml @@ -5,8 +5,9 @@ kind: Kustomization resources: - ironic-mariadb-db.yaml - ironic-rabbitmq-queue.yaml - - dnsmasq-pvc.yaml - - dnsmasq-ss.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/components/ironic/values.yaml b/components/ironic/values.yaml index a6171ce6d..973f2794f 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: @@ -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/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/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 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..f9872bcda 100644 --- a/python/ironic-understack/ironic_understack/conf.py +++ b/python/ironic-understack/ironic_understack/conf.py @@ -26,6 +26,50 @@ def setup_conf(): "1d": "bmc", }, ), + cfg.StrOpt( + "kea_url", + 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 " + "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, + 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.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 new file mode 100644 index 000000000..81f7fd0c3 --- /dev/null +++ b/python/ironic-understack/ironic_understack/dhcp/kea.py @@ -0,0 +1,146 @@ +import requests +import tenacity +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__) + +DEFAULT_CLIENT_CLASS = "BOOTSRV_A" + + +class DHCPConfigurationError(exception.IronicException): + """Raised when there is an error in configuring DHCP.""" + + _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. + + 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__() + + if not CONF.ironic_understack.kea_proxy_url: + raise DHCPConfigurationError( + "kea_proxy_url must be specified in configuration" + ) + + @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}" + response = requests.request( + method, + url, + timeout=CONF.ironic_understack.kea_request_timeout, + **kwargs, + ) + 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.""" + port = objects.Port.get(context, port_id) + + 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.""" + 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.""" + LOG.debug("Starting DHCP cleaning") + success = True + for port in task.ports: + 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: %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: + 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): + """Indicate whether the provider supports the 'ipxe' tag.""" + return False 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..342b613fc --- /dev/null +++ b/python/ironic-understack/ironic_understack/kea_proxy/kea_client.py @@ -0,0 +1,179 @@ +"""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 +import tenacity +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 _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, + "service": services or ["dhcp4"], + "arguments": arguments, + } + + 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(): + """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 new file mode 100644 index 000000000..ed03964ac --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_dhcp_kea.py @@ -0,0 +1,162 @@ +from unittest.mock import Mock + +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(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(): + return KeaDHCPApi() + + +def _response(status_code=200, payload=None): + response = Mock() + 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_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._request("POST", "/v1/update_reservation", json={}) + + assert result == {"result": "ok"} + assert request.call_count == 2 + + +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._request("GET", "/v1/leases") + + assert request.call_count == 3 + + +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"}), + ) + + result = kea.update_port_dhcp_opts("port-id", []) + + 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_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"), + ) + + assert kea.update_port_dhcp_opts("port-id", []) is False + + +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"}), + ) + + 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"} + + +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 52d996a25..95b021d83 100644 --- a/python/ironic-understack/pyproject.toml +++ b/python/ironic-understack/pyproject.toml @@ -13,12 +13,22 @@ 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", + "tenacity>=8.0.0", ] +[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" +[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" @@ -42,6 +52,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..ddf2e5ab3 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,12 @@ name = "ironic-understack" version = "0.0.0" source = { editable = "." } dependencies = [ + { name = "cheroot" }, { name = "ironic" }, + { name = "pecan" }, { name = "pyyaml" }, + { name = "tenacity" }, + { name = "webob" }, ] [package.dev-dependencies] @@ -456,12 +473,17 @@ 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 = "tenacity", specifier = ">=8.0.0" }, + { name = "webob", specifier = ">=1.8.9,<2" }, ] [package.metadata.requires-dev] @@ -470,6 +492,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 +1520,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 +1688,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 +1730,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"