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 `-webserver` (where `` 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///archive/.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 = std::result::Result; @@ -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 { - 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 { - vec![ServicePort { - name: Some("http".to_string()), - port: port.into(), - protocol: Some("TCP".to_string()), - ..ServicePort::default() - }] -} - -fn role_port(role_name: &str) -> Option { - 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, ) -> Result { - 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, prometheus_label: Label, + name: String, ) -> Result { 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, + listener_class: String, + listener_group_name: String, +) -> Result { + 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 { + 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> = 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 }, + #[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>, + pub webservers: Option>, /// 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, + ) -> Option { + 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> { + pub fn get_role(&self, role: &AirflowRole) -> Option> { 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 { + self.get_role(role).map(|r| r.role_config) } pub fn volumes(&self) -> &Vec { @@ -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 { + 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, +) -> Role { + 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 { 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, } +#[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