diff --git a/PROJECT b/PROJECT index 13ee26d74..68dabd689 100644 --- a/PROJECT +++ b/PROJECT @@ -1,3 +1,7 @@ +# Code generated by tool. DO NOT EDIT. +# This file is used to track the info used to scaffold your project +# and allow the plugins properly work. +# More info: https://book.kubebuilder.io/reference/project-config.html domain: ansible.com layout: - ansible.sdk.operatorframework.io/v1 @@ -27,4 +31,11 @@ resources: group: awx kind: AWXRestore version: v1beta1 +- api: + crdVersion: v1 + namespaced: true + domain: ansible.com + group: awx + kind: AWXMeshIngress + version: v1alpha1 version: "3" diff --git a/awxmeshingress-demo.yml b/awxmeshingress-demo.yml new file mode 100644 index 000000000..1224dbbef --- /dev/null +++ b/awxmeshingress-demo.yml @@ -0,0 +1,7 @@ +--- +apiVersion: awx.ansible.com/v1beta1 +kind: AWXMeshIngress +metadata: + name: awx-demo +spec: + deployment_name: awx-demo diff --git a/config/crd/bases/awx.ansible.com_awxmeshingresses.yaml b/config/crd/bases/awx.ansible.com_awxmeshingresses.yaml new file mode 100644 index 000000000..483a9e387 --- /dev/null +++ b/config/crd/bases/awx.ansible.com_awxmeshingresses.yaml @@ -0,0 +1,81 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: awxmeshingresses.awx.ansible.com +spec: + group: awx.ansible.com + names: + kind: AWXMeshIngress + listKind: AWXMeshIngressList + plural: awxmeshingresses + singular: awxmeshingress + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: AWXMeshIngress is the Schema for the awxmeshingresses API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: Spec defines the desired state of AWXMeshIngress + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - deployment_name + properties: + deployment_name: + description: Name of the AWX deployment to create the Mesh Ingress for. + type: string + external_hostname: + description: External hostname to use for the Mesh Ingress. + type: string + external_ipaddress: + description: External IP address to use for the Mesh Ingress. + type: string + ingress_type: + description: The ingress type to use to reach the deployed instance + type: string + enum: + - none + - Ingress + - ingress + - IngressRouteTCP + - ingressroutetcp + - Route + - route + ingress_api_version: + description: The Ingress API version to use + type: string + ingress_annotations: + description: Annotations to add to the Ingress Controller + type: string + ingress_class_name: + description: The name of ingress class to use instead of the cluster default. + type: string + ingress_controller: + description: Special configuration for specific Ingress Controllers + type: string + status: + description: Status defines the observed state of AWXMeshIngress + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 166d9f096..d8d563eda 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -5,4 +5,5 @@ resources: - bases/awx.ansible.com_awxs.yaml - bases/awx.ansible.com_awxbackups.yaml - bases/awx.ansible.com_awxrestores.yaml +- bases/awx.ansible.com_awxmeshingresses.yaml #+kubebuilder:scaffold:crdkustomizeresource diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 7612d5b16..bbec097ca 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -39,6 +39,7 @@ spec: - --leader-elect - --leader-election-id=awx-operator image: controller:latest + imagePullPolicy: Always name: awx-manager env: - name: ANSIBLE_GATHERING diff --git a/config/rbac/awxmeshingress_editor_role.yaml b/config/rbac/awxmeshingress_editor_role.yaml new file mode 100644 index 000000000..eb40935b2 --- /dev/null +++ b/config/rbac/awxmeshingress_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit awxmeshingresses. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: awxmeshingress-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: awx-operator + app.kubernetes.io/part-of: awx-operator + app.kubernetes.io/managed-by: kustomize + name: awxmeshingress-editor-role +rules: +- apiGroups: + - awx.ansible.com + resources: + - awxmeshingresses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - awx.ansible.com + resources: + - awxmeshingresses/status + verbs: + - get diff --git a/config/rbac/awxmeshingress_viewer_role.yaml b/config/rbac/awxmeshingress_viewer_role.yaml new file mode 100644 index 000000000..4a2d0acd3 --- /dev/null +++ b/config/rbac/awxmeshingress_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view awxmeshingresses. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: awxmeshingress-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: awx-operator + app.kubernetes.io/part-of: awx-operator + app.kubernetes.io/managed-by: kustomize + name: awxmeshingress-viewer-role +rules: +- apiGroups: + - awx.ansible.com + resources: + - awxmeshingresses + verbs: + - get + - list + - watch +- apiGroups: + - awx.ansible.com + resources: + - awxmeshingresses/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 105862ddc..9d2af0ce2 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -124,3 +124,16 @@ rules: - awxrestores verbs: - '*' + - apiGroups: + - traefik.containo.us + - traefik.io + resources: + - ingressroutetcps + verbs: + - get + - list + - create + - delete + - patch + - update + - watch diff --git a/config/samples/awx_v1alpha1_awxmeshingress.yaml b/config/samples/awx_v1alpha1_awxmeshingress.yaml new file mode 100644 index 000000000..ebe2635fe --- /dev/null +++ b/config/samples/awx_v1alpha1_awxmeshingress.yaml @@ -0,0 +1,8 @@ +# Placeholder to pass CI and allow bundle generation +--- +apiVersion: awx.ansible.com/v1alpha1 +kind: AWXMeshIngress +metadata: + name: example-awx-mesh-ingress +spec: + deployment_name: example-awx diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 0dc07e09b..61466cecd 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -3,4 +3,5 @@ resources: - awx_v1beta1_awx.yaml - awx_v1beta1_awxbackup.yaml - awx_v1beta1_awxrestore.yaml +- awx_v1alpha1_awxmeshingress.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/molecule/default/tasks/awxmeshingress_test.yml b/molecule/default/tasks/awxmeshingress_test.yml new file mode 100644 index 000000000..9b18d895d --- /dev/null +++ b/molecule/default/tasks/awxmeshingress_test.yml @@ -0,0 +1,19 @@ +# TODO: Add tests for AWXMeshIngress +# --- +# - name: Create the awx.ansible.com/v1alpha1.AWXMeshIngress +# k8s: +# state: present +# namespace: '{{ namespace }}' +# definition: "{{ lookup('template', '/'.join([samples_dir, cr_file])) | from_yaml }}" +# wait: yes +# wait_timeout: 300 +# wait_condition: +# type: Successful +# status: "True" +# vars: +# cr_file: 'awx_v1alpha1_awxmeshingress.yaml' + +# - name: Add assertions here +# assert: +# that: false +# fail_msg: FIXME Add real assertions for your operator diff --git a/roles/mesh_ingress/defaults/main.yml b/roles/mesh_ingress/defaults/main.yml new file mode 100644 index 000000000..8351bb82e --- /dev/null +++ b/roles/mesh_ingress/defaults/main.yml @@ -0,0 +1,15 @@ +--- +deployment_type: awx + +ingress_type: none +ingress_api_version: 'networking.k8s.io/v1' +ingress_annotations: '' +ingress_class_name: '' +ingress_controller: '' + +set_self_owneref: true + +_control_plane_ee_image: quay.io/ansible/awx-ee:latest +_image_pull_policy: Always + +finalizer_run: false diff --git a/roles/mesh_ingress/tasks/creation.yml b/roles/mesh_ingress/tasks/creation.yml new file mode 100644 index 000000000..e96d22294 --- /dev/null +++ b/roles/mesh_ingress/tasks/creation.yml @@ -0,0 +1,150 @@ +--- +- name: Import common role + import_role: + name: common + +- name: Debug is_openshift + debug: + msg: "is_openshift={{ is_openshift }}" + +- name: Check for presence of AWX instance that we will use to create the Mesh Ingress for. + k8s_info: + api_version: awx.ansible.com/v1beta1 + kind: AWX + name: "{{ deployment_name }}" + namespace: "{{ ansible_operator_meta.namespace }}" + register: awx_instance + +- name: Fail if awx_deployment does not exist in the same namespace + fail: + msg: "AWX instance {{ deployment_name }} does not exist in the same namespace as the AWXMeshIngress instance." + when: awx_instance.resources | length == 0 + +- name: Set awx_spec + set_fact: + awx_spec: "{{ awx_instance.resources[0].spec }}" + +- name: Set owner_reference of AWXMeshIngress to related AWX instance + k8s: + state: present + definition: + apiVersion: awx.ansible.com/v1beta1 + kind: AWX + name: "{{ deployment_name }}" + namespace: "{{ ansible_operator_meta.namespace }}" + metadata: + name: "{{ deployment_name }}" + namespace: "{{ ansible_operator_meta.namespace }}" + ownerReferences: + - apiVersion: awx.ansible.com/v1beta1 + blockOwnerDeletion: true + controller: true + kind: AWX + name: "{{ deployment_name }}" + uid: "{{ awx_instance.resources[0].metadata.uid }}" + when: set_self_owneref | bool + +- name: Set user provided control plane ee image + set_fact: + _custom_control_plane_ee_image: "{{ awx_spec.control_plane_ee_image }}" + when: + - awx_spec.control_plane_ee_image | default([]) | length + +- name: Set Control Plane EE image URL + set_fact: + _control_plane_ee_image: "{{ _custom_control_plane_ee_image | default(lookup('env', 'RELATED_IMAGE_CONTROL_PLANE_EE')) | default(_control_plane_ee_image, true) }}" + +- name: Set Image Pull Policy + set_fact: + _image_pull_policy: "{{ awx_spec.image_pull_policy | default(_image_pull_policy, true) }}" + +- name: Default ingress_type to Route if OpenShift + set_fact: + ingress_type: route + when: is_openshift | bool and ingress_type == 'none' + +- name: Apply Ingress resource + k8s: + apply: yes + definition: "{{ lookup('template', 'ingress.yml.j2') }}" + wait: yes + wait_timeout: "120" + register: ingress + +# TODO: need to wait until the route is ready before we can get the hostname +# right now this will rereconcile until the route is ready + +- name: Set external_hostname + set_fact: + external_hostname: "{{ ingress.result.status.ingress[0].host }}" + when: ingress_type == 'route' + +- name: Create other resources + k8s: + apply: yes + definition: "{{ lookup('template', '{{ item }}.yml.j2') }}" + wait: yes + wait_timeout: "120" + loop: + - service_account + - receptor_conf.configmap + - service + - deployment + +- name: Get the current resource task pod information. + k8s_info: + api_version: v1 + kind: Pod + namespace: '{{ ansible_operator_meta.namespace }}' + label_selectors: + - "app.kubernetes.io/name={{ deployment_name }}-task" + - "app.kubernetes.io/managed-by={{ deployment_type }}-operator" + - "app.kubernetes.io/component={{ deployment_type }}" + field_selectors: + - status.phase=Running + register: awx_task_pod + +- name: Set the resource pod as a variable. + set_fact: + awx_task_pod: >- + {{ awx_task_pod['resources'] + | rejectattr('metadata.deletionTimestamp', 'defined') + | sort(attribute='metadata.creationTimestamp') + | first | default({}) }} + +- name: Set the resource pod name as a variable. + set_fact: + awx_task_pod_name: "{{ awx_task_pod['metadata']['name'] | default('') }}" + +- name: Add new instance to AWX + kubernetes.core.k8s_exec: + namespace: "{{ ansible_operator_meta.namespace }}" + pod: "{{ awx_task_pod_name }}" + container: "{{ deployment_name }}-task" + command: | + awx-manage provision_instance + --hostname {{ ansible_operator_meta.name }} + --node_type hop + +- name: Add internal receptor address + kubernetes.core.k8s_exec: + namespace: "{{ ansible_operator_meta.namespace }}" + pod: "{{ awx_task_pod_name }}" + container: "{{ deployment_name }}-task" + command: | + awx-manage add_receptor_address + --instance {{ ansible_operator_meta.name }} + --address {{ ansible_operator_meta.name }} + --port 27199 --protocol ws + --peers_from_control_nodes --is_internal --canonical + +- name: Add external receptor address + kubernetes.core.k8s_exec: + namespace: "{{ ansible_operator_meta.namespace }}" + pod: "{{ awx_task_pod_name }}" + container: "{{ deployment_name }}-task" + command: | + awx-manage add_receptor_address + --instance {{ ansible_operator_meta.name }} + --address {{ external_hostname }} + --port 443 --protocol ws diff --git a/roles/mesh_ingress/tasks/finalizer.yml b/roles/mesh_ingress/tasks/finalizer.yml new file mode 100644 index 000000000..8a7e37746 --- /dev/null +++ b/roles/mesh_ingress/tasks/finalizer.yml @@ -0,0 +1,33 @@ +--- +- name: Get the current resource task pod information. + k8s_info: + api_version: v1 + kind: Pod + namespace: '{{ ansible_operator_meta.namespace }}' + label_selectors: + - "app.kubernetes.io/name={{ deployment_name }}-task" + - "app.kubernetes.io/managed-by={{ deployment_type }}-operator" + - "app.kubernetes.io/component={{ deployment_type }}" + field_selectors: + - status.phase=Running + register: awx_task_pod + +- name: Set the resource pod as a variable. + set_fact: + awx_task_pod: >- + {{ awx_task_pod['resources'] + | rejectattr('metadata.deletionTimestamp', 'defined') + | sort(attribute='metadata.creationTimestamp') + | first | default({}) }} + +- name: Set the resource pod name as a variable. + set_fact: + awx_task_pod_name: "{{ awx_task_pod['metadata']['name'] | default('') }}" + +- name: Deprovision mesh ingress instance in AWX + kubernetes.core.k8s_exec: + namespace: "{{ ansible_operator_meta.namespace }}" + pod: "{{ awx_task_pod_name }}" + container: "{{ deployment_name }}-task" + command: "awx-manage deprovision_instance --hostname {{ ansible_operator_meta.name }}" + register: result diff --git a/roles/mesh_ingress/tasks/main.yml b/roles/mesh_ingress/tasks/main.yml new file mode 100644 index 000000000..733ea64bb --- /dev/null +++ b/roles/mesh_ingress/tasks/main.yml @@ -0,0 +1,12 @@ +--- +- name: Lowercase the ingress_type + set_fact: + ingress_type: "{{ ingress_type | lower }}" + +- name: Run creation tasks + include_tasks: creation.yml + when: not finalizer_run + +- name: Run finalizer tasks + include_tasks: finalizer.yml + when: finalizer_run diff --git a/roles/mesh_ingress/templates/deployment.yml.j2 b/roles/mesh_ingress/templates/deployment.yml.j2 new file mode 100644 index 000000000..90bbe6463 --- /dev/null +++ b/roles/mesh_ingress/templates/deployment.yml.j2 @@ -0,0 +1,78 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ ansible_operator_meta.name }} + namespace: {{ ansible_operator_meta.namespace }} +spec: + selector: + matchLabels: + app.kubernetes.io/name: {{ ansible_operator_meta.name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ ansible_operator_meta.name }} + spec: + containers: + - args: + - /bin/sh + - -c + - | + internal_hostname={{ ansible_operator_meta.name }} +{% if external_hostname is defined %} + external_hostname={{ external_hostname }} +{% endif %} +{% if external_ipaddress is defined %} + external_ipaddress={{ external_ipaddress }} +{% endif %} + receptor --cert-makereq bits=2048 \ + commonname=$internal_hostname \ + dnsname=$internal_hostname \ + nodeid=$internal_hostname \ +{% if external_hostname is defined %} + dnsname=$external_hostname \ +{% endif %} +{% if external_ipaddress is defined %} + ipaddress=$external_ipaddress \ +{% endif %} + outreq=/etc/receptor/tls/receptor.req \ + outkey=/etc/receptor/tls/receptor.key + receptor --cert-signreq \ + req=/etc/receptor/tls/receptor.req \ + cacert=/etc/receptor/tls/ca/mesh-CA.crt \ + cakey=/etc/receptor/tls/ca/mesh-CA.key \ + outcert=/etc/receptor/tls/receptor.crt \ + verify=yes + exec receptor --config /etc/receptor/receptor.conf + image: '{{ _control_plane_ee_image }}' + imagePullPolicy: '{{ _image_pull_policy }}' + name: {{ ansible_operator_meta.name }}-mesh-ingress + volumeMounts: + - mountPath: /etc/receptor/receptor.conf + name: {{ ansible_operator_meta.name }}-receptor-config + subPath: receptor.conf + - mountPath: /etc/receptor/tls/ca/mesh-CA.crt + name: {{ ansible_operator_meta.name }}-receptor-ca + readOnly: true + subPath: tls.crt + - mountPath: /etc/receptor/tls/ca/mesh-CA.key + name: {{ ansible_operator_meta.name }}-receptor-ca + readOnly: true + subPath: tls.key + - mountPath: /etc/receptor/tls/ + name: {{ ansible_operator_meta.name }}-receptor-tls + restartPolicy: Always + schedulerName: default-scheduler + serviceAccount: {{ ansible_operator_meta.name }} + volumes: + - name: {{ ansible_operator_meta.name }}-receptor-tls + - name: {{ ansible_operator_meta.name }}-receptor-ca + secret: + defaultMode: 420 + secretName: {{ deployment_name }}-receptor-ca + - configMap: + defaultMode: 420 + items: + - key: receptor_conf + path: receptor.conf + name: {{ ansible_operator_meta.name }}-receptor-config + name: {{ ansible_operator_meta.name }}-receptor-config diff --git a/roles/mesh_ingress/templates/ingress.yml.j2 b/roles/mesh_ingress/templates/ingress.yml.j2 new file mode 100644 index 000000000..d37c0a183 --- /dev/null +++ b/roles/mesh_ingress/templates/ingress.yml.j2 @@ -0,0 +1,83 @@ +{% if ingress_type|lower == "ingress" %} +--- +{% if ingress_api_version is defined %} +apiVersion: '{{ ingress_api_version }}' +{% endif %} +kind: Ingress +metadata: + name: {{ ansible_operator_meta.name }} + namespace: {{ ansible_operator_meta.namespace }} + annotations: +{% if ingress_annotations %} + {{ ingress_annotations | indent(width=4) }} +{% endif %} +{% if ingress_controller|lower == "nginx" %} + nginx.ingress.kubernetes.io/ssl-passthrough: "true" +{% endif %} +spec: +{% if ingress_class_name %} + ingressClassName: '{{ ingress_class_name }}' +{% endif %} + rules: + - http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ ansible_operator_meta.name }} + port: + number: 27199 +{% if external_hostname %} + host: {{ external_hostname }} +{% endif %} +{% endif %} + +{% if ingress_type|lower == "ingressroutetcp" %} +--- +{% if ingress_api_version is defined %} +apiVersion: '{{ ingress_api_version }}' +{% endif %} +kind: IngressRouteTCP +metadata: + name: {{ ansible_operator_meta.name }} + namespace: {{ ansible_operator_meta.namespace }} + annotations: +{% if ingress_annotations %} + {{ ingress_annotations | indent(width=4) }} +{% endif %} +spec: + entryPoints: + - websecure + routes: + - services: + - name: {{ ansible_operator_meta.name }} + port: 27199 +{% if external_hostname %} + match: HostSNI(`{{ external_hostname }}`) +{% endif %} + tls: + passthrough: true +{% endif %} + +{% if ingress_type|lower == "route" %} +--- +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + annotations: + openshift.io/host.generated: "true" + name: {{ ansible_operator_meta.name }} + namespace: {{ ansible_operator_meta.namespace }} +spec: + port: + targetPort: ws + tls: + insecureEdgeTerminationPolicy: None + termination: passthrough + to: + kind: Service + name: {{ ansible_operator_meta.name }} + weight: 100 + wildcardPolicy: None +{% endif %} diff --git a/roles/mesh_ingress/templates/receptor_conf.configmap.yml.j2 b/roles/mesh_ingress/templates/receptor_conf.configmap.yml.j2 new file mode 100644 index 000000000..c528922a1 --- /dev/null +++ b/roles/mesh_ingress/templates/receptor_conf.configmap.yml.j2 @@ -0,0 +1,24 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ ansible_operator_meta.name }}-receptor-config + namespace: {{ ansible_operator_meta.namespace }} +data: + receptor_conf: | + --- + - node: + id: {{ ansible_operator_meta.name }} + - log-level: debug + - control-service: + service: control + - ws-listener: + port: 27199 + tls: tlsserver + - tls-server: + cert: /etc/receptor/tls/receptor.crt + key: /etc/receptor/tls/receptor.key + name: tlsserver + clientcas: /etc/receptor/tls/ca/mesh-CA.crt + requireclientcert: true + mintls13: false diff --git a/roles/mesh_ingress/templates/service.yml.j2 b/roles/mesh_ingress/templates/service.yml.j2 new file mode 100644 index 000000000..4b4325688 --- /dev/null +++ b/roles/mesh_ingress/templates/service.yml.j2 @@ -0,0 +1,14 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ ansible_operator_meta.name }} + namespace: '{{ ansible_operator_meta.namespace }}' +spec: + type: ClusterIP + ports: + - name: ws + port: 27199 + targetPort: 27199 + selector: + app.kubernetes.io/name: {{ ansible_operator_meta.name }} diff --git a/roles/mesh_ingress/templates/service_account.yml.j2 b/roles/mesh_ingress/templates/service_account.yml.j2 new file mode 100644 index 000000000..5a96fa6af --- /dev/null +++ b/roles/mesh_ingress/templates/service_account.yml.j2 @@ -0,0 +1,6 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: '{{ ansible_operator_meta.name }}' + namespace: '{{ ansible_operator_meta.namespace }}' diff --git a/watches.yaml b/watches.yaml index 10dd3275e..355965fe4 100644 --- a/watches.yaml +++ b/watches.yaml @@ -22,4 +22,15 @@ kind: AWXRestore role: restore snakeCaseParameters: False + +- version: v1alpha1 + group: awx.ansible.com + kind: AWXMeshIngress + role: mesh_ingress + snakeCaseParameters: False + finalizer: + name: awx.ansible.com/awx-mesh-ingress-finalizer + role: mesh_ingress + vars: + finalizer_run: true # +kubebuilder:scaffold:watch