Repository: stackabletech/airflow-operator
Pull request #604: feat: Airflow Listener integration
Author: login: adwk67
Created: 2025-04-04T07:15:36Z | Merged: 2025-05-07T15:41:50Z

# Description

fixes: https://github.com/stackabletech/airflow-operator/issues/580

Jenkins [tests](https://testing.stackable.tech/view/02%20Operator%20Tests%20(custom)/job/airflow-operator-it-custom/25/) :green_circle: 
Openshift tests :green_circle: (logging test passed when run again in isolation)
Tests after https://github.com/stackabletech/decisions/issues/51 changes: :green_circle: [Jenkins](https://testing.stackable.tech/view/02%20Operator%20Tests%20(custom)/job/airflow-operator-it-custom/28/)

## Definition of Done Checklist

- Not all of these items are applicable to all PRs, the author should update this template to only leave the boxes in that are relevant
- Please make sure all these things are done and tick the boxes

```[tasklist]
# Author
- [x] Changes are OpenShift compatible
- [x] CRD changes approved
- [x] CRD documentation for all fields, following the [style guide](https://docs.stackable.tech/home/nightly/contributor/docs/style-guide).
- [x] Helm chart can be installed and deployed operator works
- [x] Integration tests passed (for non trivial changes)
- [x] Changes need to be "offline" compatible
```

```[tasklist]
# Reviewer
- [x] Code contains useful comments
- [x] Code contains useful logging statements
- [x] (Integration-)Test cases added
- [x] Documentation added or updated. Follows the [style guide](https://docs.stackable.tech/home/nightly/contributor/docs/style-guide).
- [x] Changelog updated
- [x] Cargo.toml only contains references to git tags (not specific commits or branches)
```

```[tasklist]
# Acceptance
- [ ] Feature Tracker has been updated
- [ ] Proper release label has been added
- [ ] [Roadmap](https://github.com/orgs/stackabletech/projects/25/views/1) has been updated
```



Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 212f6e9d..d63246b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 ### Added
 
+- Added listener support for Airflow ([#604]).
 - Adds new telemetry CLI arguments and environment variables ([#613]).
   - Use `--file-log-max-files` (or `FILE_LOG_MAX_FILES`) to limit the number of log files kept.
   - Use `--file-log-rotation-period` (or `FILE_LOG_ROTATION_PERIOD`) to configure the frequency of rotation.
@@ -28,6 +29,7 @@
 
 [#600]: https://github.com/stackabletech/airflow-operator/pull/600
 [#601]: https://github.com/stackabletech/airflow-operator/pull/601
+[#604]: https://github.com/stackabletech/airflow-operator/pull/604
 [#607]: https://github.com/stackabletech/airflow-operator/pull/607
 [#608]: https://github.com/stackabletech/airflow-operator/pull/608
 [#613]: https://github.com/stackabletech/airflow-operator/pull/613
diff --git a/deploy/helm/airflow-operator/crds/crds.yaml b/deploy/helm/airflow-operator/crds/crds.yaml
index 781fd8af..49e32896 100644
--- a/deploy/helm/airflow-operator/crds/crds.yaml
+++ b/deploy/helm/airflow-operator/crds/crds.yaml
@@ -584,23 +584,6 @@ spec:
                       default: false
                       description: for internal use only - not for production use.
                       type: boolean
-                    listenerClass:
-                      default: cluster-internal
-                      description: |-
-                        This field controls which type of Service the Operator creates for this AirflowCluster:
-
-                        * cluster-internal: Use a ClusterIP service
-
-                        * external-unstable: Use a NodePort service
-
-                        * external-stable: Use a LoadBalancer service
-
-                        This is a temporary solution with the goal to keep yaml manifests forward compatible. In the future, this setting will control which [ListenerClass](https://docs.stackable.tech/home/nightly/listener-operator/listenerclass.html) will be used to expose the service, and ListenerClass names will stay the same, allowing for a non-breaking change.
-                      enum:
-                        - cluster-internal
-                        - external-unstable
-                        - external-stable
-                      type: string
                     loadExamples:
                       default: false
                       description: Whether to load example DAGs or not; defaults to false. The examples are used in the [getting started guide](https://docs.stackable.tech/home/nightly/airflow/getting_started/).
@@ -1338,6 +1321,10 @@ spec:
                           description: Time period Pods have to gracefully shut down, e.g. `30m`, `1h` or `2d`. Consult the operator documentation for details.
                           nullable: true
                           type: string
+                        listenerClass:
+                          description: This field controls which [ListenerClass](https://docs.stackable.tech/home/nightly/listener-operator/listenerclass.html) is used to expose the webserver.
+                          nullable: true
+                          type: string
                         logging:
                           default:
                             containers: {}
@@ -1555,6 +1542,10 @@ spec:
                                 description: Time period Pods have to gracefully shut down, e.g. `30m`, `1h` or `2d`. Consult the operator documentation for details.
                                 nullable: true
                                 type: string
+                              listenerClass:
+                                description: This field controls which [ListenerClass](https://docs.stackable.tech/home/nightly/listener-operator/listenerclass.html) is used to expose the webserver.
+                                nullable: true
+                                type: string
                               logging:
                                 default:
                                   containers: {}
diff --git a/deploy/helm/airflow-operator/templates/roles.yaml b/deploy/helm/airflow-operator/templates/roles.yaml
index ee6e9a46..dbe39470 100644
--- a/deploy/helm/airflow-operator/templates/roles.yaml
+++ b/deploy/helm/airflow-operator/templates/roles.yaml
@@ -84,6 +84,17 @@ rules:
       - customresourcedefinitions
     verbs:
       - get
+  - apiGroups:
+      - listeners.stackable.tech
+    resources:
+      - listeners
+    verbs:
+      - get
+      - list
+      - watch
+      - patch
+      - create
+      - delete
   - apiGroups:
       - {{ include "operator.name" . }}.stackable.tech
     resources:
diff --git a/docs/modules/airflow/pages/usage-guide/listenerclass.adoc b/docs/modules/airflow/pages/usage-guide/listenerclass.adoc
index 67c9f330..9e4ee761 100644
--- a/docs/modules/airflow/pages/usage-guide/listenerclass.adoc
+++ b/docs/modules/airflow/pages/usage-guide/listenerclass.adoc
@@ -1,18 +1,19 @@
 = Service exposition with ListenerClasses
 :description: Configure Airflow service exposure with ListenerClasses: cluster-internal, external-unstable, or external-stable.
 
-Airflow offers a web UI and an API, both are exposed by the webserver process under the `webserver` role.
-The Operator deploys a service called `<name>-webserver` (where `<name>` is the name of the AirflowCluster) through which Airflow can be reached.
-
-This service can have three different types: `cluster-internal`, `external-unstable` and `external-stable`.
-Read more about the types in the xref:concepts:service-exposition.adoc[service exposition] documentation at platform level.
-
-This is how the listener class is configured:
+The operator deploys a xref:listener-operator:listener.adoc[Listener] for the Webserver pod.
+The listener defaults to only being accessible from within the Kubernetes cluster, but this can be changed by setting `.spec.webservers.config.listenerClass`:
 
 [source,yaml]
 ----
 spec:
-  clusterConfig:
-    listenerClass: cluster-internal  # <1>
+  webservers:
+    config:
+      listenerClass: external-unstable  # <1>
+  schedulers:
+    ...
+  celeryExecutors:
+    ...
 ----
-<1> The default `cluster-internal` setting.
+<1> Specify a ListenerClass, such as `external-stable`, `external-unstable`, or `cluster-internal` (the default setting is `cluster-internal`).
+This can be set only for the webservers role.
diff --git a/nix/sources.json b/nix/sources.json
index 78d7121b..5aee1c9d 100644
--- a/nix/sources.json
+++ b/nix/sources.json
@@ -29,10 +29,10 @@
         "homepage": "",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "b0b4b5f8f621bfe213b8b21694bab52ecfcbf30b",
-        "sha256": "1y8kwbb5b0r1m88nk871ai56qi2drygvibjgc2swp48jfyp5ya99",
+        "rev": "b2b0718004cc9a5bca610326de0a82e6ea75920b",
+        "sha256": "0aqrxx1w40aqicjhg2057bpyrrbsx6mnii5dp5klpm4labfg2iwi",
         "type": "tarball",
-        "url": "https://github.com/NixOS/nixpkgs/archive/b0b4b5f8f621bfe213b8b21694bab52ecfcbf30b.tar.gz",
+        "url": "https://github.com/NixOS/nixpkgs/archive/b2b0718004cc9a5bca610326de0a82e6ea75920b.tar.gz",
         "url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
     }
 }
diff --git a/rust/operator-binary/src/airflow_controller.rs b/rust/operator-binary/src/airflow_controller.rs
index 7ce18f92..e7154e9a 100644
--- a/rust/operator-binary/src/airflow_controller.rs
+++ b/rust/operator-binary/src/airflow_controller.rs
@@ -19,13 +19,20 @@ use stackable_operator::{
         configmap::ConfigMapBuilder,
         meta::ObjectMetaBuilder,
         pod::{
-            PodBuilder, container::ContainerBuilder, resources::ResourceRequirementsBuilder,
-            security::PodSecurityContextBuilder, volume::VolumeBuilder,
+            PodBuilder,
+            container::ContainerBuilder,
+            resources::ResourceRequirementsBuilder,
+            security::PodSecurityContextBuilder,
+            volume::{
+                ListenerOperatorVolumeSourceBuilder, ListenerOperatorVolumeSourceBuilderError,
+                ListenerReference, VolumeBuilder,
+            },
         },
     },
     cluster_resources::{ClusterResourceApplyStrategy, ClusterResources},
     commons::{
         authentication::{AuthenticationClass, ldap},
+        listener::{Listener, ListenerPort, ListenerSpec},
         product_image_selection::ResolvedProductImage,
         rbac::build_rbac_resources,
     },
@@ -35,8 +42,9 @@ use stackable_operator::{
         api::{
             apps::v1::{StatefulSet, StatefulSetSpec},
             core::v1::{
-                ConfigMap, EmptyDirVolumeSource, EnvVar, PodTemplateSpec, Probe, Service,
-                ServiceAccount, ServicePort, ServiceSpec, TCPSocketAction, VolumeMount,
+                ConfigMap, EmptyDirVolumeSource, EnvVar, PersistentVolumeClaim, PodTemplateSpec,
+                Probe, Service, ServiceAccount, ServicePort, ServiceSpec, TCPSocketAction,
+                VolumeMount,
             },
         },
         apimachinery::pkg::{apis::meta::v1::LabelSelector, util::intstr::IntOrString},
@@ -76,8 +84,10 @@ use crate::{
     crd::{
         self, AIRFLOW_CONFIG_FILENAME, AIRFLOW_UID, APP_NAME, AirflowClusterStatus, AirflowConfig,
         AirflowConfigOptions, AirflowExecutor, AirflowRole, CONFIG_PATH, Container, ExecutorConfig,
-        ExecutorConfigFragment, LOG_CONFIG_DIR, OPERATOR_NAME, STACKABLE_LOG_DIR,
-        TEMPLATE_CONFIGMAP_NAME, TEMPLATE_LOCATION, TEMPLATE_NAME, TEMPLATE_VOLUME_NAME,
+        ExecutorConfigFragment, HTTP_PORT, HTTP_PORT_NAME, LISTENER_VOLUME_DIR,
+        LISTENER_VOLUME_NAME, LOG_CONFIG_DIR, METRICS_PORT, METRICS_PORT_NAME, OPERATOR_NAME,
+        STACKABLE_LOG_DIR, TEMPLATE_CONFIGMAP_NAME, TEMPLATE_LOCATION, TEMPLATE_NAME,
+        TEMPLATE_VOLUME_NAME,
         authentication::{
             AirflowAuthenticationClassResolved, AirflowClientAuthenticationDetailsResolved,
         },
@@ -103,9 +113,6 @@ pub const DOCKER_IMAGE_BASE_NAME: &str = "airflow";
 pub const AIRFLOW_FULL_CONTROLLER_NAME: &str =
     concatcp!(AIRFLOW_CONTROLLER_NAME, '.', OPERATOR_NAME);
 
-const METRICS_PORT_NAME: &str = "metrics";
-const METRICS_PORT: i32 = 9102;
-
 pub struct Ctx {
     pub client: stackable_operator::client::Client,
     pub product_config: ProductConfigManager,
@@ -321,6 +328,21 @@ pub enum Error {
 
     #[snafu(display("failed to build Statefulset environmental variables"))]
     BuildStatefulsetEnvVars { source: env_vars::Error },
+
+    #[snafu(display("failed to build Labels"))]
+    LabelBuild {
+        source: stackable_operator::kvp::LabelError,
+    },
+
+    #[snafu(display("failed to build listener volume"))]
+    BuildListenerVolume {
+        source: ListenerOperatorVolumeSourceBuilderError,
+    },
+
+    #[snafu(display("failed to apply group listener"))]
+    ApplyGroupListener {
+        source: stackable_operator::cluster_resources::Error,
+    },
 }
 
 type Result<T, E = Error> = std::result::Result<T, E>;
@@ -450,16 +472,6 @@ pub async fn reconcile_airflow(
                 role: role_name.to_string(),
             })?;
 
-        // some roles will only run "internally" and do not need to be created as services
-        if let Some(resolved_port) = role_port(role_name) {
-            let role_service =
-                build_role_service(airflow, &resolved_product_image, role_name, resolved_port)?;
-            cluster_resources
-                .add(client, role_service)
-                .await
-                .context(ApplyRoleServiceSnafu)?;
-        }
-
         for (rolegroup_name, rolegroup_config) in role_config.iter() {
             let rolegroup = RoleGroupRef {
                 cluster: ObjectRef::from_obj(airflow),
@@ -491,6 +503,26 @@ pub async fn reconcile_airflow(
                 airflow_executor,
             )?;
 
+            if let Some(listener_class) =
+                airflow.merged_listener_class(&airflow_role, &rolegroup.role_group)
+            {
+                if let Some(listener_group_name) =
+                    airflow.group_listener_name(&airflow_role, &rolegroup)
+                {
+                    let rg_group_listener = build_group_listener(
+                        airflow,
+                        &resolved_product_image,
+                        &rolegroup,
+                        listener_class.to_string(),
+                        listener_group_name,
+                    )?;
+                    cluster_resources
+                        .add(client, rg_group_listener)
+                        .await
+                        .context(ApplyGroupListenerSnafu)?;
+                }
+            }
+
             ss_cond_builder.add(
                 cluster_resources
                     .add(client, rg_statefulset)
@@ -523,7 +555,7 @@ pub async fn reconcile_airflow(
             pod_disruption_budget: pdb,
         }) = role_config
         {
-            add_pdbs(pdb, airflow, &airflow_role, client, &mut cluster_resources)
+            add_pdbs(&pdb, airflow, &airflow_role, client, &mut cluster_resources)
                 .await
                 .context(FailedToCreatePdbSnafu)?;
         }
@@ -602,68 +634,6 @@ async fn build_executor_template(
     Ok(())
 }
 
-/// The server-role service is the primary endpoint that should be used by clients that do not perform internal load balancing,
-/// including targets outside the cluster.
-fn build_role_service(
-    airflow: &v1alpha1::AirflowCluster,
-    resolved_product_image: &ResolvedProductImage,
-    role_name: &str,
-    port: u16,
-) -> Result<Service> {
-    let role_svc_name = format!("{}-{}", airflow.name_any(), role_name);
-    let ports = role_ports(port);
-
-    let metadata = ObjectMetaBuilder::new()
-        .name_and_namespace(airflow)
-        .name(&role_svc_name)
-        .ownerreference_from_resource(airflow, None, Some(true))
-        .context(ObjectMissingMetadataForOwnerRefSnafu)?
-        .with_recommended_labels(build_recommended_labels(
-            airflow,
-            AIRFLOW_CONTROLLER_NAME,
-            &resolved_product_image.app_version_label,
-            role_name,
-            "global",
-        ))
-        .context(ObjectMetaSnafu)?
-        .build();
-
-    let service_selector_labels =
-        Labels::role_selector(airflow, APP_NAME, role_name).context(BuildLabelSnafu)?;
-
-    let service_spec = ServiceSpec {
-        type_: Some(
-            airflow
-                .spec
-                .cluster_config
-                .listener_class
-                .k8s_service_type(),
-        ),
-        ports: Some(ports),
-        selector: Some(service_selector_labels.into()),
-        ..ServiceSpec::default()
-    };
-
-    Ok(Service {
-        metadata,
-        spec: Some(service_spec),
-        status: None,
-    })
-}
-
-fn role_ports(port: u16) -> Vec<ServicePort> {
-    vec![ServicePort {
-        name: Some("http".to_string()),
-        port: port.into(),
-        protocol: Some("TCP".to_string()),
-        ..ServicePort::default()
-    }]
-}
-
-fn role_port(role_name: &str) -> Option<u16> {
-    AirflowRole::from_str(role_name).unwrap().get_http_port()
-}
-
 /// The rolegroup [`ConfigMap`] configures the rolegroup based on the configuration given by the administrator
 #[allow(clippy::too_many_arguments)]
 fn build_rolegroup_config_map(
@@ -781,17 +751,13 @@ fn build_rolegroup_service(
     resolved_product_image: &ResolvedProductImage,
     rolegroup: &RoleGroupRef<v1alpha1::AirflowCluster>,
 ) -> Result<Service> {
-    let mut ports = vec![ServicePort {
+    let ports = vec![ServicePort {
         name: Some(METRICS_PORT_NAME.into()),
-        port: METRICS_PORT,
+        port: METRICS_PORT.into(),
         protocol: Some("TCP".to_string()),
         ..Default::default()
     }];
 
-    if let Some(http_port) = role_port(&rolegroup.role) {
-        ports.append(&mut role_ports(http_port));
-    }
-
     let prometheus_label =
         Label::try_from(("prometheus.io/scrape", "true")).context(BuildLabelSnafu)?;
 
@@ -800,6 +766,7 @@ fn build_rolegroup_service(
         &resolved_product_image,
         &rolegroup,
         prometheus_label,
+        format!("{name}-metrics", name = rolegroup.object_name()),
     )?;
 
     let service_selector_labels =
@@ -828,10 +795,11 @@ fn build_rolegroup_metadata(
     resolved_product_image: &&ResolvedProductImage,
     rolegroup: &&RoleGroupRef<v1alpha1::AirflowCluster>,
     prometheus_label: Label,
+    name: String,
 ) -> Result<ObjectMeta, Error> {
     let metadata = ObjectMetaBuilder::new()
         .name_and_namespace(airflow)
-        .name(rolegroup.object_name())
+        .name(name)
         .ownerreference_from_resource(airflow, None, Some(true))
         .context(ObjectMissingMetadataForOwnerRefSnafu)?
         .with_recommended_labels(build_recommended_labels(
@@ -847,6 +815,47 @@ fn build_rolegroup_metadata(
     Ok(metadata)
 }
 
+pub fn build_group_listener(
+    airflow: &v1alpha1::AirflowCluster,
+    resolved_product_image: &ResolvedProductImage,
+    rolegroup: &RoleGroupRef<v1alpha1::AirflowCluster>,
+    listener_class: String,
+    listener_group_name: String,
+) -> Result<Listener> {
+    Ok(Listener {
+        metadata: ObjectMetaBuilder::new()
+            .name_and_namespace(airflow)
+            .name(listener_group_name)
+            .ownerreference_from_resource(airflow, None, Some(true))
+            .context(ObjectMissingMetadataForOwnerRefSnafu)?
+            .with_recommended_labels(build_recommended_labels(
+                airflow,
+                AIRFLOW_CONTROLLER_NAME,
+                &resolved_product_image.app_version_label,
+                &rolegroup.role,
+                &rolegroup.role_group,
+            ))
+            .context(ObjectMetaSnafu)?
+            .build(),
+        spec: ListenerSpec {
+            class_name: Some(listener_class),
+            ports: Some(listener_ports()),
+            ..ListenerSpec::default()
+        },
+        status: None,
+    })
+}
+
+/// We only use the http port here and intentionally omit
+/// the metrics one.
+fn listener_ports() -> Vec<ListenerPort> {
+    vec![ListenerPort {
+        name: HTTP_PORT_NAME.to_string(),
+        port: HTTP_PORT.into(),
+        protocol: Some("TCP".to_string()),
+    }]
+}
+
 /// The rolegroup [`StatefulSet`] runs the rolegroup, as configured by the administrator.
 ///
 /// The [`Pod`](`stackable_operator::k8s_openapi::api::core::v1::Pod`)s are accessible through the corresponding [`Service`] (from [`build_rolegroup_service`]).
@@ -869,15 +878,26 @@ fn build_server_rolegroup_statefulset(
     let rolegroup = role.role_groups.get(&rolegroup_ref.role_group);
 
     let mut pb = PodBuilder::new();
+    let recommended_object_labels = build_recommended_labels(
+        airflow,
+        AIRFLOW_CONTROLLER_NAME,
+        &resolved_product_image.app_version_label,
+        &rolegroup_ref.role,
+        &rolegroup_ref.role_group,
+    );
+    // Used for PVC templates that cannot be modified once they are deployed
+    let unversioned_recommended_labels = Labels::recommended(build_recommended_labels(
+        airflow,
+        AIRFLOW_CONTROLLER_NAME,
+        // A version value is required, and we do want to use the "recommended" format for the other desired labels
+        "none",
+        &rolegroup_ref.role,
+        &rolegroup_ref.role_group,
+    ))
+    .context(LabelBuildSnafu)?;
 
     let pb_metadata = ObjectMetaBuilder::new()
-        .with_recommended_labels(build_recommended_labels(
-            airflow,
-            AIRFLOW_CONTROLLER_NAME,
-            &resolved_product_image.app_version_label,
-            &rolegroup_ref.role,
-            &rolegroup_ref.role_group,
-        ))
+        .with_recommended_labels(recommended_object_labels)
         .context(ObjectMetaSnafu)?
         .build();
 
@@ -952,10 +972,11 @@ fn build_server_rolegroup_statefulset(
             .context(AddVolumeMountSnafu)?;
     }
 
-    if let Some(resolved_port) = airflow_role.get_http_port() {
+    // for roles with an http endpoint
+    if let Some(http_port) = airflow_role.get_http_port() {
         let probe = Probe {
             tcp_socket: Some(TCPSocketAction {
-                port: IntOrString::Int(resolved_port.into()),
+                port: IntOrString::Int(http_port.into()),
                 ..TCPSocketAction::default()
             }),
             initial_delay_seconds: Some(60),
@@ -965,7 +986,28 @@ fn build_server_rolegroup_statefulset(
         };
         airflow_container.readiness_probe(probe.clone());
         airflow_container.liveness_probe(probe);
-        airflow_container.add_container_port("http", resolved_port.into());
+        airflow_container.add_container_port(HTTP_PORT_NAME, http_port.into());
+    }
+
+    let mut pvcs: Option<Vec<PersistentVolumeClaim>> = None;
+
+    if let Some(listener_group_name) = airflow.group_listener_name(airflow_role, rolegroup_ref) {
+        // Listener endpoints for the Webserver role will use persistent volumes
+        // so that load balancers can hard-code the target addresses. This will
+        // be the case even when no class is set (and the value defaults to
+        // cluster-internal) as the address should still be consistent.
+        let pvc = ListenerOperatorVolumeSourceBuilder::new(
+            &ListenerReference::ListenerName(listener_group_name),
+            &unversioned_recommended_labels,
+        )
+        .context(BuildListenerVolumeSnafu)?
+        .build_pvc(LISTENER_VOLUME_NAME.to_string())
+        .context(BuildListenerVolumeSnafu)?;
+        pvcs = Some(vec![pvc]);
+
+        airflow_container
+            .add_volume_mount(LISTENER_VOLUME_NAME, LISTENER_VOLUME_DIR)
+            .context(AddVolumeMountSnafu)?;
     }
 
     pb.add_container(airflow_container.build());
@@ -989,7 +1031,7 @@ fn build_server_rolegroup_statefulset(
             ]
             .join("\n"),
         ])
-        .add_container_port(METRICS_PORT_NAME, METRICS_PORT)
+        .add_container_port(METRICS_PORT_NAME, METRICS_PORT.into())
         .resources(
             ResourceRequirementsBuilder::new()
                 .with_cpu_request("100m")
@@ -1093,6 +1135,7 @@ fn build_server_rolegroup_statefulset(
         &resolved_product_image,
         &rolegroup_ref,
         restarter_label,
+        rolegroup_ref.object_name(),
     )?;
 
     let statefulset_match_labels = Labels::role_group_selector(
@@ -1120,6 +1163,7 @@ fn build_server_rolegroup_statefulset(
         },
         service_name: rolegroup_ref.object_name(),
         template: pod_template,
+        volume_claim_templates: pvcs,
         ..StatefulSetSpec::default()
     };
 
diff --git a/rust/operator-binary/src/crd/mod.rs b/rust/operator-binary/src/crd/mod.rs
index e435a812..0f3d30f9 100644
--- a/rust/operator-binary/src/crd/mod.rs
+++ b/rust/operator-binary/src/crd/mod.rs
@@ -34,7 +34,7 @@ use stackable_operator::{
     },
     role_utils::{
         CommonConfiguration, GenericProductSpecificCommonConfig, GenericRoleConfig, Role,
-        RoleGroupRef,
+        RoleGroup, RoleGroupRef,
     },
     schemars::{self, JsonSchema},
     status::condition::{ClusterCondition, HasStatusCondition},
@@ -72,6 +72,14 @@ pub const TEMPLATE_CONFIGMAP_NAME: &str = "airflow-executor-pod-template";
 pub const TEMPLATE_LOCATION: &str = "/templates";
 pub const TEMPLATE_NAME: &str = "airflow_executor_pod_template.yaml";
 
+pub const LISTENER_VOLUME_NAME: &str = "listener";
+pub const LISTENER_VOLUME_DIR: &str = "/stackable/listener";
+
+pub const HTTP_PORT_NAME: &str = "http";
+pub const HTTP_PORT: u16 = 8080;
+pub const METRICS_PORT_NAME: &str = "metrics";
+pub const METRICS_PORT: u16 = 9102;
+
 const DEFAULT_AIRFLOW_GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_minutes_unchecked(2);
 const DEFAULT_WORKER_GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_minutes_unchecked(5);
 
@@ -84,10 +92,15 @@ pub const MAX_LOG_FILES_SIZE: MemoryQuantity = MemoryQuantity {
 pub enum Error {
     #[snafu(display("Unknown Airflow role found {role}. Should be one of {roles:?}"))]
     UnknownAirflowRole { role: String, roles: Vec<String> },
+
     #[snafu(display("fragment validation failure"))]
     FragmentValidationFailure { source: ValidationError },
+
     #[snafu(display("Configuration/Executor conflict!"))]
     NoRoleForExecutorFailure,
+
+    #[snafu(display("object has no associated namespace"))]
+    NoNamespace,
 }
 
 #[derive(Display, EnumIter, EnumString)]
@@ -187,7 +200,7 @@ pub mod versioned {
 
         /// The `webserver` role provides the main UI for user interaction.
         #[serde(default, skip_serializing_if = "Option::is_none")]
-        pub webservers: Option<Role<AirflowConfigFragment>>,
+        pub webservers: Option<Role<WebserverConfigFragment>>,
 
         /// The `scheduler` is responsible for triggering jobs and persisting their metadata to the backend database.
         /// Jobs are scheduled on the workers/executors.
@@ -230,20 +243,6 @@ pub mod versioned {
         #[serde(default)]
         pub load_examples: bool,
 
-        /// This field controls which type of Service the Operator creates for this AirflowCluster:
-        ///
-        /// * cluster-internal: Use a ClusterIP service
-        ///
-        /// * external-unstable: Use a NodePort service
-        ///
-        /// * external-stable: Use a LoadBalancer service
-        ///
-        /// This is a temporary solution with the goal to keep yaml manifests forward compatible.
-        /// In the future, this setting will control which [ListenerClass](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listenerclass.html)
-        /// will be used to expose the service, and ListenerClass names will stay the same, allowing for a non-breaking change.
-        #[serde(default)]
-        pub listener_class: CurrentlySupportedListenerClasses,
-
         /// Name of the Vector aggregator [discovery ConfigMap](DOCS_BASE_URL_PLACEHOLDER/concepts/service_discovery).
         /// It must contain the key `ADDRESS` with the address of the Vector aggregator.
         /// Follow the [logging tutorial](DOCS_BASE_URL_PLACEHOLDER/tutorials/logging-vector-aggregator)
@@ -280,15 +279,33 @@ impl HasStatusCondition for v1alpha1::AirflowCluster {
 }
 
 impl v1alpha1::AirflowCluster {
+    /// The name of the group-listener provided for a specific role-group.
+    /// Webservers will use this group listener so that only one load balancer
+    /// is needed (per role group).
+    pub fn group_listener_name(
+        &self,
+        role: &AirflowRole,
+        rolegroup: &RoleGroupRef<Self>,
+    ) -> Option<String> {
+        match role {
+            AirflowRole::Webserver => Some(rolegroup.object_name()),
+            AirflowRole::Scheduler | AirflowRole::Worker => None,
+        }
+    }
+
     /// the worker role will not be returned if airflow provisions pods as needed (i.e. when
     /// the kubernetes executor is specified)
-    pub fn get_role(&self, role: &AirflowRole) -> Option<&Role<AirflowConfigFragment>> {
+    pub fn get_role(&self, role: &AirflowRole) -> Option<Role<AirflowConfigFragment>> {
         match role {
-            AirflowRole::Webserver => self.spec.webservers.as_ref(),
-            AirflowRole::Scheduler => self.spec.schedulers.as_ref(),
+            AirflowRole::Webserver => self
+                .spec
+                .webservers
+                .to_owned()
+                .map(extract_role_from_webserver_config),
+            AirflowRole::Scheduler => self.spec.schedulers.to_owned(),
             AirflowRole::Worker => {
                 if let AirflowExecutor::CeleryExecutor { config } = &self.spec.executor {
-                    Some(config)
+                    Some(config.clone())
                 } else {
                     None
                 }
@@ -296,8 +313,8 @@ impl v1alpha1::AirflowCluster {
         }
     }
 
-    pub fn role_config(&self, role: &AirflowRole) -> Option<&GenericRoleConfig> {
-        self.get_role(role).map(|r| &r.role_config)
+    pub fn role_config(&self, role: &AirflowRole) -> Option<GenericRoleConfig> {
+        self.get_role(role).map(|r| r.role_config)
     }
 
     pub fn volumes(&self) -> &Vec<Volume> {
@@ -345,13 +362,12 @@ impl v1alpha1::AirflowCluster {
 
         let role = match role {
             AirflowRole::Webserver => {
-                self.spec
-                    .webservers
-                    .as_ref()
-                    .context(UnknownAirflowRoleSnafu {
+                &extract_role_from_webserver_config(self.spec.webservers.to_owned().context(
+                    UnknownAirflowRoleSnafu {
                         role: role.to_string(),
                         roles: AirflowRole::roles(),
-                    })?
+                    },
+                )?)
             }
             AirflowRole::Worker => {
                 if let AirflowExecutor::CeleryExecutor { config } = &self.spec.executor {
@@ -393,6 +409,34 @@ impl v1alpha1::AirflowCluster {
         fragment::validate(conf_rolegroup).context(FragmentValidationFailureSnafu)
     }
 
+    pub fn merged_listener_class(
+        &self,
+        role: &AirflowRole,
+        rolegroup_name: &String,
+    ) -> Option<String> {
+        if role == &AirflowRole::Webserver {
+            if let Some(webservers) = self.spec.webservers.as_ref() {
+                let conf_defaults = Some("cluster-internal".to_string());
+                let mut conf_role = webservers.config.config.listener_class.to_owned();
+                let mut conf_rolegroup = webservers
+                    .role_groups
+                    .get(rolegroup_name)
+                    .map(|rg| rg.config.config.listener_class.clone())
+                    .unwrap_or_default();
+
+                conf_role.merge(&conf_defaults);
+                conf_rolegroup.merge(&conf_role);
+
+                tracing::debug!("Merged listener-class: {:?}", conf_rolegroup);
+                conf_rolegroup
+            } else {
+                None
+            }
+        } else {
+            None
+        }
+    }
+
     /// Retrieve and merge resource configs for the executor template
     pub fn merged_executor_config(
         &self,
@@ -419,6 +463,39 @@ impl v1alpha1::AirflowCluster {
     }
 }
 
+fn extract_role_from_webserver_config(
+    fragment: Role<WebserverConfigFragment>,
+) -> Role<AirflowConfigFragment> {
+    Role {
+        config: CommonConfiguration {
+            config: fragment.config.config.airflow_config,
+            config_overrides: fragment.config.config_overrides,
+            env_overrides: fragment.config.env_overrides,
+            cli_overrides: fragment.config.cli_overrides,
+            pod_overrides: fragment.config.pod_overrides,
+            product_specific_common_config: fragment.config.product_specific_common_config,
+        },
+        role_config: fragment.role_config,
+        role_groups: fragment
+            .role_groups
+            .into_iter()
+            .map(|(k, v)| {
+                (k, RoleGroup {
+                    config: CommonConfiguration {
+                        config: v.config.config.airflow_config,
+                        config_overrides: v.config.config_overrides,
+                        env_overrides: v.config.env_overrides,
+                        cli_overrides: v.config.cli_overrides,
+                        pod_overrides: v.config.pod_overrides,
+                        product_specific_common_config: v.config.product_specific_common_config,
+                    },
+                    replicas: v.replicas,
+                })
+            })
+            .collect(),
+    }
+}
+
 #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
 #[serde(rename_all = "camelCase")]
 pub struct AirflowAuthorization {
@@ -435,31 +512,6 @@ pub struct AirflowOpaConfig {
     pub cache: UserInformationCache,
 }
 
-// TODO: Temporary solution until listener-operator is finished
-#[derive(Clone, Debug, Default, Display, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
-#[serde(rename_all = "PascalCase")]
-pub enum CurrentlySupportedListenerClasses {
-    #[default]
-    #[serde(rename = "cluster-internal")]
-    ClusterInternal,
-
-    #[serde(rename = "external-unstable")]
-    ExternalUnstable,
-
-    #[serde(rename = "external-stable")]
-    ExternalStable,
-}
-
-impl CurrentlySupportedListenerClasses {
-    pub fn k8s_service_type(&self) -> String {
-        match self {
-            CurrentlySupportedListenerClasses::ClusterInternal => "ClusterIP".to_string(),
-            CurrentlySupportedListenerClasses::ExternalUnstable => "NodePort".to_string(),
-            CurrentlySupportedListenerClasses::ExternalStable => "LoadBalancer".to_string(),
-        }
-    }
-}
-
 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
 #[serde(rename_all = "camelCase")]
 pub struct AirflowCredentials {
@@ -610,7 +662,7 @@ impl AirflowRole {
     /// created as services.
     pub fn get_http_port(&self) -> Option<u16> {
         match &self {
-            AirflowRole::Webserver => Some(8080),
+            AirflowRole::Webserver => Some(HTTP_PORT),
             AirflowRole::Scheduler => None,
             AirflowRole::Worker => None,
         }
@@ -741,6 +793,29 @@ pub struct AirflowConfig {
     pub graceful_shutdown_timeout: Option<Duration>,
 }
 
+#[derive(Clone, Debug, Default, Fragment, JsonSchema, PartialEq)]
+#[fragment_attrs(
+    derive(
+        Clone,
+        Debug,
+        Default,
+        Deserialize,
+        Merge,
+        JsonSchema,
+        PartialEq,
+        Serialize
+    ),
+    serde(rename_all = "camelCase")
+)]
+pub struct WebserverConfig {
+    #[fragment_attrs(serde(default, flatten))]
+    pub airflow_config: AirflowConfig,
+
+    /// This field controls which [ListenerClass](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listenerclass.html) is used to expose the webserver.
+    #[serde(default)]
+    pub listener_class: String,
+}
+
 impl AirflowConfig {
     pub const CREDENTIALS_SECRET_PROPERTY: &'static str = "credentialsSecret";
     pub const GIT_CREDENTIALS_SECRET_PROPERTY: &'static str = "gitCredentialsSecret";
diff --git a/tests/templates/kuttl/commons/metrics.py b/tests/templates/kuttl/commons/metrics.py
index e0fb5eff..866535f9 100755
--- a/tests/templates/kuttl/commons/metrics.py
+++ b/tests/templates/kuttl/commons/metrics.py
@@ -13,10 +13,12 @@ def exception_handler(exception_type, exception, traceback):
 
 
 def assert_metric(role, role_group, metric):
-    metric_response = requests.get(f"http://airflow-{role}-{role_group}:9102/metrics")
-    assert (
-        metric_response.status_code == 200
-    ), f"Metrics could not be retrieved from the {role}-{role_group}."
+    metric_response = requests.get(
+        f"http://airflow-{role}-{role_group}-metrics:9102/metrics"
+    )
+    assert metric_response.status_code == 200, (
+        f"Metrics could not be retrieved from the {role}-{role_group}."
+    )
     return metric in metric_response.text
 
 
diff --git a/tests/templates/kuttl/external-access/00-patch-ns.yaml.j2 b/tests/templates/kuttl/external-access/00-patch-ns.yaml.j2
new file mode 100644
index 00000000..67185acf
--- /dev/null
+++ b/tests/templates/kuttl/external-access/00-patch-ns.yaml.j2
@@ -0,0 +1,9 @@
+{% if test_scenario['values']['openshift'] == 'true' %}
+# see https://github.com/stackabletech/issues/issues/566
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestStep
+commands:
+  - script: kubectl patch namespace $NAMESPACE -p '{"metadata":{"labels":{"pod-security.kubernetes.io/enforce":"privileged"}}}'
+    timeout: 120
+{% endif %}
diff --git a/tests/templates/kuttl/external-access/10-install-postgresql.yaml b/tests/templates/kuttl/external-access/10-install-postgresql.yaml
new file mode 100644
index 00000000..9e0529d1
--- /dev/null
+++ b/tests/templates/kuttl/external-access/10-install-postgresql.yaml
@@ -0,0 +1,12 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestStep
+commands:
+  - script: >-
+      helm install airflow-postgresql
+      --namespace $NAMESPACE
+      --version 16.4.2
+      -f helm-bitnami-postgresql-values.yaml
+      oci://registry-1.docker.io/bitnamicharts/postgresql
+      --wait
+    timeout: 600
diff --git a/tests/templates/kuttl/external-access/20-assert.yaml b/tests/templates/kuttl/external-access/20-assert.yaml
new file mode 100644
index 00000000..0ae1697d
--- /dev/null
+++ b/tests/templates/kuttl/external-access/20-assert.yaml
@@ -0,0 +1,30 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-airflow-postgresql
+timeout: 480
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-postgresql
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-redis-master
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-redis-replicas
+status:
+  readyReplicas: 1
+  replicas: 1
diff --git a/tests/templates/kuttl/external-access/20-install-redis.yaml b/tests/templates/kuttl/external-access/20-install-redis.yaml
new file mode 100644
index 00000000..cc1edc53
--- /dev/null
+++ b/tests/templates/kuttl/external-access/20-install-redis.yaml
@@ -0,0 +1,12 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestStep
+commands:
+  - script: >-
+      helm install airflow-redis
+      --namespace $NAMESPACE
+      --version 17.11.3
+      -f helm-bitnami-redis-values.yaml
+      --repo https://charts.bitnami.com/bitnami redis
+      --wait
+    timeout: 600
diff --git a/tests/templates/kuttl/external-access/30-listener-classes.yaml b/tests/templates/kuttl/external-access/30-listener-classes.yaml
new file mode 100644
index 00000000..893032c5
--- /dev/null
+++ b/tests/templates/kuttl/external-access/30-listener-classes.yaml
@@ -0,0 +1,6 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestStep
+commands:
+  - script: |
+      envsubst < listener-classes.yaml | kubectl apply -n $NAMESPACE -f -
diff --git a/tests/templates/kuttl/external-access/40-assert.yaml b/tests/templates/kuttl/external-access/40-assert.yaml
new file mode 100644
index 00000000..b7a9f06f
--- /dev/null
+++ b/tests/templates/kuttl/external-access/40-assert.yaml
@@ -0,0 +1,114 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-available-condition
+timeout: 600
+commands:
+  - script: kubectl -n $NAMESPACE wait --for=condition=available airflowclusters.airflow.stackable.tech/airflow --timeout 301s
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-airflow-cluster
+timeout: 1200
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-external-unstable
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-cluster-internal
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-worker-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 300
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-scheduler-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-webserver
+status:
+  expectedPods: 4
+  currentHealthy: 4
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-worker
+status:
+  expectedPods: 2
+  currentHealthy: 2
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-scheduler
+status:
+  expectedPods: 1
+  currentHealthy: 1
+  disruptionsAllowed: 1
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-cluster-internal
+spec:
+  type: ClusterIP # cluster-internal
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-default
+spec:
+  type: NodePort # external-stable
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-external-unstable
+spec:
+  type: NodePort # external-unstable
diff --git a/tests/templates/kuttl/external-access/40-install-airflow-cluster.yaml b/tests/templates/kuttl/external-access/40-install-airflow-cluster.yaml
new file mode 100644
index 00000000..9da44e7e
--- /dev/null
+++ b/tests/templates/kuttl/external-access/40-install-airflow-cluster.yaml
@@ -0,0 +1,8 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestStep
+timeout: 600
+commands:
+  - script: >
+      envsubst < install-airflow-cluster.yaml |
+      kubectl apply -n $NAMESPACE -f -
diff --git a/tests/templates/kuttl/external-access/helm-bitnami-postgresql-values.yaml.j2 b/tests/templates/kuttl/external-access/helm-bitnami-postgresql-values.yaml.j2
new file mode 100644
index 00000000..f1320d2d
--- /dev/null
+++ b/tests/templates/kuttl/external-access/helm-bitnami-postgresql-values.yaml.j2
@@ -0,0 +1,30 @@
+---
+volumePermissions:
+  enabled: false
+  securityContext:
+    runAsUser: auto
+
+primary:
+  podSecurityContext:
+{% if test_scenario['values']['openshift'] == 'true' %}
+    enabled: false
+{% else %}
+    enabled: true
+{% endif %}
+  containerSecurityContext:
+    enabled: false
+  resources:
+    requests:
+      memory: "128Mi"
+      cpu: "100m"
+    limits:
+      memory: "128Mi"
+      cpu: "400m"
+shmVolume:
+  chmod:
+    enabled: false
+
+auth:
+  username: airflow
+  password: airflow
+  database: airflow
diff --git a/tests/templates/kuttl/external-access/helm-bitnami-redis-values.yaml.j2 b/tests/templates/kuttl/external-access/helm-bitnami-redis-values.yaml.j2
new file mode 100644
index 00000000..d920abc5
--- /dev/null
+++ b/tests/templates/kuttl/external-access/helm-bitnami-redis-values.yaml.j2
@@ -0,0 +1,43 @@
+---
+volumePermissions:
+  enabled: false
+  containerSecurityContext:
+    runAsUser: auto
+
+master:
+  podSecurityContext:
+{% if test_scenario['values']['openshift'] == 'true' %}
+    enabled: false
+{% else %}
+    enabled: true
+{% endif %}
+  containerSecurityContext:
+    enabled: false
+  resources:
+    requests:
+      memory: "128Mi"
+      cpu: "200m"
+    limits:
+      memory: "128Mi"
+      cpu: "800m"
+
+replica:
+  replicaCount: 1
+  podSecurityContext:
+{% if test_scenario['values']['openshift'] == 'true' %}
+    enabled: false
+{% else %}
+    enabled: true
+{% endif %}
+  containerSecurityContext:
+    enabled: false
+  resources:
+    requests:
+      memory: "128Mi"
+      cpu: "100m"
+    limits:
+      memory: "128Mi"
+      cpu: "400m"
+
+auth:
+  password: redis
diff --git a/tests/templates/kuttl/external-access/install-airflow-cluster.yaml.j2 b/tests/templates/kuttl/external-access/install-airflow-cluster.yaml.j2
new file mode 100644
index 00000000..d18d6c57
--- /dev/null
+++ b/tests/templates/kuttl/external-access/install-airflow-cluster.yaml.j2
@@ -0,0 +1,55 @@
+---
+apiVersion: v1
+kind: Secret
+metadata:
+  name: test-airflow-credentials
+type: Opaque
+stringData:
+  adminUser.username: airflow
+  adminUser.firstname: Airflow
+  adminUser.lastname: Admin
+  adminUser.email: airflow@airflow.com
+  adminUser.password: airflow
+  connections.secretKey: thisISaSECRET_1234
+  connections.sqlalchemyDatabaseUri: postgresql+psycopg2://airflow:airflow@airflow-postgresql/airflow
+  connections.celeryResultBackend: db+postgresql://airflow:airflow@airflow-postgresql/airflow
+  connections.celeryBrokerUrl: redis://:redis@airflow-redis-master:6379/0
+---
+apiVersion: airflow.stackable.tech/v1alpha1
+kind: AirflowCluster
+metadata:
+  name: airflow
+spec:
+  image:
+{% if test_scenario['values']['airflow'].find(",") > 0 %}
+    custom: "{{ test_scenario['values']['airflow'].split(',')[1] }}"
+    productVersion: "{{ test_scenario['values']['airflow'].split(',')[0] }}"
+{% else %}
+    productVersion: "{{ test_scenario['values']['airflow'] }}"
+{% endif %}
+    pullPolicy: IfNotPresent
+  clusterConfig:
+    loadExamples: true
+    credentialsSecret: test-airflow-credentials
+  webservers:
+    config:
+      listenerClass: test-external-stable-$NAMESPACE
+    roleGroups:
+      default:
+        replicas: 2
+      external-unstable:
+        replicas: 1
+        config:
+          listenerClass: test-external-unstable-$NAMESPACE
+      cluster-internal:
+        replicas: 1
+        config:
+          listenerClass: test-cluster-internal-$NAMESPACE
+  celeryExecutors:
+    roleGroups:
+      default:
+        replicas: 2
+  schedulers:
+    roleGroups:
+      default:
+        replicas: 1
diff --git a/tests/templates/kuttl/external-access/listener-classes.yaml b/tests/templates/kuttl/external-access/listener-classes.yaml
new file mode 100644
index 00000000..4131526a
--- /dev/null
+++ b/tests/templates/kuttl/external-access/listener-classes.yaml
@@ -0,0 +1,21 @@
+---
+apiVersion: listeners.stackable.tech/v1alpha1
+kind: ListenerClass
+metadata:
+  name: test-cluster-internal-$NAMESPACE
+spec:
+  serviceType: ClusterIP
+---
+apiVersion: listeners.stackable.tech/v1alpha1
+kind: ListenerClass
+metadata:
+  name: test-external-stable-$NAMESPACE
+spec:
+  serviceType: NodePort
+---
+apiVersion: listeners.stackable.tech/v1alpha1
+kind: ListenerClass
+metadata:
+  name: test-external-unstable-$NAMESPACE
+spec:
+  serviceType: NodePort
diff --git a/tests/templates/kuttl/mount-dags-gitsync/dag_metrics.py b/tests/templates/kuttl/mount-dags-gitsync/dag_metrics.py
index bc5ff92a..670bbd53 100755
--- a/tests/templates/kuttl/mount-dags-gitsync/dag_metrics.py
+++ b/tests/templates/kuttl/mount-dags-gitsync/dag_metrics.py
@@ -7,10 +7,12 @@
 
 
 def assert_metric(role, metric):
-    metric_response = requests.get(f"http://airflow-{role}-default:9102/metrics")
-    assert (
-        metric_response.status_code == 200
-    ), f"Metrics could not be retrieved from the {role}."
+    metric_response = requests.get(
+        f"http://airflow-{role}-default-metrics:9102/metrics"
+    )
+    assert metric_response.status_code == 200, (
+        f"Metrics could not be retrieved from the {role}."
+    )
     return metric in metric_response.text
 
 
diff --git a/tests/templates/kuttl/oidc/login.py b/tests/templates/kuttl/oidc/login.py
index e0e22a70..aa859803 100644
--- a/tests/templates/kuttl/oidc/login.py
+++ b/tests/templates/kuttl/oidc/login.py
@@ -10,9 +10,10 @@
 )
 
 session = requests.Session()
+url = "http://airflow-webserver-default:8080"
 
 # Click on "Sign In with keycloak" in Airflow
-login_page = session.get("http://airflow-webserver:8080/login/keycloak?next=")
+login_page = session.get(f"{url}/login/keycloak?next=")
 
 assert login_page.ok, "Redirection from Airflow to Keycloak failed"
 assert login_page.url.startswith(
@@ -27,32 +28,32 @@
 )
 
 assert welcome_page.ok, "Login failed"
-assert (
-    welcome_page.url == "http://airflow-webserver:8080/home"
-), "Redirection to the Airflow home page expected"
+assert welcome_page.url == f"{url}/home", (
+    "Redirection to the Airflow home page expected"
+)
 
 # Open the user information page in Airflow
-userinfo_page = session.get("http://airflow-webserver:8080/users/userinfo/")
+userinfo_page = session.get(f"{url}/users/userinfo/")
 
 assert userinfo_page.ok, "Retrieving user information failed"
-assert (
-    userinfo_page.url == "http://airflow-webserver:8080/users/userinfo/"
-), "Redirection to the Airflow user info page expected"
+assert userinfo_page.url == f"{url}/users/userinfo/", (
+    "Redirection to the Airflow user info page expected"
+)
 
 # Expect the user data provided by Keycloak in Airflow
 userinfo_page_html = BeautifulSoup(userinfo_page.text, "html.parser")
 table_rows = userinfo_page_html.find_all("tr")
 user_data = {tr.find("th").text: tr.find("td").text for tr in table_rows}
 
-assert (
-    user_data["First Name"] == "Jane"
-), "The first name of the user in Airflow should match the one provided by Keycloak"
-assert (
-    user_data["Last Name"] == "Doe"
-), "The last name of the user in Airflow should match the one provided by Keycloak"
-assert (
-    user_data["Email"] == "jane.doe@stackable.tech"
-), "The email of the user in Airflow should match the one provided by Keycloak"
+assert user_data["First Name"] == "Jane", (
+    "The first name of the user in Airflow should match the one provided by Keycloak"
+)
+assert user_data["Last Name"] == "Doe", (
+    "The last name of the user in Airflow should match the one provided by Keycloak"
+)
+assert user_data["Email"] == "jane.doe@stackable.tech", (
+    "The email of the user in Airflow should match the one provided by Keycloak"
+)
 
 # Later this can be extended to use different OIDC providers (currently only Keycloak is
 # supported)
diff --git a/tests/templates/kuttl/opa/41_check-authorization.py b/tests/templates/kuttl/opa/41_check-authorization.py
index d6cd11d4..d86502dc 100644
--- a/tests/templates/kuttl/opa/41_check-authorization.py
+++ b/tests/templates/kuttl/opa/41_check-authorization.py
@@ -26,10 +26,12 @@
     "password": "NvfpU518",
 }
 
+url = "http://airflow-webserver-default:8080"
+
 
 def create_user(user):
     requests.post(
-        "http://airflow-webserver:8080/auth/fab/v1/users",
+        f"{url}/auth/fab/v1/users",
         auth=("airflow", "airflow"),
         json=user,
     )
@@ -38,7 +40,7 @@ def create_user(user):
 def check_api_authorization_for_user(
     user, expected_status_code, method, endpoint, data=None, api="api/v1"
 ):
-    api_url = f"http://airflow-webserver:8080/{api}"
+    api_url = f"{url}/{api}"
 
     auth = (user["username"], user["password"])
     response = requests.request(method, f"{api_url}/{endpoint}", auth=auth, json=data)
@@ -59,18 +61,16 @@ def check_website_authorization_for_user(user, expected_status_code):
     password = user["password"]
     with requests.Session() as session:
         login_response = session.post(
-            "http://airflow-webserver:8080/login/",
+            f"{url}/login/",
             data=f"username={username}&password={password}",
             allow_redirects=False,
             headers={"Content-Type": "application/x-www-form-urlencoded"},
         )
         assert login_response.ok, f"Login for {username} failed"
-        home_response = session.get(
-            "http://airflow-webserver:8080/home", allow_redirects=False
+        home_response = session.get(f"{url}/home", allow_redirects=False)
+        assert home_response.status_code == expected_status_code, (
+            f"GET /home returned status code {home_response.status_code}, but {expected_status_code} was expected."
         )
-        assert (
-            home_response.status_code == expected_status_code
-        ), f"GET /home returned status code {home_response.status_code}, but {expected_status_code} was expected."
 
 
 def test_is_authorized_configuration():
diff --git a/tests/templates/kuttl/orphaned-resources/50-assert.yaml b/tests/templates/kuttl/orphaned-resources/50-assert.yaml
index 050e881d..1272ebc6 100644
--- a/tests/templates/kuttl/orphaned-resources/50-assert.yaml
+++ b/tests/templates/kuttl/orphaned-resources/50-assert.yaml
@@ -23,4 +23,4 @@ metadata:
 apiVersion: v1
 kind: Service
 metadata:
-  name: airflow-worker-newrolegroup
+  name: airflow-worker-newrolegroup-metrics
diff --git a/tests/templates/kuttl/orphaned-resources/50-errors.yaml b/tests/templates/kuttl/orphaned-resources/50-errors.yaml
index 06b33ec1..1da89a7d 100644
--- a/tests/templates/kuttl/orphaned-resources/50-errors.yaml
+++ b/tests/templates/kuttl/orphaned-resources/50-errors.yaml
@@ -22,4 +22,4 @@ metadata:
 apiVersion: v1
 kind: Service
 metadata:
-  name: airflow-worker-default
+  name: airflow-worker-default-metrics
diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml
index 82f11035..73e8948e 100644
--- a/tests/test-definition.yaml
+++ b/tests/test-definition.yaml
@@ -83,6 +83,10 @@ tests:
     dimensions:
       - airflow-latest
       - openshift
+  - name: external-access
+    dimensions:
+      - airflow
+      - openshift
 suites:
   - name: nightly
     # Run nightly with the latest airflow


Reviews:
user: login: maltesander
body: First batch, tests work fine, will do some more manual testing.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-16T13:06:38Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T13:30:04Z
user: login: maltesander
body: Tested and works. Just some more nitpicking / clarification.
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-22T08:35:32Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T09:03:37Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T09:03:43Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T09:03:48Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T09:03:53Z
user: login: maltesander
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T13:40:39Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T13:42:46Z
user: login: nightkr
body: (Not a complete review, but a few notes..)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T11:16:34Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T12:40:31Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T15:42:15Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T15:46:06Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T16:27:29Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T08:48:57Z
user: login: nightkr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T09:24:12Z
user: login: nightkr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T09:25:48Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T10:02:51Z
user: login: nightkr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T10:22:28Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T11:19:19Z
user: login: nightkr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T10:41:15Z
user: login: sbernauer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T12:21:33Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T12:40:11Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T12:40:20Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T12:40:27Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T12:45:05Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T12:49:00Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T12:53:13Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T13:12:01Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T13:12:58Z
user: login: sbernauer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T13:48:06Z
user: login: nightkr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:52:31Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T13:57:33Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T13:58:40Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T14:00:01Z
user: login: nightkr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T14:07:22Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T14:28:40Z
user: login: nightkr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T14:32:45Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T15:21:52Z
user: login: sbernauer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T17:36:18Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-30T06:53:52Z
user: login: sbernauer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-30T07:00:00Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-30T07:04:32Z
user: login: sbernauer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-30T07:24:49Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-30T08:19:39Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T09:28:27Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T09:49:07Z
user: login: sbernauer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T06:55:33Z
user: login: nightkr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T11:19:29Z
user: login: nightkr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T11:19:37Z
user: login: nightkr
body: LGTM if tests pass
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T12:02:56Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T12:23:50Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T12:23:57Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T12:24:04Z
user: login: adwk67
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T12:24:15Z

Review comments:
diff_hunk: @@ -0,0 +1,99 @@
+---
+apiVersion: batch/v1
+kind: Job
+metadata:
+  name: access-airflow
+spec:
+  template:
+    spec:
+      serviceAccountName: test-sa
+      containers:
+        - name: access-airflow
+{% if test_scenario['values']['airflow'].find(",") > 0 %}
+          image: "{{ test_scenario['values']['airflow'].split(',')[1] }}"
+{% else %}
+          image: oci.stackable.tech/sdp/airflow:{{ test_scenario['values']['airflow'] }}-stackable0.0.0-dev
+{% endif %}
+          imagePullPolicy: IfNotPresent
+          command:
+            - /bin/bash
+            - /tmp/script/script.sh
+          env:
+            - name: SCHEDULER_METRICS
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-scheduler-default-0.metrics
+            - name: WORKER_0_METRICS
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-worker-default-0.metrics
+            - name: WORKER_1_METRICS
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-worker-default-1.metrics
+            - name: WEBSERVER_DEFAULT_HTTP
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-webserver-default-0.http
+            - name: WEBSERVER_DEFAULT_METRICS
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-webserver-default-0.metrics
+            - name: WEBSERVER_EXTERNAL_HTTP
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-webserver-external-unstable-0.http
+            - name: WEBSERVER_EXTERNAL_METRICS
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-webserver-external-unstable-0.metrics
+          volumeMounts:
+            - name: script
+              mountPath: /tmp/script
+      volumes:
+        - name: script
+          configMap:
+            name: access-airflow-script
+      securityContext:
+        fsGroup: 1000
+        runAsGroup: 1000
+        runAsUser: 1000
+      restartPolicy: OnFailure
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: access-airflow-script
+data:
+  script.sh: |
+    set -euxo pipefail
+
+    echo "Attempting to reach master at $SCHEDULER_METRICS..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${SCHEDULER_METRICS}" | grep 200
+
+    echo "Attempting to reach master at $WORKER_0_METRICS..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${WORKER_0_METRICS}" | grep 200
+
+    echo "Attempting to reach region-server at $WORKER_1_METRICS..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${WORKER_1_METRICS}" | grep 200
+
+    echo "Attempting to reach rest-server at $WEBSERVER_DEFAULT_HTTP..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${WEBSERVER_DEFAULT_HTTP}" | grep 200
+
+    echo "Attempting to reach rest-server at $WEBSERVER_DEFAULT_METRICS..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${WEBSERVER_DEFAULT_METRICS}" | grep 200
+
+    echo "Attempting to reach rest-server at $WEBSERVER_EXTERNAL_HTTP..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${WEBSERVER_EXTERNAL_HTTP}" | grep 200
+
+    echo "Attempting to reach rest-server at $WEBSERVER_EXTERNAL_METRICS..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${WEBSERVER_EXTERNAL_METRICS}" | grep 200
path: tests/templates/kuttl/external-access/50-access-airflow.txt.j2
user: login: maltesander
body: "rest-server", "region-server", "master" should be airflowrized please :)
created_at: 2025-04-16T11:25:42Z
author_association: MEMBER
diff_hunk: @@ -739,6 +922,10 @@ pub struct AirflowConfig {
     /// Time period Pods have to gracefully shut down, e.g. `30m`, `1h` or `2d`. Consult the operator documentation for details.
     #[fragment_attrs(serde(default))]
     pub graceful_shutdown_timeout: Option<Duration>,
+
+    /// This field controls which [ListenerClass](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listenerclass.html) is used to expose the webserver.
path: rust/operator-binary/src/crd/mod.rs
user: login: maltesander
body: ```suggestion
    /// This field controls which [ListenerClass](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listenerclass.html) is used to expose the airflow services.
```
created_at: 2025-04-16T11:30:08Z
author_association: MEMBER
diff_hunk: @@ -417,6 +468,134 @@ impl v1alpha1::AirflowCluster {
         tracing::debug!("Merged executor config: {:?}", conf_executor);
         fragment::validate(conf_executor).context(FragmentValidationFailureSnafu)
     }
+
+    pub fn rolegroup_ref(
+        &self,
+        role_name: impl Into<String>,
+        group_name: impl Into<String>,
+    ) -> RoleGroupRef<v1alpha1::AirflowCluster> {
+        RoleGroupRef {
+            cluster: ObjectRef::from_obj(self),
+            role: role_name.into(),
+            role_group: group_name.into(),
+        }
+    }
+
+    pub fn rolegroup_ref_and_replicas(
+        &self,
+        role: &AirflowRole,
+    ) -> Vec<(RoleGroupRef<v1alpha1::AirflowCluster>, u16)> {
+        match role {
+            AirflowRole::Webserver => self
+                .spec
+                .webservers
+                .iter()
+                .flat_map(|role| &role.role_groups)
+                // Order rolegroups consistently, to avoid spurious downstream rewrites
+                .collect::<BTreeMap<_, _>>()
+                .into_iter()
+                .filter(|(rolegroup_name, _)| {
+                    self.resolved_listener_class_discoverable(role, rolegroup_name)
+                })
+                .map(|(rolegroup_name, role_group)| {
+                    (
+                        self.rolegroup_ref(AirflowRole::Webserver.to_string(), rolegroup_name),
path: rust/operator-binary/src/crd/mod.rs
user: login: maltesander
body: ```suggestion
                        self.rolegroup_ref(role.to_string(), rolegroup_name),
```
created_at: 2025-04-16T13:05:09Z
author_association: MEMBER
diff_hunk: @@ -417,6 +468,134 @@ impl v1alpha1::AirflowCluster {
         tracing::debug!("Merged executor config: {:?}", conf_executor);
         fragment::validate(conf_executor).context(FragmentValidationFailureSnafu)
     }
+
+    pub fn rolegroup_ref(
+        &self,
+        role_name: impl Into<String>,
+        group_name: impl Into<String>,
+    ) -> RoleGroupRef<v1alpha1::AirflowCluster> {
+        RoleGroupRef {
+            cluster: ObjectRef::from_obj(self),
+            role: role_name.into(),
+            role_group: group_name.into(),
+        }
+    }
+
+    pub fn rolegroup_ref_and_replicas(
+        &self,
+        role: &AirflowRole,
+    ) -> Vec<(RoleGroupRef<v1alpha1::AirflowCluster>, u16)> {
+        match role {
+            AirflowRole::Webserver => self
+                .spec
+                .webservers
+                .iter()
+                .flat_map(|role| &role.role_groups)
+                // Order rolegroups consistently, to avoid spurious downstream rewrites
+                .collect::<BTreeMap<_, _>>()
+                .into_iter()
+                .filter(|(rolegroup_name, _)| {
+                    self.resolved_listener_class_discoverable(role, rolegroup_name)
+                })
+                .map(|(rolegroup_name, role_group)| {
+                    (
+                        self.rolegroup_ref(AirflowRole::Webserver.to_string(), rolegroup_name),
+                        role_group.replicas.unwrap_or_default(),
+                    )
+                })
+                .collect(),
+            AirflowRole::Scheduler => self
+                .spec
+                .schedulers
+                .iter()
+                .flat_map(|role| &role.role_groups)
+                // Order rolegroups consistently, to avoid spurious downstream rewrites
+                .collect::<BTreeMap<_, _>>()
+                .into_iter()
+                .filter(|(rolegroup_name, _)| {
+                    self.resolved_listener_class_discoverable(role, rolegroup_name)
+                })
+                .map(|(rolegroup_name, role_group)| {
+                    (
+                        self.rolegroup_ref(AirflowRole::Scheduler.to_string(), rolegroup_name),
path: rust/operator-binary/src/crd/mod.rs
user: login: maltesander
body: ```suggestion
                        self.rolegroup_ref(role.to_string(), rolegroup_name),
```
created_at: 2025-04-16T13:05:30Z
author_association: MEMBER
diff_hunk: @@ -417,6 +468,134 @@ impl v1alpha1::AirflowCluster {
         tracing::debug!("Merged executor config: {:?}", conf_executor);
         fragment::validate(conf_executor).context(FragmentValidationFailureSnafu)
     }
+
+    pub fn rolegroup_ref(
+        &self,
+        role_name: impl Into<String>,
+        group_name: impl Into<String>,
+    ) -> RoleGroupRef<v1alpha1::AirflowCluster> {
+        RoleGroupRef {
+            cluster: ObjectRef::from_obj(self),
+            role: role_name.into(),
+            role_group: group_name.into(),
+        }
+    }
+
+    pub fn rolegroup_ref_and_replicas(
+        &self,
+        role: &AirflowRole,
+    ) -> Vec<(RoleGroupRef<v1alpha1::AirflowCluster>, u16)> {
+        match role {
+            AirflowRole::Webserver => self
+                .spec
+                .webservers
+                .iter()
+                .flat_map(|role| &role.role_groups)
+                // Order rolegroups consistently, to avoid spurious downstream rewrites
+                .collect::<BTreeMap<_, _>>()
+                .into_iter()
+                .filter(|(rolegroup_name, _)| {
+                    self.resolved_listener_class_discoverable(role, rolegroup_name)
+                })
+                .map(|(rolegroup_name, role_group)| {
+                    (
+                        self.rolegroup_ref(AirflowRole::Webserver.to_string(), rolegroup_name),
+                        role_group.replicas.unwrap_or_default(),
+                    )
+                })
+                .collect(),
+            AirflowRole::Scheduler => self
+                .spec
+                .schedulers
+                .iter()
+                .flat_map(|role| &role.role_groups)
+                // Order rolegroups consistently, to avoid spurious downstream rewrites
+                .collect::<BTreeMap<_, _>>()
+                .into_iter()
+                .filter(|(rolegroup_name, _)| {
+                    self.resolved_listener_class_discoverable(role, rolegroup_name)
+                })
+                .map(|(rolegroup_name, role_group)| {
+                    (
+                        self.rolegroup_ref(AirflowRole::Scheduler.to_string(), rolegroup_name),
+                        role_group.replicas.unwrap_or_default(),
+                    )
+                })
+                .collect(),
+            AirflowRole::Worker => {
+                if let AirflowExecutor::CeleryExecutor { config } = &self.spec.executor {
+                    config
+                        .role_groups
+                        .iter()
+                        // Order rolegroups consistently, to avoid spurious downstream rewrites
+                        .collect::<BTreeMap<_, _>>()
+                        .into_iter()
+                        .filter(|(rolegroup_name, _)| {
+                            self.resolved_listener_class_discoverable(role, rolegroup_name)
+                        })
+                        .map(|(rolegroup_name, role_group)| {
+                            (
+                                self.rolegroup_ref(AirflowRole::Worker.to_string(), rolegroup_name),
path: rust/operator-binary/src/crd/mod.rs
user: login: maltesander
body: ```suggestion
                                self.rolegroup_ref(role.to_string(), rolegroup_name),
```
created_at: 2025-04-16T13:05:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,99 @@
+---
+apiVersion: batch/v1
+kind: Job
+metadata:
+  name: access-airflow
+spec:
+  template:
+    spec:
+      serviceAccountName: test-sa
+      containers:
+        - name: access-airflow
+{% if test_scenario['values']['airflow'].find(",") > 0 %}
+          image: "{{ test_scenario['values']['airflow'].split(',')[1] }}"
+{% else %}
+          image: oci.stackable.tech/sdp/airflow:{{ test_scenario['values']['airflow'] }}-stackable0.0.0-dev
+{% endif %}
+          imagePullPolicy: IfNotPresent
+          command:
+            - /bin/bash
+            - /tmp/script/script.sh
+          env:
+            - name: SCHEDULER_METRICS
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-scheduler-default-0.metrics
+            - name: WORKER_0_METRICS
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-worker-default-0.metrics
+            - name: WORKER_1_METRICS
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-worker-default-1.metrics
+            - name: WEBSERVER_DEFAULT_HTTP
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-webserver-default-0.http
+            - name: WEBSERVER_DEFAULT_METRICS
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-webserver-default-0.metrics
+            - name: WEBSERVER_EXTERNAL_HTTP
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-webserver-external-unstable-0.http
+            - name: WEBSERVER_EXTERNAL_METRICS
+              valueFrom:
+                configMapKeyRef:
+                  name: airflow
+                  key: airflow-webserver-external-unstable-0.metrics
+          volumeMounts:
+            - name: script
+              mountPath: /tmp/script
+      volumes:
+        - name: script
+          configMap:
+            name: access-airflow-script
+      securityContext:
+        fsGroup: 1000
+        runAsGroup: 1000
+        runAsUser: 1000
+      restartPolicy: OnFailure
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: access-airflow-script
+data:
+  script.sh: |
+    set -euxo pipefail
+
+    echo "Attempting to reach master at $SCHEDULER_METRICS..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${SCHEDULER_METRICS}" | grep 200
+
+    echo "Attempting to reach master at $WORKER_0_METRICS..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${WORKER_0_METRICS}" | grep 200
+
+    echo "Attempting to reach region-server at $WORKER_1_METRICS..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${WORKER_1_METRICS}" | grep 200
+
+    echo "Attempting to reach rest-server at $WEBSERVER_DEFAULT_HTTP..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${WEBSERVER_DEFAULT_HTTP}" | grep 200
+
+    echo "Attempting to reach rest-server at $WEBSERVER_DEFAULT_METRICS..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${WEBSERVER_DEFAULT_METRICS}" | grep 200
+
+    echo "Attempting to reach rest-server at $WEBSERVER_EXTERNAL_HTTP..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${WEBSERVER_EXTERNAL_HTTP}" | grep 200
+
+    echo "Attempting to reach rest-server at $WEBSERVER_EXTERNAL_METRICS..."
+    curl --retry 0 -f -s -o /dev/null -w "%{http_code}" "${WEBSERVER_EXTERNAL_METRICS}" | grep 200
path: tests/templates/kuttl/external-access/50-access-airflow.txt.j2
user: login: adwk67
body: thanks! acbca3e
created_at: 2025-04-16T13:30:04Z
author_association: MEMBER
diff_hunk: @@ -321,6 +327,27 @@ pub enum Error {
 
     #[snafu(display("failed to build Statefulset environmental variables"))]
     BuildStatefulsetEnvVars { source: env_vars::Error },
+
+    #[snafu(display("failed to build listener volume"))]
+    BuildListenerVolume {
+        source: ListenerOperatorVolumeSourceBuilderError,
+    },
+
+    #[snafu(display("failed to build Labels"))]
+    LabelBuild {
+        source: stackable_operator::kvp::LabelError,
+    },
+
+    #[snafu(display("cannot collect discovery configuration"))]
+    CollectDiscoveryConfig { source: crate::crd::Error },
+
+    #[snafu(display("failed to apply discovery configmap"))]
+    ApplyDiscoveryConfigMap {
+        source: stackable_operator::cluster_resources::Error,
+    },
path: rust/operator-binary/src/airflow_controller.rs
user: login: maltesander
body: Nitpick: this is all a bit inconsistent in capitalization
created_at: 2025-04-16T15:00:27Z
author_association: MEMBER
diff_hunk: @@ -518,17 +539,56 @@ pub async fn reconcile_airflow(
                 })?;
         }
 
+        // if the replicas are changed at the same time as the reconciliation
+        // being paused, it may be possible to have listeners that are *expected*
+        // (according to their replica number) but which are not yet created, so
+        // deactivate this action in such cases.
path: rust/operator-binary/src/airflow_controller.rs
user: login: maltesander
body: Could we add a hint like:
```
If the cluster is stopping / scaling down (which may take a while depending on the graceful shutdown period), the discovery configmap will be empty, but the listeners will exist (and endpoints reachable) until the Pod is gone.
```
created_at: 2025-04-22T08:22:27Z
author_association: MEMBER
diff_hunk: @@ -518,17 +539,56 @@ pub async fn reconcile_airflow(
                 })?;
         }
 
+        // if the replicas are changed at the same time as the reconciliation
+        // being paused, it may be possible to have listeners that are *expected*
+        // (according to their replica number) but which are not yet created, so
+        // deactivate this action in such cases.
+        if airflow.spec.cluster_operation.reconciliation_paused
+            || airflow.spec.cluster_operation.stopped
+        {
+            tracing::info!(
+                "Cluster is in a transitional state so do not attempt to collect listener information that will only be active once cluster has returned to a non-transitional state."
path: rust/operator-binary/src/airflow_controller.rs
user: login: maltesander
body: I would mention what is actually happening, either no reconcilation or the cluster is stopped?
created_at: 2025-04-22T08:23:09Z
author_association: MEMBER
diff_hunk: @@ -965,9 +977,23 @@ fn build_server_rolegroup_statefulset(
         };
         airflow_container.readiness_probe(probe.clone());
         airflow_container.liveness_probe(probe);
-        airflow_container.add_container_port("http", resolved_port.into());
+        airflow_container.add_container_port(HTTP_PORT_NAME, http_port.into());
     }
 
+    let listener_class = &merged_airflow_config.listener_class;
+    // all listeners will use ephemeral volumes as they can/should
+    // be removed when the pods is re-started, and no data needs to be preserved
path: rust/operator-binary/src/airflow_controller.rs
user: login: maltesander
body: ```suggestion
    // be removed when the pods is re-started, and no data needs to be preserved
```
Ephemeral PVCs survive pod restarts (they are not recreated)
created_at: 2025-04-22T08:31:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,104 @@
+use std::{borrow::Cow, collections::HashMap, num::TryFromIntError};
+
+use futures::future::try_join_all;
+use snafu::{OptionExt, ResultExt, Snafu};
+use stackable_operator::{
+    commons::listener::Listener, k8s_openapi::api::core::v1::Pod,
+    kube::runtime::reflector::ObjectRef, utils::cluster_info::KubernetesClusterInfo,
+};
+
+#[derive(Snafu, Debug)]
+pub enum Error {
+    #[snafu(display("unable to get {listener} (for {pod})"))]
+    GetPodListener {
+        source: stackable_operator::client::Error,
+        listener: ObjectRef<Listener>,
+        pod: ObjectRef<Pod>,
+    },
+
+    #[snafu(display("{listener} (for {pod}) has no address"))]
+    PodListenerHasNoAddress {
+        listener: ObjectRef<Listener>,
+        pod: ObjectRef<Pod>,
+    },
+
+    #[snafu(display("port {port} ({port_name:?}) is out of bounds, must be within {range:?}", range = 0..=u16::MAX))]
path: rust/operator-binary/src/crd/utils.rs
user: login: maltesander
body: ```suggestion
    #[snafu(display("port {port} ({port_name:?}) is out of bounds, must be within {range:?}", range = 0..=u16::MAX))]
```
This works for ports < 1024?
created_at: 2025-04-22T08:33:38Z
author_association: MEMBER
diff_hunk: @@ -321,6 +327,27 @@ pub enum Error {
 
     #[snafu(display("failed to build Statefulset environmental variables"))]
     BuildStatefulsetEnvVars { source: env_vars::Error },
+
+    #[snafu(display("failed to build listener volume"))]
+    BuildListenerVolume {
+        source: ListenerOperatorVolumeSourceBuilderError,
+    },
+
+    #[snafu(display("failed to build Labels"))]
+    LabelBuild {
+        source: stackable_operator::kvp::LabelError,
+    },
+
+    #[snafu(display("cannot collect discovery configuration"))]
+    CollectDiscoveryConfig { source: crate::crd::Error },
+
+    #[snafu(display("failed to apply discovery configmap"))]
+    ApplyDiscoveryConfigMap {
+        source: stackable_operator::cluster_resources::Error,
+    },
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: 5c7022c
created_at: 2025-04-22T09:03:37Z
author_association: MEMBER
diff_hunk: @@ -518,17 +539,56 @@ pub async fn reconcile_airflow(
                 })?;
         }
 
+        // if the replicas are changed at the same time as the reconciliation
+        // being paused, it may be possible to have listeners that are *expected*
+        // (according to their replica number) but which are not yet created, so
+        // deactivate this action in such cases.
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: 5c7022c
created_at: 2025-04-22T09:03:43Z
author_association: MEMBER
diff_hunk: @@ -518,17 +539,56 @@ pub async fn reconcile_airflow(
                 })?;
         }
 
+        // if the replicas are changed at the same time as the reconciliation
+        // being paused, it may be possible to have listeners that are *expected*
+        // (according to their replica number) but which are not yet created, so
+        // deactivate this action in such cases.
+        if airflow.spec.cluster_operation.reconciliation_paused
+            || airflow.spec.cluster_operation.stopped
+        {
+            tracing::info!(
+                "Cluster is in a transitional state so do not attempt to collect listener information that will only be active once cluster has returned to a non-transitional state."
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: 5c7022c
created_at: 2025-04-22T09:03:48Z
author_association: MEMBER
diff_hunk: @@ -965,9 +977,23 @@ fn build_server_rolegroup_statefulset(
         };
         airflow_container.readiness_probe(probe.clone());
         airflow_container.liveness_probe(probe);
-        airflow_container.add_container_port("http", resolved_port.into());
+        airflow_container.add_container_port(HTTP_PORT_NAME, http_port.into());
     }
 
+    let listener_class = &merged_airflow_config.listener_class;
+    // all listeners will use ephemeral volumes as they can/should
+    // be removed when the pods is re-started, and no data needs to be preserved
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: 5c7022c
created_at: 2025-04-22T09:03:53Z
author_association: MEMBER
diff_hunk: @@ -1,18 +1,19 @@
 = Service exposition with ListenerClasses
 :description: Configure Airflow service exposure with ListenerClasses: cluster-internal, external-unstable, or external-stable.
 
-Airflow offers a web UI and an API, both are exposed by the webserver process under the `webserver` role.
-The Operator deploys a service called `<name>-webserver` (where `<name>` is the name of the AirflowCluster) through which Airflow can be reached.
-
-This service can have three different types: `cluster-internal`, `external-unstable` and `external-stable`.
-Read more about the types in the xref:concepts:service-exposition.adoc[service exposition] documentation at platform level.
-
-This is how the listener class is configured:
+The operator deploys a xref:listener-operator:listener.adoc[Listener] for the Webserver pod.
+The listener defaults to only being accessible from within the Kubernetes cluster, but this can be changed by setting `.spec.webservers.config.listenerClass`:
 
 [source,yaml]
 ----
 spec:
-  clusterConfig:
-    listenerClass: cluster-internal  # <1>
+  schedulers:
+    config:
+      listenerClass: external-unstable  # <1>
+  webservers:
+    ...
+  celeryExecutors:
+    ...
path: docs/modules/airflow/pages/usage-guide/listenerclass.adoc
user: login: maltesander
body: ```suggestion
  webservers:
    config:
      listenerClass: external-unstable  # <1>
  schedulers:
    ...
  celeryExecutors:
    ...
```
created_at: 2025-04-24T13:24:55Z
author_association: MEMBER
diff_hunk: @@ -282,22 +281,29 @@ impl HasStatusCondition for v1alpha1::AirflowCluster {
 impl v1alpha1::AirflowCluster {
     /// the worker role will not be returned if airflow provisions pods as needed (i.e. when
     /// the kubernetes executor is specified)
-    pub fn get_role(&self, role: &AirflowRole) -> Option<&Role<AirflowConfigFragment>> {
+    pub fn get_role(&self, role: &AirflowRole) -> Option<Role<AirflowConfigFragment>> {
         match role {
-            AirflowRole::Webserver => self.spec.webservers.as_ref(),
-            AirflowRole::Scheduler => self.spec.schedulers.as_ref(),
+            AirflowRole::Webserver => {
+                if let Some(webserver_config) = self.spec.webservers.to_owned() {
+                    let role = extract_role_from_webserver_config(webserver_config);
+                    Some(role)
+                } else {
+                    None
+                }
+            }
path: rust/operator-binary/src/crd/mod.rs
user: login: maltesander
body: ```suggestion
            AirflowRole::Webserver => self
                .spec
                .webservers
                .to_owned()
                .map(extract_role_from_webserver_config),
```
created_at: 2025-04-24T13:29:56Z
author_association: MEMBER
diff_hunk: @@ -1,18 +1,19 @@
 = Service exposition with ListenerClasses
 :description: Configure Airflow service exposure with ListenerClasses: cluster-internal, external-unstable, or external-stable.
 
-Airflow offers a web UI and an API, both are exposed by the webserver process under the `webserver` role.
-The Operator deploys a service called `<name>-webserver` (where `<name>` is the name of the AirflowCluster) through which Airflow can be reached.
-
-This service can have three different types: `cluster-internal`, `external-unstable` and `external-stable`.
-Read more about the types in the xref:concepts:service-exposition.adoc[service exposition] documentation at platform level.
-
-This is how the listener class is configured:
+The operator deploys a xref:listener-operator:listener.adoc[Listener] for the Webserver pod.
+The listener defaults to only being accessible from within the Kubernetes cluster, but this can be changed by setting `.spec.webservers.config.listenerClass`:
 
 [source,yaml]
 ----
 spec:
-  clusterConfig:
-    listenerClass: cluster-internal  # <1>
+  schedulers:
+    config:
+      listenerClass: external-unstable  # <1>
+  webservers:
+    ...
+  celeryExecutors:
+    ...
path: docs/modules/airflow/pages/usage-guide/listenerclass.adoc
user: login: adwk67
body: thanks!
created_at: 2025-04-24T13:42:46Z
author_association: MEMBER
diff_hunk: @@ -965,7 +915,25 @@ fn build_server_rolegroup_statefulset(
         };
         airflow_container.readiness_probe(probe.clone());
         airflow_container.liveness_probe(probe);
-        airflow_container.add_container_port("http", resolved_port.into());
+        airflow_container.add_container_port(HTTP_PORT_NAME, http_port.into());
+    }
+
+    if let Some(listener_class) =
+        airflow.merged_listener_class(airflow_role, &rolegroup_ref.role_group)
+    {
+        // all listeners will use ephemeral volumes as they can/should
+        // be removed when the pods are *terminated* (ephemeral PVCs will
+        // survive re-starts)
+        pb.add_listener_volume_by_listener_class(
+            LISTENER_VOLUME_NAME,
+            &listener_class.to_string(),
+            &recommended_labels,
+        )
+        .context(AddVolumeSnafu)?;
path: rust/operator-binary/src/airflow_controller.rs
user: login: nightkr
body: Clients don't care about _which_ airflow webserver they connect to, right? In that case, we should use a group listener instead (https://docs.stackable.tech/home/stable/listener-operator/volume/#_listeners_stackable_techlistener_name), so we only end up with one load balancer that knows how to route everywhere.
created_at: 2025-04-25T11:13:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -965,7 +915,25 @@ fn build_server_rolegroup_statefulset(
         };
         airflow_container.readiness_probe(probe.clone());
         airflow_container.liveness_probe(probe);
-        airflow_container.add_container_port("http", resolved_port.into());
+        airflow_container.add_container_port(HTTP_PORT_NAME, http_port.into());
+    }
+
+    if let Some(listener_class) =
+        airflow.merged_listener_class(airflow_role, &rolegroup_ref.role_group)
+    {
+        // all listeners will use ephemeral volumes as they can/should
+        // be removed when the pods are *terminated* (ephemeral PVCs will
+        // survive re-starts)
path: rust/operator-binary/src/airflow_controller.rs
user: login: nightkr
body: We should use persistent volumes for web UIs IMO, since upstream load balancers will depend on probably end up hard-coding the target addresses.
created_at: 2025-04-25T11:14:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1304,162 +1287,191 @@ spec:
                     config:
                       default: {}
                       properties:
-                        affinity:
+                        airflowConfig:
path: deploy/helm/airflow-operator/crds/crds.yaml
user: login: nightkr
body: Smells like a flatten is missing here? (Or that the CRD YAML is out of date)
created_at: 2025-04-25T11:16:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1304,162 +1287,191 @@ spec:
                     config:
                       default: {}
                       properties:
-                        affinity:
+                        airflowConfig:
path: deploy/helm/airflow-operator/crds/crds.yaml
user: login: adwk67
body: Thanks! (the flatten was in the wrong place). 7da421c
created_at: 2025-04-25T12:40:30Z
author_association: MEMBER
diff_hunk: @@ -965,7 +915,25 @@ fn build_server_rolegroup_statefulset(
         };
         airflow_container.readiness_probe(probe.clone());
         airflow_container.liveness_probe(probe);
-        airflow_container.add_container_port("http", resolved_port.into());
+        airflow_container.add_container_port(HTTP_PORT_NAME, http_port.into());
+    }
+
+    if let Some(listener_class) =
+        airflow.merged_listener_class(airflow_role, &rolegroup_ref.role_group)
+    {
+        // all listeners will use ephemeral volumes as they can/should
+        // be removed when the pods are *terminated* (ephemeral PVCs will
+        // survive re-starts)
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: 7ac6077
created_at: 2025-04-25T15:42:15Z
author_association: MEMBER
diff_hunk: @@ -965,7 +915,25 @@ fn build_server_rolegroup_statefulset(
         };
         airflow_container.readiness_probe(probe.clone());
         airflow_container.liveness_probe(probe);
-        airflow_container.add_container_port("http", resolved_port.into());
+        airflow_container.add_container_port(HTTP_PORT_NAME, http_port.into());
+    }
+
+    if let Some(listener_class) =
+        airflow.merged_listener_class(airflow_role, &rolegroup_ref.role_group)
+    {
+        // all listeners will use ephemeral volumes as they can/should
+        // be removed when the pods are *terminated* (ephemeral PVCs will
+        // survive re-starts)
+        pb.add_listener_volume_by_listener_class(
+            LISTENER_VOLUME_NAME,
+            &listener_class.to_string(),
+            &recommended_labels,
+        )
+        .context(AddVolumeSnafu)?;
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: If I've understood this correctly, the operator would then be responsible for creating a listener-by-name (like we do for the kafka bootstrap listener) if the webserver role (or one of its rolegroups) declares a listener class as external-stable or external-unstable, and then this group listener is passed as the name to ListenerReference::ListenerClass for the ListenerOperatorVolumeSourceBuilder for the pvcs? ~And this listener doesn't care if webserver rolegroups specify different classes (e.g. one as external-stable and another as external-unstable)?~ I'll do this per role-group, as we have done for Kafka.
created_at: 2025-04-25T15:46:06Z
author_association: MEMBER
diff_hunk: @@ -965,7 +915,25 @@ fn build_server_rolegroup_statefulset(
         };
         airflow_container.readiness_probe(probe.clone());
         airflow_container.liveness_probe(probe);
-        airflow_container.add_container_port("http", resolved_port.into());
+        airflow_container.add_container_port(HTTP_PORT_NAME, http_port.into());
+    }
+
+    if let Some(listener_class) =
+        airflow.merged_listener_class(airflow_role, &rolegroup_ref.role_group)
+    {
+        // all listeners will use ephemeral volumes as they can/should
+        // be removed when the pods are *terminated* (ephemeral PVCs will
+        // survive re-starts)
+        pb.add_listener_volume_by_listener_class(
+            LISTENER_VOLUME_NAME,
+            &listener_class.to_string(),
+            &recommended_labels,
+        )
+        .context(AddVolumeSnafu)?;
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: ~Hmmm, but for Kafka we create a bootstrap-listener per role-group.~
created_at: 2025-04-25T16:27:29Z
author_association: MEMBER
diff_hunk: @@ -965,7 +915,25 @@ fn build_server_rolegroup_statefulset(
         };
         airflow_container.readiness_probe(probe.clone());
         airflow_container.liveness_probe(probe);
-        airflow_container.add_container_port("http", resolved_port.into());
+        airflow_container.add_container_port(HTTP_PORT_NAME, http_port.into());
+    }
+
+    if let Some(listener_class) =
+        airflow.merged_listener_class(airflow_role, &rolegroup_ref.role_group)
+    {
+        // all listeners will use ephemeral volumes as they can/should
+        // be removed when the pods are *terminated* (ephemeral PVCs will
+        // survive re-starts)
+        pb.add_listener_volume_by_listener_class(
+            LISTENER_VOLUME_NAME,
+            &listener_class.to_string(),
+            &recommended_labels,
+        )
+        .context(AddVolumeSnafu)?;
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: 0f650d3
created_at: 2025-04-28T08:48:57Z
author_association: MEMBER
diff_hunk: @@ -435,27 +505,31 @@ pub struct AirflowOpaConfig {
     pub cache: UserInformationCache,
 }
 
-// TODO: Temporary solution until listener-operator is finished
 #[derive(Clone, Debug, Default, Display, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
 #[serde(rename_all = "PascalCase")]
-pub enum CurrentlySupportedListenerClasses {
+pub enum SupportedListenerClasses {
     #[default]
     #[serde(rename = "cluster-internal")]
+    #[strum(serialize = "cluster-internal")]
     ClusterInternal,
 
     #[serde(rename = "external-unstable")]
+    #[strum(serialize = "external-unstable")]
     ExternalUnstable,
 
     #[serde(rename = "external-stable")]
+    #[strum(serialize = "external-stable")]
     ExternalStable,
 }
 
-impl CurrentlySupportedListenerClasses {
-    pub fn k8s_service_type(&self) -> String {
+impl Atomic for SupportedListenerClasses {}
+
+impl SupportedListenerClasses {
+    pub fn discoverable(&self) -> bool {
         match self {
-            CurrentlySupportedListenerClasses::ClusterInternal => "ClusterIP".to_string(),
-            CurrentlySupportedListenerClasses::ExternalUnstable => "NodePort".to_string(),
-            CurrentlySupportedListenerClasses::ExternalStable => "LoadBalancer".to_string(),
+            SupportedListenerClasses::ClusterInternal => false,
+            SupportedListenerClasses::ExternalUnstable => true,
+            SupportedListenerClasses::ExternalStable => true,
         }
     }
 }
path: rust/operator-binary/src/crd/mod.rs
user: login: nightkr
body: Now that we're using listener-operator, the listener class name should just be an opaque string.
created_at: 2025-04-28T09:24:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -435,27 +505,31 @@ pub struct AirflowOpaConfig {
     pub cache: UserInformationCache,
 }
 
-// TODO: Temporary solution until listener-operator is finished
 #[derive(Clone, Debug, Default, Display, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
 #[serde(rename_all = "PascalCase")]
-pub enum CurrentlySupportedListenerClasses {
+pub enum SupportedListenerClasses {
     #[default]
     #[serde(rename = "cluster-internal")]
+    #[strum(serialize = "cluster-internal")]
     ClusterInternal,
 
     #[serde(rename = "external-unstable")]
+    #[strum(serialize = "external-unstable")]
     ExternalUnstable,
 
     #[serde(rename = "external-stable")]
+    #[strum(serialize = "external-stable")]
     ExternalStable,
 }
 
-impl CurrentlySupportedListenerClasses {
-    pub fn k8s_service_type(&self) -> String {
+impl Atomic for SupportedListenerClasses {}
+
+impl SupportedListenerClasses {
+    pub fn discoverable(&self) -> bool {
         match self {
-            CurrentlySupportedListenerClasses::ClusterInternal => "ClusterIP".to_string(),
-            CurrentlySupportedListenerClasses::ExternalUnstable => "NodePort".to_string(),
-            CurrentlySupportedListenerClasses::ExternalStable => "LoadBalancer".to_string(),
+            SupportedListenerClasses::ClusterInternal => false,
+            SupportedListenerClasses::ExternalUnstable => true,
+            SupportedListenerClasses::ExternalStable => true,
         }
     }
 }
path: rust/operator-binary/src/crd/mod.rs
user: login: nightkr
body: Users are free to define their own custom listenerclasses, or even to redefine our preset ones (though the latter isn't really recommended since it could lead to upgrade strife..).
created_at: 2025-04-28T09:25:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -435,27 +505,31 @@ pub struct AirflowOpaConfig {
     pub cache: UserInformationCache,
 }
 
-// TODO: Temporary solution until listener-operator is finished
 #[derive(Clone, Debug, Default, Display, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
 #[serde(rename_all = "PascalCase")]
-pub enum CurrentlySupportedListenerClasses {
+pub enum SupportedListenerClasses {
     #[default]
     #[serde(rename = "cluster-internal")]
+    #[strum(serialize = "cluster-internal")]
     ClusterInternal,
 
     #[serde(rename = "external-unstable")]
+    #[strum(serialize = "external-unstable")]
     ExternalUnstable,
 
     #[serde(rename = "external-stable")]
+    #[strum(serialize = "external-stable")]
     ExternalStable,
 }
 
-impl CurrentlySupportedListenerClasses {
-    pub fn k8s_service_type(&self) -> String {
+impl Atomic for SupportedListenerClasses {}
+
+impl SupportedListenerClasses {
+    pub fn discoverable(&self) -> bool {
         match self {
-            CurrentlySupportedListenerClasses::ClusterInternal => "ClusterIP".to_string(),
-            CurrentlySupportedListenerClasses::ExternalUnstable => "NodePort".to_string(),
-            CurrentlySupportedListenerClasses::ExternalStable => "LoadBalancer".to_string(),
+            SupportedListenerClasses::ClusterInternal => false,
+            SupportedListenerClasses::ExternalUnstable => true,
+            SupportedListenerClasses::ExternalStable => true,
         }
     }
 }
path: rust/operator-binary/src/crd/mod.rs
user: login: adwk67
body: Looking at the implementation for kafka-operator, a PVC is created for the bootstrap listener even when it is not defined by the user (defaulting to cluster-internal). I guess there is no easy way around that, other than looking up the listener class and checking its type?
created_at: 2025-04-28T10:02:51Z
author_association: MEMBER
diff_hunk: @@ -435,27 +505,31 @@ pub struct AirflowOpaConfig {
     pub cache: UserInformationCache,
 }
 
-// TODO: Temporary solution until listener-operator is finished
 #[derive(Clone, Debug, Default, Display, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
 #[serde(rename_all = "PascalCase")]
-pub enum CurrentlySupportedListenerClasses {
+pub enum SupportedListenerClasses {
     #[default]
     #[serde(rename = "cluster-internal")]
+    #[strum(serialize = "cluster-internal")]
     ClusterInternal,
 
     #[serde(rename = "external-unstable")]
+    #[strum(serialize = "external-unstable")]
     ExternalUnstable,
 
     #[serde(rename = "external-stable")]
+    #[strum(serialize = "external-stable")]
     ExternalStable,
 }
 
-impl CurrentlySupportedListenerClasses {
-    pub fn k8s_service_type(&self) -> String {
+impl Atomic for SupportedListenerClasses {}
+
+impl SupportedListenerClasses {
+    pub fn discoverable(&self) -> bool {
         match self {
-            CurrentlySupportedListenerClasses::ClusterInternal => "ClusterIP".to_string(),
-            CurrentlySupportedListenerClasses::ExternalUnstable => "NodePort".to_string(),
-            CurrentlySupportedListenerClasses::ExternalStable => "LoadBalancer".to_string(),
+            SupportedListenerClasses::ClusterInternal => false,
+            SupportedListenerClasses::ExternalUnstable => true,
+            SupportedListenerClasses::ExternalStable => true,
         }
     }
 }
path: rust/operator-binary/src/crd/mod.rs
user: login: nightkr
body: kafka-operator behaves correctly there; even when running cluster-internally we want the Listener -> Service to be the ingress point that we expose.
created_at: 2025-04-28T10:22:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -435,27 +505,31 @@ pub struct AirflowOpaConfig {
     pub cache: UserInformationCache,
 }
 
-// TODO: Temporary solution until listener-operator is finished
 #[derive(Clone, Debug, Default, Display, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
 #[serde(rename_all = "PascalCase")]
-pub enum CurrentlySupportedListenerClasses {
+pub enum SupportedListenerClasses {
     #[default]
     #[serde(rename = "cluster-internal")]
+    #[strum(serialize = "cluster-internal")]
     ClusterInternal,
 
     #[serde(rename = "external-unstable")]
+    #[strum(serialize = "external-unstable")]
     ExternalUnstable,
 
     #[serde(rename = "external-stable")]
+    #[strum(serialize = "external-stable")]
     ExternalStable,
 }
 
-impl CurrentlySupportedListenerClasses {
-    pub fn k8s_service_type(&self) -> String {
+impl Atomic for SupportedListenerClasses {}
+
+impl SupportedListenerClasses {
+    pub fn discoverable(&self) -> bool {
         match self {
-            CurrentlySupportedListenerClasses::ClusterInternal => "ClusterIP".to_string(),
-            CurrentlySupportedListenerClasses::ExternalUnstable => "NodePort".to_string(),
-            CurrentlySupportedListenerClasses::ExternalStable => "LoadBalancer".to_string(),
+            SupportedListenerClasses::ClusterInternal => false,
+            SupportedListenerClasses::ExternalUnstable => true,
+            SupportedListenerClasses::ExternalStable => true,
         }
     }
 }
path: rust/operator-binary/src/crd/mod.rs
user: login: adwk67
body: See dcc21f9. As we discussed offline, I've used a consistent address for the Webserver UI whatever the listener class.
created_at: 2025-04-28T11:19:18Z
author_association: MEMBER
diff_hunk: @@ -1,18 +1,19 @@
 = Service exposition with ListenerClasses
 :description: Configure Airflow service exposure with ListenerClasses: cluster-internal, external-unstable, or external-stable.
 
-Airflow offers a web UI and an API, both are exposed by the webserver process under the `webserver` role.
-The Operator deploys a service called `<name>-webserver` (where `<name>` is the name of the AirflowCluster) through which Airflow can be reached.
-
-This service can have three different types: `cluster-internal`, `external-unstable` and `external-stable`.
-Read more about the types in the xref:concepts:service-exposition.adoc[service exposition] documentation at platform level.
-
-This is how the listener class is configured:
+The operator deploys a xref:listener-operator:listener.adoc[Listener] for the Webserver pod.
+The listener defaults to only being accessible from within the Kubernetes cluster, but this can be changed by setting `.spec.webservers.config.listenerClass`:
 
 [source,yaml]
 ----
 spec:
-  clusterConfig:
-    listenerClass: cluster-internal  # <1>
+  webservers:
+    config:
+      listenerClass: external-unstable  # <1>
+  schedulers:
+    ...
+  celeryExecutors:
+    ...
 ----
-<1> The default `cluster-internal` setting.
+<1> Specify one of `external-stable`, `external-unstable`, `cluster-internal` (the default setting is `cluster-internal`).
path: docs/modules/airflow/pages/usage-guide/listenerclass.adoc
user: login: nightkr
body: ```suggestion
<1> Specify a ListenerClass, such as `external-stable`, `external-unstable`, or `cluster-internal` (the default setting is `cluster-internal`).
```
created_at: 2025-04-29T09:34:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -602,68 +634,15 @@ async fn build_executor_template(
     Ok(())
 }
 
-/// The server-role service is the primary endpoint that should be used by clients that do not perform internal load balancing,
-/// including targets outside the cluster.
-fn build_role_service(
-    airflow: &v1alpha1::AirflowCluster,
-    resolved_product_image: &ResolvedProductImage,
-    role_name: &str,
-    port: u16,
-) -> Result<Service> {
-    let role_svc_name = format!("{}-{}", airflow.name_any(), role_name);
-    let ports = role_ports(port);
-
-    let metadata = ObjectMetaBuilder::new()
-        .name_and_namespace(airflow)
-        .name(&role_svc_name)
-        .ownerreference_from_resource(airflow, None, Some(true))
-        .context(ObjectMissingMetadataForOwnerRefSnafu)?
-        .with_recommended_labels(build_recommended_labels(
-            airflow,
-            AIRFLOW_CONTROLLER_NAME,
-            &resolved_product_image.app_version_label,
-            role_name,
-            "global",
-        ))
-        .context(ObjectMetaSnafu)?
-        .build();
-
-    let service_selector_labels =
-        Labels::role_selector(airflow, APP_NAME, role_name).context(BuildLabelSnafu)?;
-
-    let service_spec = ServiceSpec {
-        type_: Some(
-            airflow
-                .spec
-                .cluster_config
-                .listener_class
-                .k8s_service_type(),
-        ),
-        ports: Some(ports),
-        selector: Some(service_selector_labels.into()),
-        ..ServiceSpec::default()
-    };
-
-    Ok(Service {
-        metadata,
-        spec: Some(service_spec),
-        status: None,
-    })
-}
-
 fn role_ports(port: u16) -> Vec<ServicePort> {
path: rust/operator-binary/src/airflow_controller.rs
user: login: nightkr
body: I don't love the kinda weird roundabout way this is bounced around, but not new so oh well.
created_at: 2025-04-29T10:17:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -847,6 +827,51 @@ fn build_rolegroup_metadata(
     Ok(metadata)
 }
 
+pub fn build_group_listener(
+    airflow: &v1alpha1::AirflowCluster,
+    resolved_product_image: &ResolvedProductImage,
+    rolegroup: &RoleGroupRef<v1alpha1::AirflowCluster>,
+    listener_class: String,
+) -> Result<Listener> {
+    Ok(Listener {
+        metadata: ObjectMetaBuilder::new()
+            .name_and_namespace(airflow)
+            .name(airflow.group_listener_name(rolegroup))
+            .ownerreference_from_resource(airflow, None, Some(true))
+            .context(ObjectMissingMetadataForOwnerRefSnafu)?
+            .with_recommended_labels(build_recommended_labels(
+                airflow,
+                AIRFLOW_CONTROLLER_NAME,
+                &resolved_product_image.app_version_label,
+                &rolegroup.role,
+                &rolegroup.role_group,
+            ))
+            .context(ObjectMetaSnafu)?
+            .build(),
+        spec: ListenerSpec {
+            class_name: Some(listener_class),
+            ports: Some(listener_ports()),
+            ..ListenerSpec::default()
+        },
+        status: None,
+    })
+}
+
+fn listener_ports() -> Vec<ListenerPort> {
+    vec![
+        ListenerPort {
+            name: METRICS_PORT_NAME.to_string(),
+            port: METRICS_PORT.into(),
+            protocol: Some("TCP".to_string()),
+        },
path: rust/operator-binary/src/airflow_controller.rs
user: login: nightkr
body: Don't think we need metrics here?
created_at: 2025-04-29T10:18:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -602,68 +634,15 @@ async fn build_executor_template(
     Ok(())
 }
 
-/// The server-role service is the primary endpoint that should be used by clients that do not perform internal load balancing,
-/// including targets outside the cluster.
-fn build_role_service(
path: rust/operator-binary/src/airflow_controller.rs
user: login: nightkr
body: Do we still want to keep the old service around for a cycle or so to ease migration? Not sure.
created_at: 2025-04-29T10:19:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -869,15 +894,26 @@ fn build_server_rolegroup_statefulset(
     let rolegroup = role.role_groups.get(&rolegroup_ref.role_group);
 
     let mut pb = PodBuilder::new();
+    let recommended_object_labels = build_recommended_labels(
+        airflow,
+        AIRFLOW_CONTROLLER_NAME,
+        &resolved_product_image.app_version_label,
+        &rolegroup_ref.role,
+        &rolegroup_ref.role_group,
+    );
+    // Used for PVC templates that cannot be modified once they are deployed
+    let unversioned_recommended_labels = Labels::recommended(build_recommended_labels(
path: rust/operator-binary/src/airflow_controller.rs
user: login: nightkr
body: More of a note for the future, but do we want to have an unversioned variant of `build_recommended_labels` in op-rs?
created_at: 2025-04-29T10:21:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -965,7 +1002,31 @@ fn build_server_rolegroup_statefulset(
         };
         airflow_container.readiness_probe(probe.clone());
         airflow_container.liveness_probe(probe);
-        airflow_container.add_container_port("http", resolved_port.into());
+        airflow_container.add_container_port(HTTP_PORT_NAME, http_port.into());
+    }
+
+    let mut pvcs: Option<Vec<PersistentVolumeClaim>> = None;
+
+    if airflow
+        .merged_listener_class(airflow_role, &rolegroup_ref.role_group)
+        .is_some()
+    {
+        // Listener endpoints for the Webserver role will use persistent volumes
+        // so that load balancers can hard-code the target addresses. This will
+        // be the case even when no class is set (and the value defaults to
+        // cluster-internal) as the address should still be consistent.
+        let pvc = ListenerOperatorVolumeSourceBuilder::new(
+            &ListenerReference::ListenerName(airflow.group_listener_name(rolegroup_ref)),
path: rust/operator-binary/src/airflow_controller.rs
user: login: nightkr
body: Nit: Maybe we should define `group_listener_name` to return an `Option` instead, and `if let` on that, rather require the consuming code (here) to make the implication between `merged_listener_class` and `group_listener_name`?
created_at: 2025-04-29T10:23:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -393,6 +402,34 @@ impl v1alpha1::AirflowCluster {
         fragment::validate(conf_rolegroup).context(FragmentValidationFailureSnafu)
     }
 
+    pub fn merged_listener_class(
+        &self,
+        role: &AirflowRole,
+        rolegroup_name: &String,
+    ) -> Option<String> {
+        if role == &AirflowRole::Webserver {
+            if let Some(webservers) = self.spec.webservers.as_ref() {
+                let conf_defaults = Some("cluster-internal".to_string());
+                let mut conf_role = webservers.config.config.listener_class.to_owned();
+                let mut conf_rolegroup = webservers
+                    .role_groups
+                    .get(rolegroup_name)
+                    .map(|rg| rg.config.config.listener_class.clone())
+                    .unwrap_or_default();
+
+                conf_role.merge(&conf_defaults);
+                conf_rolegroup.merge(&conf_role);
path: rust/operator-binary/src/crd/mod.rs
user: login: nightkr
body: Hm, don't love how this ends up bypassing "the main merge".. will have to look into how hdfs-op is doing it. Probably not worth blocking the PR over, more of a note for later.
created_at: 2025-04-29T10:27:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -419,6 +456,39 @@ impl v1alpha1::AirflowCluster {
     }
 }
 
+fn extract_role_from_webserver_config(
+    fragment: Role<WebserverConfigFragment>,
+) -> Role<AirflowConfigFragment> {
path: rust/operator-binary/src/crd/mod.rs
user: login: nightkr
body: Another note for the future, but.. maybe we should look into at least lifting the outline of this logic into op-rs. Again, should look at how hdfs-op does this.
created_at: 2025-04-29T10:33:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,12 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestStep
+commands:
+  - script: >-
path: tests/templates/kuttl/external-access/20-install-redis.yaml
user: login: nightkr
body: Couldn't this be merged into 10-, so it can be done in parallel?
created_at: 2025-04-29T10:35:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,10 @@
+---
path: tests/templates/kuttl/external-access/30-assert.yaml.j2
user: login: nightkr
body: Is this nothing-assert actually required?
created_at: 2025-04-29T10:35:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-available-condition
+timeout: 600
+commands:
+  - script: kubectl -n $NAMESPACE wait --for=condition=available airflowclusters.airflow.stackable.tech/airflow --timeout 301s
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-airflow-cluster
+timeout: 1200
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-external-unstable
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-cluster-internal
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-worker-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 300
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-scheduler-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-webserver
+status:
+  expectedPods: 4
+  currentHealthy: 4
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-worker
+status:
+  expectedPods: 2
+  currentHealthy: 2
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-scheduler
+status:
+  expectedPods: 1
+  currentHealthy: 1
+  disruptionsAllowed: 1
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-cluster-internal-group
+spec:
+  type: ClusterIP # cluster-internal
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-default-group
+spec:
+  type: NodePort # external-stable
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-external-unstable-group
+spec:
+  type: NodePort # external-unstable
path: tests/templates/kuttl/external-access/40-assert.yaml
user: login: nightkr
body: Hm, do we want to install test-specific ListenerClasses? Probably don't want to fail over a cluster being installed with the cloud preset instead.
created_at: 2025-04-29T10:38:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,9 +10,10 @@
 )
 
 session = requests.Session()
+url = "http://airflow-webserver-default:8080"
path: tests/templates/kuttl/oidc/login.py
user: login: nightkr
body: ```suggestion
url = "http://airflow-webserver-default-group:8080"
```
created_at: 2025-04-29T10:40:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,10 +26,12 @@
     "password": "NvfpU518",
 }
 
+url = "http://airflow-webserver-default:8080"
path: tests/templates/kuttl/opa/41_check-authorization.py
user: login: nightkr
body: ```suggestion
url = "http://airflow-webserver-default-group:8080"
```
created_at: 2025-04-29T10:40:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -280,24 +279,35 @@ impl HasStatusCondition for v1alpha1::AirflowCluster {
 }
 
 impl v1alpha1::AirflowCluster {
+    /// The name of the group-listener provided for a specific role-group.
+    /// Webservers will use this group listener so that only one load balancer
+    /// is needed (per role group).
+    pub fn group_listener_name(&self, rolegroup: &RoleGroupRef<Self>) -> String {
+        format!("{}-group", rolegroup.object_name())
path: rust/operator-binary/src/crd/mod.rs
user: login: sbernauer
body: I didn't look at the code (I trust you and @nightkr on that ^^), but I tried it out.
It ends up creating the following services using external-unstable:
```
k get svc | grep airflow
airflow-postgresql                ClusterIP   10.96.44.126   <none>        5432/TCP                        8m33s
airflow-postgresql-hl             ClusterIP   None           <none>        5432/TCP                        8m33s
airflow-redis-headless            ClusterIP   None           <none>        6379/TCP                        7m54s
airflow-redis-master              ClusterIP   10.96.68.247   <none>        6379/TCP                        7m54s
airflow-redis-replicas            ClusterIP   10.96.67.156   <none>        6379/TCP                        7m54s
airflow-scheduler-default         ClusterIP   None           <none>        9102/TCP                        5m40s
airflow-webserver-default         ClusterIP   None           <none>        9102/TCP,8080/TCP               5m40s
airflow-webserver-default-group   NodePort    10.96.175.84   <none>        8080:31730/TCP,9102:31012/TCP   5m40s
airflow-worker-default            ClusterIP   None           <none>        9102/TCP                        5m40s
```
Instead of having two webserver services (doing the same thing), can't we have a single one named `airflow-webserver-default`? That's of specified serviceType, `airflow-webserver-default-0.airflow-webserver-default.default.svc.cluster.local` should work in any case, regardless of the serviceType.
created_at: 2025-04-29T12:21:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,10 @@
+---
path: tests/templates/kuttl/external-access/30-assert.yaml.j2
user: login: adwk67
body: No - have removed it. f9c41bc.
created_at: 2025-04-29T12:40:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,12 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestStep
+commands:
+  - script: >-
path: tests/templates/kuttl/external-access/20-install-redis.yaml
user: login: adwk67
body: I've left the product installs as they are (I can't think of anywhere else where we install products together in one step: we keep them discrete for good reason) but combined the asserts. Not sure what benefit this brings, TBH. f9c41bc.
created_at: 2025-04-29T12:40:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,114 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-available-condition
+timeout: 600
+commands:
+  - script: kubectl -n $NAMESPACE wait --for=condition=available airflowclusters.airflow.stackable.tech/airflow --timeout 301s
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-airflow-cluster
+timeout: 1200
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-external-unstable
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-cluster-internal
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-worker-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 300
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-scheduler-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-webserver
+status:
+  expectedPods: 4
+  currentHealthy: 4
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-worker
+status:
+  expectedPods: 2
+  currentHealthy: 2
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-scheduler
+status:
+  expectedPods: 1
+  currentHealthy: 1
+  disruptionsAllowed: 1
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-cluster-internal-group
+spec:
+  type: ClusterIP # cluster-internal
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-default-group
+spec:
+  type: NodePort # external-stable
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-external-unstable-group
+spec:
+  type: NodePort # external-unstable
path: tests/templates/kuttl/external-access/40-assert.yaml
user: login: adwk67
body: I'm not sure I understand: I'm using the standard listener classes and doing a trivial test for each one.
created_at: 2025-04-29T12:40:26Z
author_association: MEMBER
diff_hunk: @@ -602,68 +634,15 @@ async fn build_executor_template(
     Ok(())
 }
 
-/// The server-role service is the primary endpoint that should be used by clients that do not perform internal load balancing,
-/// including targets outside the cluster.
-fn build_role_service(
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: I think that will make things a bit messy (cluster config + role/role-group config): yes, it's a bit abrupt to remove it but we did that for HDFS and Kafka, IIRC.
created_at: 2025-04-29T12:45:05Z
author_association: MEMBER
diff_hunk: @@ -280,24 +279,35 @@ impl HasStatusCondition for v1alpha1::AirflowCluster {
 }
 
 impl v1alpha1::AirflowCluster {
+    /// The name of the group-listener provided for a specific role-group.
+    /// Webservers will use this group listener so that only one load balancer
+    /// is needed (per role group).
+    pub fn group_listener_name(&self, rolegroup: &RoleGroupRef<Self>) -> String {
+        format!("{}-group", rolegroup.object_name())
path: rust/operator-binary/src/crd/mod.rs
user: login: adwk67
body: A change (see comment further up) will remove the metrics endpoint from the group listener, so that is maybe an argument to keep them both.
created_at: 2025-04-29T12:49:00Z
author_association: MEMBER
diff_hunk: @@ -869,15 +894,26 @@ fn build_server_rolegroup_statefulset(
     let rolegroup = role.role_groups.get(&rolegroup_ref.role_group);
 
     let mut pb = PodBuilder::new();
+    let recommended_object_labels = build_recommended_labels(
+        airflow,
+        AIRFLOW_CONTROLLER_NAME,
+        &resolved_product_image.app_version_label,
+        &rolegroup_ref.role,
+        &rolegroup_ref.role_group,
+    );
+    // Used for PVC templates that cannot be modified once they are deployed
+    let unversioned_recommended_labels = Labels::recommended(build_recommended_labels(
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: Probably, as we will have this for several listener-op integrations (airflow, superset, druid, probably nifi, maybe hive and trino)
created_at: 2025-04-29T12:53:13Z
author_association: MEMBER
diff_hunk: @@ -393,6 +402,34 @@ impl v1alpha1::AirflowCluster {
         fragment::validate(conf_rolegroup).context(FragmentValidationFailureSnafu)
     }
 
+    pub fn merged_listener_class(
+        &self,
+        role: &AirflowRole,
+        rolegroup_name: &String,
+    ) -> Option<String> {
+        if role == &AirflowRole::Webserver {
+            if let Some(webservers) = self.spec.webservers.as_ref() {
+                let conf_defaults = Some("cluster-internal".to_string());
+                let mut conf_role = webservers.config.config.listener_class.to_owned();
+                let mut conf_rolegroup = webservers
+                    .role_groups
+                    .get(rolegroup_name)
+                    .map(|rg| rg.config.config.listener_class.clone())
+                    .unwrap_or_default();
+
+                conf_role.merge(&conf_defaults);
+                conf_rolegroup.merge(&conf_role);
path: rust/operator-binary/src/crd/mod.rs
user: login: adwk67
body: hdfs-op wraps the 3 different configs in AnyNodeConfig and resolves the config per role, so at least the merging is in one place. Maybe we can make a decision on this when we do Druid, as that has more role-complexity.
created_at: 2025-04-29T13:12:01Z
author_association: MEMBER
diff_hunk: @@ -419,6 +456,39 @@ impl v1alpha1::AirflowCluster {
     }
 }
 
+fn extract_role_from_webserver_config(
+    fragment: Role<WebserverConfigFragment>,
+) -> Role<AirflowConfigFragment> {
path: rust/operator-binary/src/crd/mod.rs
user: login: adwk67
body: Ditto comment re. Druid: when we look at Airflow and Druid together it should be clearer what to lift into the framework.
created_at: 2025-04-29T13:12:58Z
author_association: MEMBER
diff_hunk: @@ -280,24 +279,35 @@ impl HasStatusCondition for v1alpha1::AirflowCluster {
 }
 
 impl v1alpha1::AirflowCluster {
+    /// The name of the group-listener provided for a specific role-group.
+    /// Webservers will use this group listener so that only one load balancer
+    /// is needed (per role group).
+    pub fn group_listener_name(&self, rolegroup: &RoleGroupRef<Self>) -> String {
+        format!("{}-group", rolegroup.object_name())
path: rust/operator-binary/src/crd/mod.rs
user: login: sbernauer
body: I saw it and I personally prefer to keep the metrics in (as the current state)
created_at: 2025-04-29T13:48:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,114 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-available-condition
+timeout: 600
+commands:
+  - script: kubectl -n $NAMESPACE wait --for=condition=available airflowclusters.airflow.stackable.tech/airflow --timeout 301s
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-airflow-cluster
+timeout: 1200
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-external-unstable
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-cluster-internal
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-worker-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 300
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-scheduler-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-webserver
+status:
+  expectedPods: 4
+  currentHealthy: 4
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-worker
+status:
+  expectedPods: 2
+  currentHealthy: 2
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-scheduler
+status:
+  expectedPods: 1
+  currentHealthy: 1
+  disruptionsAllowed: 1
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-cluster-internal-group
+spec:
+  type: ClusterIP # cluster-internal
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-default-group
+spec:
+  type: NodePort # external-stable
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-external-unstable-group
+spec:
+  type: NodePort # external-unstable
path: tests/templates/kuttl/external-access/40-assert.yaml
user: login: nightkr
body: Yeah, in particular you can't *rely* on that `external-stable` always creates a NodePort. For example: https://docs.stackable.tech/home/stable/listener-operator/listenerclass/#preset-ephemeral-nodes configures it to use LoadBalancer instead.
created_at: 2025-04-29T13:52:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -847,6 +827,51 @@ fn build_rolegroup_metadata(
     Ok(metadata)
 }
 
+pub fn build_group_listener(
+    airflow: &v1alpha1::AirflowCluster,
+    resolved_product_image: &ResolvedProductImage,
+    rolegroup: &RoleGroupRef<v1alpha1::AirflowCluster>,
+    listener_class: String,
+) -> Result<Listener> {
+    Ok(Listener {
+        metadata: ObjectMetaBuilder::new()
+            .name_and_namespace(airflow)
+            .name(airflow.group_listener_name(rolegroup))
+            .ownerreference_from_resource(airflow, None, Some(true))
+            .context(ObjectMissingMetadataForOwnerRefSnafu)?
+            .with_recommended_labels(build_recommended_labels(
+                airflow,
+                AIRFLOW_CONTROLLER_NAME,
+                &resolved_product_image.app_version_label,
+                &rolegroup.role,
+                &rolegroup.role_group,
+            ))
+            .context(ObjectMetaSnafu)?
+            .build(),
+        spec: ListenerSpec {
+            class_name: Some(listener_class),
+            ports: Some(listener_ports()),
+            ..ListenerSpec::default()
+        },
+        status: None,
+    })
+}
+
+fn listener_ports() -> Vec<ListenerPort> {
+    vec![
+        ListenerPort {
+            name: METRICS_PORT_NAME.to_string(),
+            port: METRICS_PORT.into(),
+            protocol: Some("TCP".to_string()),
+        },
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: Removed from listener ports: cb85a6a
created_at: 2025-04-29T13:57:33Z
author_association: MEMBER
diff_hunk: @@ -965,7 +1002,31 @@ fn build_server_rolegroup_statefulset(
         };
         airflow_container.readiness_probe(probe.clone());
         airflow_container.liveness_probe(probe);
-        airflow_container.add_container_port("http", resolved_port.into());
+        airflow_container.add_container_port(HTTP_PORT_NAME, http_port.into());
+    }
+
+    let mut pvcs: Option<Vec<PersistentVolumeClaim>> = None;
+
+    if airflow
+        .merged_listener_class(airflow_role, &rolegroup_ref.role_group)
+        .is_some()
+    {
+        // Listener endpoints for the Webserver role will use persistent volumes
+        // so that load balancers can hard-code the target addresses. This will
+        // be the case even when no class is set (and the value defaults to
+        // cluster-internal) as the address should still be consistent.
+        let pvc = ListenerOperatorVolumeSourceBuilder::new(
+            &ListenerReference::ListenerName(airflow.group_listener_name(rolegroup_ref)),
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: I re-worked this in cb85a6a but that has introduced a nested condition in another place. That is something that would be improved by including this in the overall merged config.
created_at: 2025-04-29T13:58:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,114 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-available-condition
+timeout: 600
+commands:
+  - script: kubectl -n $NAMESPACE wait --for=condition=available airflowclusters.airflow.stackable.tech/airflow --timeout 301s
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-airflow-cluster
+timeout: 1200
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-external-unstable
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-cluster-internal
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-worker-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 300
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-scheduler-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-webserver
+status:
+  expectedPods: 4
+  currentHealthy: 4
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-worker
+status:
+  expectedPods: 2
+  currentHealthy: 2
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-scheduler
+status:
+  expectedPods: 1
+  currentHealthy: 1
+  disruptionsAllowed: 1
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-cluster-internal-group
+spec:
+  type: ClusterIP # cluster-internal
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-default-group
+spec:
+  type: NodePort # external-stable
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-external-unstable-group
+spec:
+  type: NodePort # external-unstable
path: tests/templates/kuttl/external-access/40-assert.yaml
user: login: adwk67
body: Ah, I see. Hmm.
created_at: 2025-04-29T14:00:00Z
author_association: MEMBER
diff_hunk: @@ -965,7 +1002,31 @@ fn build_server_rolegroup_statefulset(
         };
         airflow_container.readiness_probe(probe.clone());
         airflow_container.liveness_probe(probe);
-        airflow_container.add_container_port("http", resolved_port.into());
+        airflow_container.add_container_port(HTTP_PORT_NAME, http_port.into());
+    }
+
+    let mut pvcs: Option<Vec<PersistentVolumeClaim>> = None;
+
+    if airflow
+        .merged_listener_class(airflow_role, &rolegroup_ref.role_group)
+        .is_some()
+    {
+        // Listener endpoints for the Webserver role will use persistent volumes
+        // so that load balancers can hard-code the target addresses. This will
+        // be the case even when no class is set (and the value defaults to
+        // cluster-internal) as the address should still be consistent.
+        let pvc = ListenerOperatorVolumeSourceBuilder::new(
+            &ListenerReference::ListenerName(airflow.group_listener_name(rolegroup_ref)),
path: rust/operator-binary/src/airflow_controller.rs
user: login: nightkr
body: We could eliminate the extra nesting by doing something like `if let (Some(foo), Some(bar)) = (build_foo(), build_bar()) {}`, but I agree that it's weird that they end up seeming uncorrelated there...
created_at: 2025-04-29T14:07:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-available-condition
+timeout: 600
+commands:
+  - script: kubectl -n $NAMESPACE wait --for=condition=available airflowclusters.airflow.stackable.tech/airflow --timeout 301s
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-airflow-cluster
+timeout: 1200
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-external-unstable
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-cluster-internal
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-worker-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 300
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-scheduler-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-webserver
+status:
+  expectedPods: 4
+  currentHealthy: 4
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-worker
+status:
+  expectedPods: 2
+  currentHealthy: 2
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-scheduler
+status:
+  expectedPods: 1
+  currentHealthy: 1
+  disruptionsAllowed: 1
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-cluster-internal-group
+spec:
+  type: ClusterIP # cluster-internal
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-default-group
+spec:
+  type: NodePort # external-stable
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-external-unstable-group
+spec:
+  type: NodePort # external-unstable
path: tests/templates/kuttl/external-access/40-assert.yaml
user: login: adwk67
body: I've added custom listener classes here to stay independent of any future changes: 57e1644
created_at: 2025-04-29T14:28:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,114 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-available-condition
+timeout: 600
+commands:
+  - script: kubectl -n $NAMESPACE wait --for=condition=available airflowclusters.airflow.stackable.tech/airflow --timeout 301s
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-airflow-cluster
+timeout: 1200
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-external-unstable
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-cluster-internal
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-worker-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 300
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-scheduler-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-webserver
+status:
+  expectedPods: 4
+  currentHealthy: 4
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-worker
+status:
+  expectedPods: 2
+  currentHealthy: 2
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-scheduler
+status:
+  expectedPods: 1
+  currentHealthy: 1
+  disruptionsAllowed: 1
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-cluster-internal-group
+spec:
+  type: ClusterIP # cluster-internal
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-default-group
+spec:
+  type: NodePort # external-stable
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-external-unstable-group
+spec:
+  type: NodePort # external-unstable
path: tests/templates/kuttl/external-access/40-assert.yaml
user: login: nightkr
body: :+1:

As per convention, those classes should include the name of the test namespace to prevent clashes when running multiple tests in parallel.
created_at: 2025-04-29T14:32:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-available-condition
+timeout: 600
+commands:
+  - script: kubectl -n $NAMESPACE wait --for=condition=available airflowclusters.airflow.stackable.tech/airflow --timeout 301s
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+metadata:
+  name: test-airflow-cluster
+timeout: 1200
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-external-unstable
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-webserver-cluster-internal
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-worker-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 300
+status:
+  readyReplicas: 2
+  replicas: 2
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: airflow-scheduler-default
+spec:
+  template:
+    spec:
+      terminationGracePeriodSeconds: 120
+status:
+  readyReplicas: 1
+  replicas: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-webserver
+status:
+  expectedPods: 4
+  currentHealthy: 4
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-worker
+status:
+  expectedPods: 2
+  currentHealthy: 2
+  disruptionsAllowed: 1
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: airflow-scheduler
+status:
+  expectedPods: 1
+  currentHealthy: 1
+  disruptionsAllowed: 1
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-cluster-internal-group
+spec:
+  type: ClusterIP # cluster-internal
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-default-group
+spec:
+  type: NodePort # external-stable
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: airflow-webserver-external-unstable-group
+spec:
+  type: NodePort # external-unstable
path: tests/templates/kuttl/external-access/40-assert.yaml
user: login: adwk67
body: Fixed ea88ae4
created_at: 2025-04-29T15:21:52Z
author_association: MEMBER
diff_hunk: @@ -847,6 +827,51 @@ fn build_rolegroup_metadata(
     Ok(metadata)
 }
 
+pub fn build_group_listener(
+    airflow: &v1alpha1::AirflowCluster,
+    resolved_product_image: &ResolvedProductImage,
+    rolegroup: &RoleGroupRef<v1alpha1::AirflowCluster>,
+    listener_class: String,
+) -> Result<Listener> {
+    Ok(Listener {
+        metadata: ObjectMetaBuilder::new()
+            .name_and_namespace(airflow)
+            .name(airflow.group_listener_name(rolegroup))
+            .ownerreference_from_resource(airflow, None, Some(true))
+            .context(ObjectMissingMetadataForOwnerRefSnafu)?
+            .with_recommended_labels(build_recommended_labels(
+                airflow,
+                AIRFLOW_CONTROLLER_NAME,
+                &resolved_product_image.app_version_label,
+                &rolegroup.role,
+                &rolegroup.role_group,
+            ))
+            .context(ObjectMetaSnafu)?
+            .build(),
+        spec: ListenerSpec {
+            class_name: Some(listener_class),
+            ports: Some(listener_ports()),
+            ..ListenerSpec::default()
+        },
+        status: None,
+    })
+}
+
+fn listener_ports() -> Vec<ListenerPort> {
+    vec![
+        ListenerPort {
+            name: METRICS_PORT_NAME.to_string(),
+            port: METRICS_PORT.into(),
+            protocol: Some("TCP".to_string()),
+        },
path: rust/operator-binary/src/airflow_controller.rs
user: login: sbernauer
body: I would prefer to keep them. Customers aren't necessarily using Prometheus within k8s, although that's the probably best solution. *If* we remove them this should be marked as breaking change in the changelog IMHO.
Maybe worth asking customers if anyone relies on this functionality before removing it.
created_at: 2025-04-29T17:36:18Z
author_association: MEMBER
diff_hunk: @@ -847,6 +827,51 @@ fn build_rolegroup_metadata(
     Ok(metadata)
 }
 
+pub fn build_group_listener(
+    airflow: &v1alpha1::AirflowCluster,
+    resolved_product_image: &ResolvedProductImage,
+    rolegroup: &RoleGroupRef<v1alpha1::AirflowCluster>,
+    listener_class: String,
+) -> Result<Listener> {
+    Ok(Listener {
+        metadata: ObjectMetaBuilder::new()
+            .name_and_namespace(airflow)
+            .name(airflow.group_listener_name(rolegroup))
+            .ownerreference_from_resource(airflow, None, Some(true))
+            .context(ObjectMissingMetadataForOwnerRefSnafu)?
+            .with_recommended_labels(build_recommended_labels(
+                airflow,
+                AIRFLOW_CONTROLLER_NAME,
+                &resolved_product_image.app_version_label,
+                &rolegroup.role,
+                &rolegroup.role_group,
+            ))
+            .context(ObjectMetaSnafu)?
+            .build(),
+        spec: ListenerSpec {
+            class_name: Some(listener_class),
+            ports: Some(listener_ports()),
+            ..ListenerSpec::default()
+        },
+        status: None,
+    })
+}
+
+fn listener_ports() -> Vec<ListenerPort> {
+    vec![
+        ListenerPort {
+            name: METRICS_PORT_NAME.to_string(),
+            port: METRICS_PORT.into(),
+            protocol: Some("TCP".to_string()),
+        },
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: If we support metrics-via-listener *now*, when introducing it, then it will be more difficult to drop support for it later on. Presumably users who are currently exposing metrics externally can continue to do so (using port-forwards, manually resolved node-IPs etc.)
created_at: 2025-04-30T06:53:51Z
author_association: MEMBER
diff_hunk: @@ -847,6 +827,51 @@ fn build_rolegroup_metadata(
     Ok(metadata)
 }
 
+pub fn build_group_listener(
+    airflow: &v1alpha1::AirflowCluster,
+    resolved_product_image: &ResolvedProductImage,
+    rolegroup: &RoleGroupRef<v1alpha1::AirflowCluster>,
+    listener_class: String,
+) -> Result<Listener> {
+    Ok(Listener {
+        metadata: ObjectMetaBuilder::new()
+            .name_and_namespace(airflow)
+            .name(airflow.group_listener_name(rolegroup))
+            .ownerreference_from_resource(airflow, None, Some(true))
+            .context(ObjectMissingMetadataForOwnerRefSnafu)?
+            .with_recommended_labels(build_recommended_labels(
+                airflow,
+                AIRFLOW_CONTROLLER_NAME,
+                &resolved_product_image.app_version_label,
+                &rolegroup.role,
+                &rolegroup.role_group,
+            ))
+            .context(ObjectMetaSnafu)?
+            .build(),
+        spec: ListenerSpec {
+            class_name: Some(listener_class),
+            ports: Some(listener_ports()),
+            ..ListenerSpec::default()
+        },
+        status: None,
+    })
+}
+
+fn listener_ports() -> Vec<ListenerPort> {
+    vec![
+        ListenerPort {
+            name: METRICS_PORT_NAME.to_string(),
+            port: METRICS_PORT.into(),
+            protocol: Some("TCP".to_string()),
+        },
path: rust/operator-binary/src/airflow_controller.rs
user: login: sbernauer
body: I guess this smells like a decision, if that is ok for you?
created_at: 2025-04-30T07:00:00Z
author_association: MEMBER
diff_hunk: @@ -847,6 +827,51 @@ fn build_rolegroup_metadata(
     Ok(metadata)
 }
 
+pub fn build_group_listener(
+    airflow: &v1alpha1::AirflowCluster,
+    resolved_product_image: &ResolvedProductImage,
+    rolegroup: &RoleGroupRef<v1alpha1::AirflowCluster>,
+    listener_class: String,
+) -> Result<Listener> {
+    Ok(Listener {
+        metadata: ObjectMetaBuilder::new()
+            .name_and_namespace(airflow)
+            .name(airflow.group_listener_name(rolegroup))
+            .ownerreference_from_resource(airflow, None, Some(true))
+            .context(ObjectMissingMetadataForOwnerRefSnafu)?
+            .with_recommended_labels(build_recommended_labels(
+                airflow,
+                AIRFLOW_CONTROLLER_NAME,
+                &resolved_product_image.app_version_label,
+                &rolegroup.role,
+                &rolegroup.role_group,
+            ))
+            .context(ObjectMetaSnafu)?
+            .build(),
+        spec: ListenerSpec {
+            class_name: Some(listener_class),
+            ports: Some(listener_ports()),
+            ..ListenerSpec::default()
+        },
+        status: None,
+    })
+}
+
+fn listener_ports() -> Vec<ListenerPort> {
+    vec![
+        ListenerPort {
+            name: METRICS_PORT_NAME.to_string(),
+            port: METRICS_PORT.into(),
+            protocol: Some("TCP".to_string()),
+        },
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: I thought we sort of already decided this last Thursday (with 5-6 of us) when we mentioned removing the metrics ports/addresses from the listener-op.
created_at: 2025-04-30T07:04:32Z
author_association: MEMBER
diff_hunk: @@ -847,6 +827,51 @@ fn build_rolegroup_metadata(
     Ok(metadata)
 }
 
+pub fn build_group_listener(
+    airflow: &v1alpha1::AirflowCluster,
+    resolved_product_image: &ResolvedProductImage,
+    rolegroup: &RoleGroupRef<v1alpha1::AirflowCluster>,
+    listener_class: String,
+) -> Result<Listener> {
+    Ok(Listener {
+        metadata: ObjectMetaBuilder::new()
+            .name_and_namespace(airflow)
+            .name(airflow.group_listener_name(rolegroup))
+            .ownerreference_from_resource(airflow, None, Some(true))
+            .context(ObjectMissingMetadataForOwnerRefSnafu)?
+            .with_recommended_labels(build_recommended_labels(
+                airflow,
+                AIRFLOW_CONTROLLER_NAME,
+                &resolved_product_image.app_version_label,
+                &rolegroup.role,
+                &rolegroup.role_group,
+            ))
+            .context(ObjectMetaSnafu)?
+            .build(),
+        spec: ListenerSpec {
+            class_name: Some(listener_class),
+            ports: Some(listener_ports()),
+            ..ListenerSpec::default()
+        },
+        status: None,
+    })
+}
+
+fn listener_ports() -> Vec<ListenerPort> {
+    vec![
+        ListenerPort {
+            name: METRICS_PORT_NAME.to_string(),
+            port: METRICS_PORT.into(),
+            protocol: Some("TCP".to_string()),
+        },
path: rust/operator-binary/src/airflow_controller.rs
user: login: sbernauer
body: Hmm yeah, I can see your point.
I can't really recall it 100%, so I'm not sure, but I *thought* that at least I was not happy with it.
But I'm happy to create a decision and not put this on you. Gathering data about customer usage would be part of it I guess.
created_at: 2025-04-30T07:24:49Z
author_association: MEMBER
diff_hunk: @@ -847,6 +827,51 @@ fn build_rolegroup_metadata(
     Ok(metadata)
 }
 
+pub fn build_group_listener(
+    airflow: &v1alpha1::AirflowCluster,
+    resolved_product_image: &ResolvedProductImage,
+    rolegroup: &RoleGroupRef<v1alpha1::AirflowCluster>,
+    listener_class: String,
+) -> Result<Listener> {
+    Ok(Listener {
+        metadata: ObjectMetaBuilder::new()
+            .name_and_namespace(airflow)
+            .name(airflow.group_listener_name(rolegroup))
+            .ownerreference_from_resource(airflow, None, Some(true))
+            .context(ObjectMissingMetadataForOwnerRefSnafu)?
+            .with_recommended_labels(build_recommended_labels(
+                airflow,
+                AIRFLOW_CONTROLLER_NAME,
+                &resolved_product_image.app_version_label,
+                &rolegroup.role,
+                &rolegroup.role_group,
+            ))
+            .context(ObjectMetaSnafu)?
+            .build(),
+        spec: ListenerSpec {
+            class_name: Some(listener_class),
+            ports: Some(listener_ports()),
+            ..ListenerSpec::default()
+        },
+        status: None,
+    })
+}
+
+fn listener_ports() -> Vec<ListenerPort> {
+    vec![
+        ListenerPort {
+            name: METRICS_PORT_NAME.to_string(),
+            port: METRICS_PORT.into(),
+            protocol: Some("TCP".to_string()),
+        },
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: That's fine by me. Maybe after a daily so we don't have to wait too long.
created_at: 2025-04-30T08:19:39Z
author_association: MEMBER
diff_hunk: @@ -847,6 +827,51 @@ fn build_rolegroup_metadata(
     Ok(metadata)
 }
 
+pub fn build_group_listener(
+    airflow: &v1alpha1::AirflowCluster,
+    resolved_product_image: &ResolvedProductImage,
+    rolegroup: &RoleGroupRef<v1alpha1::AirflowCluster>,
+    listener_class: String,
+) -> Result<Listener> {
+    Ok(Listener {
+        metadata: ObjectMetaBuilder::new()
+            .name_and_namespace(airflow)
+            .name(airflow.group_listener_name(rolegroup))
+            .ownerreference_from_resource(airflow, None, Some(true))
+            .context(ObjectMissingMetadataForOwnerRefSnafu)?
+            .with_recommended_labels(build_recommended_labels(
+                airflow,
+                AIRFLOW_CONTROLLER_NAME,
+                &resolved_product_image.app_version_label,
+                &rolegroup.role,
+                &rolegroup.role_group,
+            ))
+            .context(ObjectMetaSnafu)?
+            .build(),
+        spec: ListenerSpec {
+            class_name: Some(listener_class),
+            ports: Some(listener_ports()),
+            ..ListenerSpec::default()
+        },
+        status: None,
+    })
+}
+
+fn listener_ports() -> Vec<ListenerPort> {
+    vec![
+        ListenerPort {
+            name: METRICS_PORT_NAME.to_string(),
+            port: METRICS_PORT.into(),
+            protocol: Some("TCP".to_string()),
+        },
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: See https://github.com/stackabletech/decisions/issues/51
created_at: 2025-05-06T09:28:27Z
author_association: MEMBER
diff_hunk: @@ -280,24 +279,35 @@ impl HasStatusCondition for v1alpha1::AirflowCluster {
 }
 
 impl v1alpha1::AirflowCluster {
+    /// The name of the group-listener provided for a specific role-group.
+    /// Webservers will use this group listener so that only one load balancer
+    /// is needed (per role group).
+    pub fn group_listener_name(&self, rolegroup: &RoleGroupRef<Self>) -> String {
+        format!("{}-group", rolegroup.object_name())
path: rust/operator-binary/src/crd/mod.rs
user: login: adwk67
body: As per https://github.com/stackabletech/decisions/issues/51 and f0b5b30:

Services:
```bash
# webserver listener-class: cluster-internal
airflow-scheduler-default-metrics   ClusterIP   None            <none>        9102/TCP   74s
airflow-webserver-default           ClusterIP   10.96.163.3     <none>        8080/TCP   74s
airflow-webserver-default-metrics   ClusterIP   None            <none>        9102/TCP   74s
airflow-worker-default-metrics      ClusterIP   None            <none>        9102/TCP   74s

# webserver listener-class: external-unstable
airflow-scheduler-default-metrics   ClusterIP   None            <none>        9102/TCP         28s
airflow-webserver-default           NodePort    10.96.195.152   <none>        8080:30828/TCP   29s
airflow-webserver-default-metrics   ClusterIP   None            <none>        9102/TCP         29s
airflow-worker-default-metrics      ClusterIP   None            <none>        9102/TCP         29s
```



created_at: 2025-05-06T09:49:06Z
author_association: MEMBER
diff_hunk: @@ -280,24 +279,35 @@ impl HasStatusCondition for v1alpha1::AirflowCluster {
 }
 
 impl v1alpha1::AirflowCluster {
+    /// The name of the group-listener provided for a specific role-group.
+    /// Webservers will use this group listener so that only one load balancer
+    /// is needed (per role group).
+    pub fn group_listener_name(&self, rolegroup: &RoleGroupRef<Self>) -> String {
+        format!("{}-group", rolegroup.object_name())
path: rust/operator-binary/src/crd/mod.rs
user: login: sbernauer
body: Awesome, thanks! Also see https://github.com/stackabletech/airflow-operator/pull/604#issuecomment-2857335292
created_at: 2025-05-07T06:55:33Z
author_association: MEMBER
diff_hunk: @@ -393,6 +402,34 @@ impl v1alpha1::AirflowCluster {
         fragment::validate(conf_rolegroup).context(FragmentValidationFailureSnafu)
     }
 
+    pub fn merged_listener_class(
+        &self,
+        role: &AirflowRole,
+        rolegroup_name: &String,
+    ) -> Option<String> {
+        if role == &AirflowRole::Webserver {
+            if let Some(webservers) = self.spec.webservers.as_ref() {
+                let conf_defaults = Some("cluster-internal".to_string());
+                let mut conf_role = webservers.config.config.listener_class.to_owned();
+                let mut conf_rolegroup = webservers
+                    .role_groups
+                    .get(rolegroup_name)
+                    .map(|rg| rg.config.config.listener_class.clone())
+                    .unwrap_or_default();
+
+                conf_role.merge(&conf_defaults);
+                conf_rolegroup.merge(&conf_role);
path: rust/operator-binary/src/crd/mod.rs
user: login: nightkr
body: I'm fine with punting on this until Druid, but as long as we have a note to come back to Airflow eventually rather than letting it all diverge forever.
created_at: 2025-05-07T11:19:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -419,6 +456,39 @@ impl v1alpha1::AirflowCluster {
     }
 }
 
+fn extract_role_from_webserver_config(
+    fragment: Role<WebserverConfigFragment>,
+) -> Role<AirflowConfigFragment> {
path: rust/operator-binary/src/crd/mod.rs
user: login: nightkr
body: Sure.
created_at: 2025-05-07T11:19:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -869,15 +894,26 @@ fn build_server_rolegroup_statefulset(
     let rolegroup = role.role_groups.get(&rolegroup_ref.role_group);
 
     let mut pb = PodBuilder::new();
+    let recommended_object_labels = build_recommended_labels(
+        airflow,
+        AIRFLOW_CONTROLLER_NAME,
+        &resolved_product_image.app_version_label,
+        &rolegroup_ref.role,
+        &rolegroup_ref.role_group,
+    );
+    // Used for PVC templates that cannot be modified once they are deployed
+    let unversioned_recommended_labels = Labels::recommended(build_recommended_labels(
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: To be considered in https://github.com/stackabletech/airflow-operator/issues/622
created_at: 2025-05-07T12:23:49Z
author_association: MEMBER
diff_hunk: @@ -965,7 +1002,31 @@ fn build_server_rolegroup_statefulset(
         };
         airflow_container.readiness_probe(probe.clone());
         airflow_container.liveness_probe(probe);
-        airflow_container.add_container_port("http", resolved_port.into());
+        airflow_container.add_container_port(HTTP_PORT_NAME, http_port.into());
+    }
+
+    let mut pvcs: Option<Vec<PersistentVolumeClaim>> = None;
+
+    if airflow
+        .merged_listener_class(airflow_role, &rolegroup_ref.role_group)
+        .is_some()
+    {
+        // Listener endpoints for the Webserver role will use persistent volumes
+        // so that load balancers can hard-code the target addresses. This will
+        // be the case even when no class is set (and the value defaults to
+        // cluster-internal) as the address should still be consistent.
+        let pvc = ListenerOperatorVolumeSourceBuilder::new(
+            &ListenerReference::ListenerName(airflow.group_listener_name(rolegroup_ref)),
path: rust/operator-binary/src/airflow_controller.rs
user: login: adwk67
body: To be considered in https://github.com/stackabletech/airflow-operator/issues/622
created_at: 2025-05-07T12:23:57Z
author_association: MEMBER
diff_hunk: @@ -393,6 +402,34 @@ impl v1alpha1::AirflowCluster {
         fragment::validate(conf_rolegroup).context(FragmentValidationFailureSnafu)
     }
 
+    pub fn merged_listener_class(
+        &self,
+        role: &AirflowRole,
+        rolegroup_name: &String,
+    ) -> Option<String> {
+        if role == &AirflowRole::Webserver {
+            if let Some(webservers) = self.spec.webservers.as_ref() {
+                let conf_defaults = Some("cluster-internal".to_string());
+                let mut conf_role = webservers.config.config.listener_class.to_owned();
+                let mut conf_rolegroup = webservers
+                    .role_groups
+                    .get(rolegroup_name)
+                    .map(|rg| rg.config.config.listener_class.clone())
+                    .unwrap_or_default();
+
+                conf_role.merge(&conf_defaults);
+                conf_rolegroup.merge(&conf_role);
path: rust/operator-binary/src/crd/mod.rs
user: login: adwk67
body: To be considered in https://github.com/stackabletech/airflow-operator/issues/622
created_at: 2025-05-07T12:24:04Z
author_association: MEMBER
diff_hunk: @@ -419,6 +456,39 @@ impl v1alpha1::AirflowCluster {
     }
 }
 
+fn extract_role_from_webserver_config(
+    fragment: Role<WebserverConfigFragment>,
+) -> Role<AirflowConfigFragment> {
path: rust/operator-binary/src/crd/mod.rs
user: login: adwk67
body: To be considered in https://github.com/stackabletech/airflow-operator/issues/622
created_at: 2025-05-07T12:24:15Z
author_association: MEMBER

PR comments:
user: login: adwk67
created_at: 2025-04-28T12:27:18Z
body: Re-ran tests locally: :green_circle: 
author_association: MEMBER
user: login: sbernauer
created_at: 2025-05-07T06:54:41Z
body: Thanks @adwk67 for addressing the changes from the ADR!

I didn't look at the code, as @maltesander and @nightkr already did, but installed the operator and tried it out.
This time everything looks good to me, I would be happy to merge once the other reviewers are happy as well.

Small notes on what I checked:

Services now look good! They match the decision https://github.com/stackabletech/decisions/issues/51:

```bash
➜  airflow-operator git:(feat/integrate-listener-operator) ✗ k get svc | grep airflow                                                                                              kind-kind
airflow-scheduler-default-metrics   ClusterIP   None            <none>        9102/TCP         3m1s
airflow-webserver-default           NodePort    10.96.33.78     <none>        8080:30139/TCP   3m1s
airflow-webserver-default-metrics   ClusterIP   None            <none>        9102/TCP         3m1s
airflow-worker-default-metrics      ClusterIP   None            <none>        9102/TCP         3m1s
```

stacklet list looks nice, the metrics are not shown any more (which is fine I'd say):

```bash
➜  airflow-operator git:(feat/integrate-listener-operator) ✗ stackablectl stacklet list                                                                                            kind-kind

┌─────────┬─────────┬───────────┬─────────────────────────────────────────────────┬─────────────────────────────────┐
│ PRODUCT ┆ NAME    ┆ NAMESPACE ┆ ENDPOINTS                                       ┆ CONDITIONS                      │
╞═════════╪═════════╪═══════════╪═════════════════════════════════════════════════╪═════════════════════════════════╡
│ airflow ┆ airflow ┆ default   ┆ webserver-default-http  http://172.18.0.2:30139 ┆ Available, Reconciling, Running │
└─────────┴─────────┴───────────┴─────────────────────────────────────────────────┴─────────────────────────────────┘
```

I can also sprinkle in some more rolegroups, looks good:

```bash
➜  airflow-operator git:(feat/integrate-listener-operator) ✗ k get svc | grep airflow                                                                                              kind-kind
airflow-scheduler-default-metrics   ClusterIP   None            <none>        9102/TCP         7m55s
airflow-webserver-big               NodePort    10.96.43.47     <none>        8080:31748/TCP   15s
airflow-webserver-big-metrics       ClusterIP   None            <none>        9102/TCP         15s
airflow-webserver-default           NodePort    10.96.33.78     <none>        8080:30139/TCP   7m55s
airflow-webserver-default-metrics   ClusterIP   None            <none>        9102/TCP         7m55s
airflow-worker-default-metrics      ClusterIP   None            <none>        9102/TCP         7m55s
airflow-worker-with-gpu-metrics     ClusterIP   None            <none>        9102/TCP         15s
```

```bash
┌─────────┬─────────┬───────────┬─────────────────────────────────────────────────┬────────────────────────────────────────────┐
│ PRODUCT ┆ NAME    ┆ NAMESPACE ┆ ENDPOINTS                                       ┆ CONDITIONS                                 │
╞═════════╪═════════╪═══════════╪═════════════════════════════════════════════════╪════════════════════════════════════════════╡
│ airflow ┆ airflow ┆ default   ┆ webserver-big-http      http://172.18.0.2:31748 ┆ Unavailable: See [1], Reconciling, Running │
│         ┆         ┆           ┆ webserver-default-http  http://172.18.0.2:30139 ┆                                            │
└─────────┴─────────┴───────────┴─────────────────────────────────────────────────┴────────────────────────────────────────────
```

And the metrics services have the correct scrape label:

```bash
➜  airflow-operator git:(feat/integrate-listener-operator) ✗ k get svc -l prometheus.io/scrape=true                                                                                kind-kind
NAME                                TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
airflow-scheduler-default-metrics   ClusterIP   None         <none>        9102/TCP   17m
airflow-webserver-big-metrics       ClusterIP   None         <none>        9102/TCP   9m56s
airflow-webserver-default-metrics   ClusterIP   None         <none>        9102/TCP   17m
airflow-worker-default-metrics      ClusterIP   None         <none>        9102/TCP   17m
airflow-worker-with-gpu-metrics     ClusterIP   None         <none>        9102/TCP   9m56s
```

Afterwards I ran `stackablectl stack in monitoring --skip-release`, all metrics show up in Prometheus :)

Metrics show up, e.g.

```promql
count by (pod) (
  {job="airflow"}
)
```
shows all 8 Pods produce metrics:
![image](https://github.com/user-attachments/assets/ce930ba8-5443-4155-93e8-382afdc297d8)

author_association: MEMBER
user: login: adwk67
created_at: 2025-05-07T12:25:32Z
body: > LGTM if tests pass

Thanks - jenkins [run](https://testing.stackable.tech/view/02%20Operator%20Tests%20(custom)/job/airflow-operator-it-custom/29/), and a new issue to re-visit various things mentioned above: https://github.com/stackabletech/issues/issues/734
Hmm....run 29 failed although the previous one 28 (done before the final merge of main) passed. Seems to be due to problems pulling images.

Ran it again on a manually-provisioned replicated k3s cluster:

```
--- PASS: kuttl (2246.78s)
    --- PASS: kuttl/harness (0.00s)
        --- PASS: kuttl/harness/smoke_airflow-2.10.4_openshift-false_executor-celery (233.67s)
        --- PASS: kuttl/harness/mount-dags-configmap_airflow-latest-2.10.4_openshift-false_executor-kubernetes (128.45s)
        --- PASS: kuttl/harness/mount-dags-gitsync_airflow-latest-2.10.4_openshift-false_executor-celery (508.08s)
        --- PASS: kuttl/harness/logging_airflow-2.10.4_openshift-false_executor-kubernetes (322.02s)
        --- PASS: kuttl/harness/logging_airflow-2.10.4_openshift-false_executor-celery (291.14s)
        --- PASS: kuttl/harness/opa_airflow-2.10.4_opa-latest-1.0.1_openshift-false (129.83s)
        --- PASS: kuttl/harness/external-access_airflow-2.10.4_openshift-false (209.05s)
        --- PASS: kuttl/harness/ldap_airflow-latest-2.10.4_ldap-authentication-server-verification-tls_openshift-false_executor-celery (200.36s)
        --- PASS: kuttl/harness/orphaned-resources_airflow-latest-2.10.4_openshift-false (162.89s)
        --- PASS: kuttl/harness/overrides_airflow-latest-2.10.4_openshift-false (159.94s)
        --- PASS: kuttl/harness/ldap_airflow-latest-2.10.4_ldap-authentication-server-verification-tls_openshift-false_executor-kubernetes (155.33s)
        --- PASS: kuttl/harness/cluster-operation_airflow-latest-2.10.4_openshift-false (257.11s)
        --- PASS: kuttl/harness/ldap_airflow-latest-2.10.4_ldap-authentication-insecure-tls_openshift-false_executor-kubernetes (148.40s)
        --- PASS: kuttl/harness/ldap_airflow-latest-2.10.4_ldap-authentication-no-tls_openshift-false_executor-kubernetes (150.75s)
        --- PASS: kuttl/harness/ldap_airflow-latest-2.10.4_ldap-authentication-no-tls_openshift-false_executor-celery (199.21s)
        --- PASS: kuttl/harness/ldap_airflow-latest-2.10.4_ldap-authentication-insecure-tls_openshift-false_executor-celery (195.43s)
        --- PASS: kuttl/harness/smoke_airflow-2.10.4_openshift-false_executor-kubernetes (197.03s)
        --- PASS: kuttl/harness/oidc_airflow-2.10.4_openshift-false (153.17s)
        --- PASS: kuttl/harness/mount-dags-configmap_airflow-latest-2.10.4_openshift-false_executor-celery (177.11s)
        --- PASS: kuttl/harness/resources_airflow-latest-2.10.4_openshift-false (139.18s)
        --- PASS: kuttl/harness/mount-dags-gitsync_airflow-latest-2.10.4_openshift-false_executor-kubernetes (193.90s)
PASS
```

author_association: MEMBER