diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 448a52da..6ed0cd40 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -142,6 +142,14 @@ jobs: echo "MINI_LAB_VM_IMAGE=ghcr.io/metal-stack/mini-lab-vms:${IMAGE_TAG}" >> $GITHUB_ENV echo "MINI_LAB_SONIC_IMAGE=ghcr.io/metal-stack/mini-lab-sonic:${IMAGE_TAG}" >> $GITHUB_ENV + - name: Setup kind + run: | + sudo curl -Lo /usr/local/bin/kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64" + sudo chmod +x /usr/local/bin/kind + kind version + env: + KIND_VERSION: v0.32.0 + - name: Run integration tests shell: bash run: | diff --git a/.gitignore b/.gitignore index cf409683..373462c3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ files/certs/*.pem files/certs/**/*.pem files/certs/**/*.crt .vscode -vrnetlab \ No newline at end of file +vrnetlab +.codegraph diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 63418fe7..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,3 +0,0 @@ -# Contributing - -Please check out the [contributing section](https://docs.metal-stack.io/stable/development/contributing/) in our [docs](https://docs.metal-stack.io/). \ No newline at end of file diff --git a/Makefile b/Makefile index bd059e06..db61ea15 100644 --- a/Makefile +++ b/Makefile @@ -126,6 +126,16 @@ control-plane-bake: --kubeconfig $(KUBECONFIG); fi $(MAKE) create-proxy-registries docker compose up -d --force-recreate cloud-provider-kind + $(MAKE) webhook-image + +.PHONY: webhook-image +webhook-image: + docker build -t mini-lab-webhook:dev webhook/ + @if kind get clusters | grep metal-control-plane > /dev/null; then \ + kind load docker-image mini-lab-webhook:dev --name metal-control-plane; \ + else \ + echo "kind cluster metal-control-plane not found, skipping image load"; \ + fi .PHONY: partition partition: partition-bake @@ -459,7 +469,7 @@ build-dell-sonic: fetch-virtual-kubeconfig: # TODO: it's hard to get the latest issued generic kubeconfig secret... just take the first result for now kubectl --kubeconfig=$(KUBECONFIG) get secret -n garden $(shell kubectl --kubeconfig=$(KUBECONFIG) get secret -n garden -l managed-by=secrets-manager,manager-identity=gardener-operator,name=generic-token-kubeconfig --no-headers | awk '{ print $$1 }') -o jsonpath='{.data.kubeconfig}' | base64 -d > .virtual-kubeconfig - @kubectl --kubeconfig=.virtual-kubeconfig config set-cluster garden --server=https://api.gardener-kube-apiserver.172.42.0.1.nip.io:4443 + @kubectl --kubeconfig=.virtual-kubeconfig config set-cluster garden --server=https://api.gardener-kube-apiserver.$(shell kubectl --kubeconfig=$(KUBECONFIG) get svc -n virtual-garden-istio-ingress istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip }').nip.io @kubectl --kubeconfig=.virtual-kubeconfig config set-credentials garden --token=$(shell kubectl --kubeconfig=$(KUBECONFIG) get secret -n garden shoot-access-virtual-garden -o jsonpath='{.data.token}' | base64 -d) @kubectl --kubeconfig=$(KUBECONFIG) config unset users.garden @kubectl --kubeconfig=$(KUBECONFIG) config unset contexts.garden diff --git a/compose.yaml b/compose.yaml index 05c4bc5e..30c4d888 100644 --- a/compose.yaml +++ b/compose.yaml @@ -127,13 +127,13 @@ services: - REGISTRY_PROXY_TTL=168h - REGISTRY_STORAGE_DELETE_ENABLED=true - OTEL_TRACES_EXPORTER=none + cloud-provider-kind: image: registry.k8s.io/cloud-provider-kind/cloud-controller-manager:v0.10.0 restart: always - networks: - - kind + network_mode: host environment: - - KIND_EXPERIMENTAL_DOCKER_NETWORK=${KIND_EXPERIMENTAL_DOCKER_NETWORK:-kind} + - KIND_EXPERIMENTAL_DOCKER_NETWORK=${KIND_EXPERIMENTAL_DOCKER_NETWORK:-mini_lab_internal} command: # v0.10.0 of cloud controller does not support tcproutes, since it does not support the experimental gateway api channel # using envoy-gateway deployed via roles/gateway instead @@ -141,6 +141,7 @@ services: - disabled volumes: - /var/run/docker.sock:/var/run/docker.sock + volumes: proxy-docker: proxy-gcr: diff --git a/deploy_control_plane.yaml b/deploy_control_plane.yaml index 7336cf70..0453745d 100644 --- a/deploy_control_plane.yaml +++ b/deploy_control_plane.yaml @@ -1,17 +1,21 @@ --- -- name: deploy control plane +- name: deploy gateway controller hosts: control_plane connection: local gather_facts: false roles: - - name: ansible-common - tags: always - name: metal-roles/control-plane/roles/prepare tags: prepare - name: gateway tags: gateway - - name: ingress-controller - tags: ingress-controller + +- name: deploy control plane + hosts: control_plane + connection: local + gather_facts: false + roles: + - name: ansible-common + tags: always - name: metal-roles/control-plane/roles/nsq tags: nsq - name: metal-roles/control-plane/roles/metal-db @@ -33,17 +37,15 @@ tags: auth - name: metal-roles/control-plane/roles/metal tags: metal - - name: metal-roles/control-plane/roles/logging - when: monitoring_enabled - tags: logging - - name: metal-roles/control-plane/roles/monitoring - when: monitoring_enabled - tags: monitoring + +- name: deploy monitoring + import_playbook: deploy_monitoring.yaml + when: monitoring_enabled - name: deploy gardener import_playbook: deploy_gardener.yaml when: gardener_enabled and not kamaji_enabled - + - name: deploy kamaji import_playbook: deploy_kamaji.yaml when: kamaji_enabled and not gardener_enabled diff --git a/deploy_gardener.yaml b/deploy_gardener.yaml index 9cfad385..605b5cdc 100644 --- a/deploy_gardener.yaml +++ b/deploy_gardener.yaml @@ -3,24 +3,13 @@ hosts: control_plane connection: local gather_facts: false - pre_tasks: - - name: Fake Gardener metal shoot - k8s: - definition: - apiVersion: v1 - kind: ConfigMap - metadata: - name: shoot-info - namespace: kube-system - data: - nodeNetwork: 172.42.0.0/16 - podNetwork: 10.244.0.0/24 - serviceNetwork: 10.96.0.0/16 - tags: gardener - roles: - name: ansible-common tags: always + - name: gardener-hacks + tags: gardener + - name: webhook + tags: gardener - name: minio tags: minio - name: powerdns @@ -29,8 +18,6 @@ tags: gardener - name: metal-roles/control-plane/roles/gardener-extensions tags: gardener - - name: gardener-istio-patch - tags: gardener - name: metal-roles/control-plane/roles/gardener-virtual-garden-access tags: gardener - name: metal-roles/control-plane/roles/gardener-cloud-profile @@ -61,57 +48,6 @@ wait_timeout: 900 tags: gardener - - name: Wait for istio ingress gateway service - kubernetes.core.k8s_info: - api_version: v1 - kind: Service - name: istio-ingressgateway - namespace: istio-ingress - register: result - until: result.resources - retries: 30 - delay: 10 - tags: gardener - - - name: Patch istio ingress gateway service status - patch_service_status_k8s: - name: istio-ingressgateway - namespace: istio-ingress - body: - status: - loadBalancer: - ingress: - - ip: "172.42.0.1" - tags: gardener - - - name: Expose istio gateway through ingress-nginx (for local environments) - k8s: - definition: - apiVersion: networking.k8s.io/v1 - kind: Ingress - metadata: - annotations: - nginx.ingress.kubernetes.io/ssl-passthrough: "true" - name: apiserver-ingress - namespace: istio-ingress - spec: - ingressClassName: nginx - rules: - - host: "{{ metal_control_plane_stage_name }}.{{ gardener_gardenlet_default_dns_domain }}" - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: istio-ingressgateway - port: - number: 443 - tls: - - hosts: - - "{{ metal_control_plane_stage_name }}.{{ gardener_gardenlet_default_dns_domain }}" - tags: gardener - - name: Wait until Garden is ready kubernetes.core.k8s_info: api_version: "operator.gardener.cloud/v1alpha1" diff --git a/deploy_monitoring.yaml b/deploy_monitoring.yaml new file mode 100644 index 00000000..c3676993 --- /dev/null +++ b/deploy_monitoring.yaml @@ -0,0 +1,12 @@ +--- +- name: deploy monitoring + hosts: control_plane + connection: local + gather_facts: false + roles: + - name: ingress-controller + tags: ingress-controller + - name: metal-roles/control-plane/roles/logging + tags: logging + - name: metal-roles/control-plane/roles/monitoring + tags: monitoring diff --git a/files/certs/webhook/server.json b/files/certs/webhook/server.json new file mode 100644 index 00000000..a5983e6d --- /dev/null +++ b/files/certs/webhook/server.json @@ -0,0 +1,22 @@ +{ + "CN": "mini-lab-webhook", + "hosts": [ + "localhost", + "mini-lab-webhook", + "mini-lab-webhook.kube-system.svc", + "mini-lab-webhook.kube-system.svc.cluster.local" + ], + "key": { + "algo": "ecdsa", + "size": 256 + }, + "names": [ + { + "C": "DE", + "L": "Munich", + "O": "metal-stack", + "OU": "DevOps", + "ST": "Bavaria" + } + ] +} diff --git a/inventories/group_vars/all/release_vector.yaml b/inventories/group_vars/all/release_vector.yaml index 0fba64d3..ec16ee5d 100644 --- a/inventories/group_vars/all/release_vector.yaml +++ b/inventories/group_vars/all/release_vector.yaml @@ -4,6 +4,10 @@ metal_stack_release_vectors: variable_mapping_path: metal_stack_release.mapping include_role_defaults: metal-roles/common/roles/defaults oci_cosign_verify_key: "{{ lookup('file', 'cosign.pub') }}" + +gardener_extension_backup_s3_helm_chart_tag: v0.0.0-pull-request.31 +gardener_extension_backup_s3_additional_network_policies: true + ## ## for development purposes, you can override releases from our image vector here ## @@ -37,8 +41,8 @@ metal_stack_release_vectors: ## for ansible roles ## -# ansible_common_version: -# metal_roles_version: pr- +ansible_common_version: pr-51-remove-mini-lab-workaround +metal_roles_version: pr-721-backup-s3-netpols # metal_ansible_modules_version: ## diff --git a/inventories/group_vars/control_plane/gardener/gardenlet.yaml b/inventories/group_vars/control_plane/gardener/gardenlet.yaml index fe02bb70..68908fcd 100644 --- a/inventories/group_vars/control_plane/gardener/gardenlet.yaml +++ b/inventories/group_vars/control_plane/gardener/gardenlet.yaml @@ -15,7 +15,7 @@ gardener_gardenlets: name: backup-secret namespace: garden backup_infrastructure_secret: - endpoint: "{{ ('http://' + minio_dns_name + ':8080' ) | b64encode }}" + endpoint: "{{ ('http://' + minio_dns_name ) | b64encode }}" accessKeyID: "{{ minio_root_user | b64encode }}" secretAccessKey: "{{ minio_root_password | b64encode }}" region: "{{ 'us-east-1' | b64encode }}" diff --git a/inventories/group_vars/control_plane/gardener/operator.yaml b/inventories/group_vars/control_plane/gardener/operator.yaml index 06f6d525..80a941be 100644 --- a/inventories/group_vars/control_plane/gardener/operator.yaml +++ b/inventories/group_vars/control_plane/gardener/operator.yaml @@ -1,5 +1,6 @@ --- -gardener_operator_ingress_dns_domain: "gardener.{{ metal_control_plane_ingress_dns }}" +gardener_operator_ingress_dns_domain: "gardener.172.42.0.43.nip.io" +gardener_operator_virtual_garden_public_dns: "gardener-kube-apiserver.172.42.0.43.nip.io" gardener_operator_backup_infrastructure: provider: S3 @@ -10,16 +11,12 @@ gardener_operator_backup_infrastructure: namespace: garden gardener_operator_backup_infrastructure_secret: - endpoint: "{{ ('http://' + minio_dns_name + ':8080' ) | b64encode }}" + endpoint: "{{ ('http://' + minio_dns_name ) | b64encode }}" accessKeyID: "{{ minio_root_user | b64encode }}" secretAccessKey: "{{ minio_root_password | b64encode }}" region: "{{ 'us-east-1' | b64encode }}" s3ForcePathStyle: "{{ 'true' | b64encode }}" -# enable mini-lab patches -gardener_operator_patch_istio_ingress_gateway_service_ip: 172.42.0.1 -gardener_operator_expose_virtual_garden_through_ingress_nginx: true - # for local setups this should be sufficient gardener_operator_high_availability_control_plane: false diff --git a/inventories/group_vars/control_plane/minio.yaml b/inventories/group_vars/control_plane/minio.yaml index 0e7566ee..219fe31c 100644 --- a/inventories/group_vars/control_plane/minio.yaml +++ b/inventories/group_vars/control_plane/minio.yaml @@ -2,4 +2,4 @@ minio_root_user: mini-lab minio_root_password: change-me -minio_dns_name: minio.172.42.0.1.nip.io +minio_dns_name: minio.172.42.0.42.nip.io diff --git a/inventories/group_vars/control_plane/powerdns.yaml b/inventories/group_vars/control_plane/powerdns.yaml index d001532f..8f659e4a 100644 --- a/inventories/group_vars/control_plane/powerdns.yaml +++ b/inventories/group_vars/control_plane/powerdns.yaml @@ -5,16 +5,16 @@ powerdns_webserver_password: change-me powerdns_mariadb_username: root powerdns_mariadb_password: change-me -powerdns_load_balancer_dns_name: "ns.{{ metal_control_plane_ingress_dns }}" +powerdns_load_balancer_dns_name: "ns.{{ metal_control_plane_gateway_dns }}" -powerdns_api_dns_name: "powerdns-api.{{ metal_control_plane_ingress_dns }}" +powerdns_api_dns_name: "powerdns-api.{{ metal_control_plane_gateway_dns }}" powerdns_zones: - - name: "gardener.172.42.0.1.nip.io." + - name: "gardener.172.42.0.43.nip.io." kind: Master nameservers: - "{{ powerdns_load_balancer_dns_name }}." - - name: "gardener-kube-apiserver.172.42.0.1.nip.io." + - name: "gardener-kube-apiserver.172.42.0.43.nip.io." kind: Master nameservers: - "{{ powerdns_load_balancer_dns_name }}." diff --git a/roles/gardener-hacks/tasks/main.yaml b/roles/gardener-hacks/tasks/main.yaml new file mode 100644 index 00000000..b64fbdea --- /dev/null +++ b/roles/gardener-hacks/tasks/main.yaml @@ -0,0 +1,14 @@ +--- +# this allows us to use the metal-roles gardener deployment simulating to run on a metalstack.cloud cluster +- name: Fake Gardener metal shoot + k8s: + definition: + apiVersion: v1 + kind: ConfigMap + metadata: + name: shoot-info + namespace: kube-system + data: + nodeNetwork: 172.42.0.0/16 + podNetwork: 10.244.0.0/24 + serviceNetwork: 10.96.0.0/16 diff --git a/roles/gardener-istio-patch/tasks/main.yaml b/roles/gardener-istio-patch/tasks/main.yaml deleted file mode 100644 index 24ff41b9..00000000 --- a/roles/gardener-istio-patch/tasks/main.yaml +++ /dev/null @@ -1,58 +0,0 @@ ---- -# the following tasks are only for local environments, not for production environments -# gardener exposes the istio ingress gateway through service type load balancer -# we can fake the exposal by patching the status field, which is also what's -# done in the gardener local environment - -- name: Patch istio ingress gateway service to allow the seed to get ready (for local environments) - block: - - name: Wait for istio ingress gateway service - kubernetes.core.k8s_info: - api_version: v1 - kind: Service - name: istio-ingressgateway - namespace: virtual-garden-istio-ingress - register: result - until: result.resources - retries: 30 - delay: 10 - - - name: Patch istio ingress gateway service status - patch_service_status_k8s: - name: istio-ingressgateway - namespace: virtual-garden-istio-ingress - body: - status: - loadBalancer: - ingress: - - ip: "{{ gardener_operator_patch_istio_ingress_gateway_service_ip }}" - when: gardener_operator_patch_istio_ingress_gateway_service_ip - -# as we do not have service type load balancer in the local environment, this is optional for those setups -- name: Expose istio gateway through ingress-nginx (for local environments) - k8s: - definition: - apiVersion: networking.k8s.io/v1 - kind: Ingress - metadata: - annotations: - nginx.ingress.kubernetes.io/ssl-passthrough: "true" - name: apiserver-ingress - namespace: virtual-garden-istio-ingress - spec: - ingressClassName: nginx - rules: - - host: "api.{{ gardener_operator_virtual_garden_public_dns }}" - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: istio-ingressgateway - port: - number: 443 - tls: - - hosts: - - "api.{{ gardener_operator_virtual_garden_public_dns }}" - when: gardener_operator_expose_virtual_garden_through_ingress_nginx diff --git a/roles/gateway/templates/gateway.yaml b/roles/gateway/templates/gateway.yaml index 49b0ec4e..4965926e 100644 --- a/roles/gateway/templates/gateway.yaml +++ b/roles/gateway/templates/gateway.yaml @@ -18,6 +18,10 @@ spec: port: {{ gateway_http_port }} name: http hostname: "*.{{ metal_control_plane_gateway_dns }}" + # some other services for gardener integration might also use this (minio, powerdns...) + allowedRoutes: + namespaces: + from: All - protocol: HTTPS port: {{ gateway_https_port }} name: https diff --git a/roles/minio/defaults/main.yaml b/roles/minio/defaults/main.yaml index 361b7225..b81f096c 100644 --- a/roles/minio/defaults/main.yaml +++ b/roles/minio/defaults/main.yaml @@ -1,2 +1,2 @@ --- -minio_chart_version: "6.0.4" +minio_chart_version: "7.1.1" diff --git a/roles/minio/templates/values.yaml.j2 b/roles/minio/templates/values.yaml.j2 index f68a2fd8..dc3262c7 100644 --- a/roles/minio/templates/values.yaml.j2 +++ b/roles/minio/templates/values.yaml.j2 @@ -24,10 +24,25 @@ tenant: - name: mini-lab - name: gardener-operator -ingress: - api: - enabled: true - ingressClassName: nginx - annotations: - nginx.ingress.kubernetes.io/proxy-body-size: 10m - host: "{{ minio_dns_name }}" +extraResources: + - | + apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: minio-tenant + namespace: minio-tenant + spec: + parentRefs: + - name: metal-control-plane + namespace: {{ metal_control_plane_namespace }} + sectionName: http + hostnames: + - {{ minio_dns_name }} + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: minio + port: 80 diff --git a/roles/powerdns/defaults/main.yaml b/roles/powerdns/defaults/main.yaml index 37f29621..d65c6ab6 100644 --- a/roles/powerdns/defaults/main.yaml +++ b/roles/powerdns/defaults/main.yaml @@ -1,6 +1,6 @@ --- -powerdns_image_name: powerdns/pdns-auth-49 -powerdns_image_tag: 4.9.3 +powerdns_image_name: powerdns/pdns-auth-51 +powerdns_image_tag: 5.1.4 powerdns_api_key: change-me diff --git a/roles/powerdns/templates/powerdns.yaml.j2 b/roles/powerdns/templates/powerdns.yaml.j2 index 29264659..8d6bf4c9 100644 --- a/roles/powerdns/templates/powerdns.yaml.j2 +++ b/roles/powerdns/templates/powerdns.yaml.j2 @@ -117,29 +117,25 @@ spec: app: powerdns type: ClusterIP --- -apiVersion: networking.k8s.io/v1 -kind: Ingress +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute metadata: - labels: - app: powerdns - name: powerdns-api + name: powerdns spec: - ingressClassName: nginx + parentRefs: + - name: metal-control-plane + namespace: {{ metal_control_plane_namespace }} + sectionName: http + hostnames: + - {{ powerdns_api_dns_name }} rules: - - host: "{{ powerdns_api_dns_name }}" - http: - paths: - - backend: - service: - name: powerdns-api - port: - name: powerdns-api - path: / - pathType: Prefix - tls: - - hosts: - - "{{ powerdns_api_dns_name }}" - secretName: powerdns-api-tls-secret + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: powerdns-api + port: 8081 --- apiVersion: batch/v1 kind: Job diff --git a/roles/webhook/defaults/main.yaml b/roles/webhook/defaults/main.yaml new file mode 100644 index 00000000..3676b46f --- /dev/null +++ b/roles/webhook/defaults/main.yaml @@ -0,0 +1,12 @@ +--- +webhook_namespace: kube-system +webhook_deployment_name: mini-lab-webhook +webhook_service_name: mini-lab-webhook +webhook_image: mini-lab-webhook:dev +webhook_targets: + - namespace: virtual-garden-istio-ingress + service: istio-ingressgateway + ip: "172.42.0.43" + - namespace: istio-ingress + service: istio-ingressgateway + ip: "172.42.0.44" diff --git a/roles/webhook/tasks/main.yaml b/roles/webhook/tasks/main.yaml new file mode 100644 index 00000000..9663af7a --- /dev/null +++ b/roles/webhook/tasks/main.yaml @@ -0,0 +1,123 @@ +--- +- name: Create webhook namespace + kubernetes.core.k8s: + api_version: v1 + kind: Namespace + name: "{{ webhook_namespace }}" + +- name: Build webhook target args + set_fact: + webhook_target_args: "{{ webhook_target_args | default([]) + ['--target=' + item.namespace + '/' + item.service + '=' + item.ip] }}" + loop: "{{ webhook_targets }}" + +- name: Build webhook target namespaces + set_fact: + webhook_target_namespaces: "{{ webhook_targets | map(attribute='namespace') | list }}" + +- name: Build webhook target service names + set_fact: + webhook_target_service_names: "{{ webhook_targets | map(attribute='service') | list | unique }}" + +- name: Deploy webhook certificate secret + kubernetes.core.k8s: + definition: + apiVersion: v1 + kind: Secret + type: kubernetes.io/tls + metadata: + name: webhook-serving-cert + namespace: "{{ webhook_namespace }}" + data: + tls.crt: "{{ lookup('file', 'certs/webhook/server.pem') | b64encode }}" + tls.key: "{{ lookup('file', 'certs/webhook/server-key.pem') | b64encode }}" + +- name: Deploy webhook + kubernetes.core.k8s: + definition: + apiVersion: apps/v1 + kind: Deployment + metadata: + name: "{{ webhook_deployment_name }}" + namespace: "{{ webhook_namespace }}" + labels: + app: "{{ webhook_deployment_name }}" + spec: + replicas: 1 + selector: + matchLabels: + app: "{{ webhook_deployment_name }}" + template: + metadata: + labels: + app: "{{ webhook_deployment_name }}" + spec: + containers: + - name: webhook + image: "{{ webhook_image }}" + imagePullPolicy: IfNotPresent + args: "{{ ['--cert-dir=/certs', '--webhook-port=9443'] + webhook_target_args }}" + ports: + - name: webhook + containerPort: 9443 + volumeMounts: + - name: cert + mountPath: /certs + readOnly: true + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + volumes: + - name: cert + secret: + secretName: webhook-serving-cert + +- name: Deploy webhook service + kubernetes.core.k8s: + definition: + apiVersion: v1 + kind: Service + metadata: + name: "{{ webhook_service_name }}" + namespace: "{{ webhook_namespace }}" + spec: + selector: + app: "{{ webhook_deployment_name }}" + ports: + - port: 443 + targetPort: webhook + +- name: Deploy mutating webhook configuration + kubernetes.core.k8s: + definition: + apiVersion: admissionregistration.k8s.io/v1 + kind: MutatingWebhookConfiguration + metadata: + name: mini-lab-loadbalancer-ip + webhooks: + - name: loadbalancer-ip.webhook.mini-lab + admissionReviewVersions: ["v1"] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + clientConfig: + service: + name: "{{ webhook_service_name }}" + namespace: "{{ webhook_namespace }}" + path: "/mutate-services" + port: 443 + caBundle: "{{ lookup('file', 'certs/ca.pem') | b64encode }}" + rules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["services"] + namespaceSelector: + matchExpressions: + - key: kubernetes.io/metadata.name + operator: In + values: "{{ webhook_target_namespaces }}" + objectSelector: + matchFields: + - key: metadata.name + operator: In + values: "{{ webhook_target_service_names }}" diff --git a/scripts/roll_certs.sh b/scripts/roll_certs.sh index 947a8fd4..3b4096e0 100755 --- a/scripts/roll_certs.sh +++ b/scripts/roll_certs.sh @@ -13,7 +13,8 @@ rm *.csr for component in \ masterdata-api \ - metal-api; do + metal-api \ + webhook; do pushd $component echo "generating $component certs" diff --git a/test/ci-cleanup.sh b/test/ci-cleanup.sh index 52a99370..3ade03d5 100755 --- a/test/ci-cleanup.sh +++ b/test/ci-cleanup.sh @@ -16,6 +16,9 @@ fi make cleanup +echo "Remove cloud-controller-manager-kind loadbalancer containers" +docker rm -f $(docker ps -aq --filter name=kindccm-) 2>/dev/null || true + echo "Remove containers from previous runs" previous_mini_lab_containers=$(docker container list --all --filter label=containerlab=mini-lab --quiet) diff --git a/webhook/Dockerfile b/webhook/Dockerfile new file mode 100644 index 00000000..a793bd0a --- /dev/null +++ b/webhook/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.26.5 AS build +WORKDIR /src +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/webhook . + +FROM gcr.io/distroless/static-debian13:nonroot +COPY --from=build /out/webhook /webhook +USER nonroot:nonroot +ENTRYPOINT ["/webhook"] diff --git a/webhook/go.mod b/webhook/go.mod new file mode 100644 index 00000000..fbac22e9 --- /dev/null +++ b/webhook/go.mod @@ -0,0 +1,69 @@ +module github.com/metal-stack/mini-lab/webhook + +go 1.26.5 + +require ( + gomodules.xyz/jsonpatch/v2 v2.5.0 + k8s.io/api v0.36.3 + k8s.io/apimachinery v0.36.3 + sigs.k8s.io/controller-runtime v0.24.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/swag v0.28.0 // indirect + github.com/go-openapi/swag/cmdutils v0.28.0 // indirect + github.com/go-openapi/swag/conv v0.28.0 // indirect + github.com/go-openapi/swag/fileutils v0.28.0 // indirect + github.com/go-openapi/swag/jsonutils v0.28.0 // indirect + github.com/go-openapi/swag/loading v0.28.0 // indirect + github.com/go-openapi/swag/mangling v0.28.0 // indirect + github.com/go-openapi/swag/netutils v0.28.0 // indirect + github.com/go-openapi/swag/pools v0.28.0 // indirect + github.com/go-openapi/swag/stringutils v0.28.0 // indirect + github.com/go-openapi/swag/typeutils v0.28.0 // indirect + github.com/go-openapi/swag/yamlutils v0.28.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.24.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/apiextensions-apiserver v0.36.3 // indirect + k8s.io/client-go v0.36.3 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect + k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/webhook/go.sum b/webhook/go.sum new file mode 100644 index 00000000..5e919925 --- /dev/null +++ b/webhook/go.sum @@ -0,0 +1,174 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= +github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg= +github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q= +github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= +github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= +github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= +github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= +github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM= +github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k= +github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= +github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= +github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= +github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w= +k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg= +k8s.io/apiextensions-apiserver v0.36.3 h1:dPmOAPhwTtqb1bTxbFPsy18KHPhktQeO3WUPXunZIB0= +k8s.io/apiextensions-apiserver v0.36.3/go.mod h1:KTXFqgXiuw2pRoL+Wpmttqc+up9Xt/GohadPWeLLOa4= +k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM= +k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE= +k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg= +k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= +sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/webhook/handler.go b/webhook/handler.go new file mode 100644 index 00000000..6d3a7c41 --- /dev/null +++ b/webhook/handler.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "net/http" + + "gomodules.xyz/jsonpatch/v2" + admissionv1 "k8s.io/api/admission/v1" + v1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +type serviceMutator struct { + decoder admission.Decoder + targets []target +} + +func (m *serviceMutator) Handle(ctx context.Context, req admission.Request) admission.Response { + logger := log.FromContext(ctx).WithValues("namespace", req.Namespace, "name", req.Name) + + if req.Operation != admissionv1.Create && req.Operation != admissionv1.Update { + return admission.Allowed("") + } + + svc := &v1.Service{} + if err := m.decoder.Decode(req, svc); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + pinned, ok := m.matchingTarget(svc) + if !ok { + return admission.Allowed("not a target service") + } + + if svc.Spec.LoadBalancerIP == pinned { + return admission.Allowed("ip already pinned") + } + + logger.Info("pinning loadBalancerIP", "operation", req.Operation, "loadBalancerIP", pinned, "old", svc.Spec.LoadBalancerIP) + + return admission.Patched("inject loadBalancerIP", + jsonpatch.NewOperation("add", "/spec/loadBalancerIP", pinned)) +} + +func (m *serviceMutator) matchingTarget(svc *v1.Service) (string, bool) { + if svc.Spec.Type != v1.ServiceTypeLoadBalancer { + return "", false + } + for _, t := range m.targets { + if svc.Namespace == t.namespace && svc.Name == t.name { + return t.ip, true + } + } + return "", false +} diff --git a/webhook/handler_test.go b/webhook/handler_test.go new file mode 100644 index 00000000..e1c37cb7 --- /dev/null +++ b/webhook/handler_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "encoding/json" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func newTestMutator() *serviceMutator { + scheme := runtime.NewScheme() + _ = v1.AddToScheme(scheme) + return &serviceMutator{ + decoder: admission.NewDecoder(scheme), + targets: []target{ + {namespace: "virtual-garden-istio-ingress", name: "istio-ingressgateway", ip: "172.42.0.43"}, + {namespace: "istio-ingress", name: "istio-ingressgateway", ip: "172.42.0.44"}, + }, + } +} + +func encodeService(t *testing.T, svc *v1.Service) []byte { + t.Helper() + b, err := json.Marshal(svc) + if err != nil { + t.Fatal(err) + } + return b +} + +func req(t *testing.T, op admissionv1.Operation, svc *v1.Service) admission.Request { + t.Helper() + return admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: op, + Object: runtime.RawExtension{Raw: encodeService(t, svc)}, + }} +} + +func TestMutatesVirtualGardenWithoutIP(t *testing.T) { + m := newTestMutator() + svc := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "istio-ingressgateway", Namespace: "virtual-garden-istio-ingress"}, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}, + } + resp := m.Handle(context.Background(), req(t, admissionv1.Create, svc)) + if !resp.Allowed { + t.Fatalf("expected allowed response, got %v", resp.Result) + } + if len(resp.Patches) != 1 { + t.Fatalf("expected 1 patch, got %d", len(resp.Patches)) + } + if resp.Patches[0].Path != "/spec/loadBalancerIP" || resp.Patches[0].Value != "172.42.0.43" { + t.Fatalf("unexpected patch: %+v", resp.Patches[0]) + } +} + +func TestMutatesIstioIngressWithoutIP(t *testing.T) { + m := newTestMutator() + svc := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "istio-ingressgateway", Namespace: "istio-ingress"}, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}, + } + resp := m.Handle(context.Background(), req(t, admissionv1.Create, svc)) + if !resp.Allowed { + t.Fatalf("expected allowed response, got %v", resp.Result) + } + if len(resp.Patches) != 1 { + t.Fatalf("expected 1 patch, got %d", len(resp.Patches)) + } + if resp.Patches[0].Value != "172.42.0.44" { + t.Fatalf("unexpected patch: %+v", resp.Patches[0]) + } +} + +func TestDoesNotMutateAlreadyPinned(t *testing.T) { + m := newTestMutator() + svc := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "istio-ingressgateway", Namespace: "virtual-garden-istio-ingress"}, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer, LoadBalancerIP: "172.42.0.43"}, + } + resp := m.Handle(context.Background(), req(t, admissionv1.Update, svc)) + if !resp.Allowed { + t.Fatalf("expected allowed response, got %v", resp.Result) + } + if len(resp.Patches) != 0 { + t.Fatalf("expected no patches, got %d", len(resp.Patches)) + } +} + +func TestIgnoresOtherServices(t *testing.T) { + m := newTestMutator() + svc := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "other", Namespace: "default"}, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}, + } + resp := m.Handle(context.Background(), req(t, admissionv1.Create, svc)) + if !resp.Allowed || len(resp.Patches) != 0 { + t.Fatalf("expected allowed with no patches, got %v patches=%d", resp.Result, len(resp.Patches)) + } +} + +func TestParseTargets(t *testing.T) { + got, err := parseTargets([]string{ + "virtual-garden-istio-ingress/istio-ingressgateway=172.42.0.43", + "istio-ingress/istio-ingressgateway=172.42.0.44", + }) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("expected 2 targets, got %d", len(got)) + } + if got[0].namespace != "virtual-garden-istio-ingress" || got[0].name != "istio-ingressgateway" || got[0].ip != "172.42.0.43" { + t.Fatalf("unexpected target: %+v", got[0]) + } +} + +func TestParseTargetsInvalid(t *testing.T) { + if _, err := parseTargets([]string{"noequals"}); err == nil { + t.Fatal("expected error for invalid target") + } +} diff --git a/webhook/main.go b/webhook/main.go new file mode 100644 index 00000000..1986f218 --- /dev/null +++ b/webhook/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/go-logr/logr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +type target struct { + namespace string + name string + ip string +} + +func main() { + var certDir, webhookHost string + var webhookPort int + targetFlags := stringSliceFlag{} + flag.Var(&targetFlags, "target", "LoadBalancer service to pin, in the form namespace/name=ip. May be repeated.") + flag.StringVar(&certDir, "cert-dir", "/certs", "The directory that contains the server key and certificate.") + flag.StringVar(&webhookHost, "webhook-host", "", "The host the webhook server binds to.") + flag.IntVar(&webhookPort, "webhook-port", 9443, "The port the webhook server binds to.") + flag.Parse() + + slogLogger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + log.SetLogger(logr.FromSlogHandler(slogLogger.Handler())) + + targets, err := parseTargets(targetFlags.values) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid target: %v\n", err) + os.Exit(1) + } + if len(targets) == 0 { + fmt.Fprintln(os.Stderr, "at least one --target is required") + os.Exit(1) + } + + ctx := context.Background() + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), manager.Options{ + WebhookServer: webhook.NewServer(webhook.Options{ + Host: webhookHost, + Port: webhookPort, + CertDir: certDir, + }), + }) + if err != nil { + fmt.Fprintf(os.Stderr, "unable to set up manager: %v\n", err) + os.Exit(1) + } + + decoder := admission.NewDecoder(mgr.GetScheme()) + + mgr.GetWebhookServer().Register("/mutate-services", &webhook.Admission{ + Handler: &serviceMutator{ + decoder: decoder, + targets: targets, + }, + }) + + if err := mgr.Start(ctx); err != nil { + fmt.Fprintf(os.Stderr, "problem running manager: %v\n", err) + os.Exit(1) + } +} + +type stringSliceFlag struct { + values []string +} + +func (s *stringSliceFlag) String() string { + return strings.Join(s.values, ",") +} + +func (s *stringSliceFlag) Set(value string) error { + s.values = append(s.values, value) + return nil +} + +func parseTargets(values []string) ([]target, error) { + var targets []target + for _, v := range values { + parts := strings.SplitN(v, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("expected namespace/name=ip, got %q", v) + } + nsName := strings.SplitN(parts[0], "/", 2) + if len(nsName) != 2 { + return nil, fmt.Errorf("expected namespace/name=ip, got %q", v) + } + targets = append(targets, target{ + namespace: nsName[0], + name: nsName[1], + ip: parts[1], + }) + } + return targets, nil +} diff --git a/webhook/webhook b/webhook/webhook new file mode 100755 index 00000000..6ffdc13a Binary files /dev/null and b/webhook/webhook differ