diff --git a/CHANGELOG.md b/CHANGELOG.md index cbed225d..8aadcf06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - Add `/ready` endpoint to the operator Deployment, which reports the CRD installation status ([#434]). - Add startup probe to the operator Deployment ([#434]). +- Add the `OpenShiftRoute` ListenerClass service type, which exposes Listeners through OpenShift Routes. ### Changed diff --git a/Cargo.lock b/Cargo.lock index 704cc055..b6970160 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1732,7 +1732,7 @@ dependencies = [ [[package]] name = "k8s-version" version = "0.1.3" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.119.0#ed84d456ff0b8324db3d7444659cb207564be8c9" +source = "git+https://github.com/marc-merino/operator-rs.git?branch=knit%2Fopenshift-route-listeners#72af8c1d1c6e9f9b52b41ba4541e588502406a49" dependencies = [ "darling 0.24.1", "regex", @@ -3227,7 +3227,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stackable-certs" version = "0.4.1" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.119.0#ed84d456ff0b8324db3d7444659cb207564be8c9" +source = "git+https://github.com/marc-merino/operator-rs.git?branch=knit%2Fopenshift-route-listeners#72af8c1d1c6e9f9b52b41ba4541e588502406a49" dependencies = [ "const-oid", "ecdsa", @@ -3263,6 +3263,7 @@ dependencies = [ "pin-project", "prost", "serde", + "serde_json", "serde_yaml", "snafu 0.9.2", "socket2", @@ -3294,7 +3295,7 @@ dependencies = [ [[package]] name = "stackable-operator" version = "0.119.0" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.119.0#ed84d456ff0b8324db3d7444659cb207564be8c9" +source = "git+https://github.com/marc-merino/operator-rs.git?branch=knit%2Fopenshift-route-listeners#72af8c1d1c6e9f9b52b41ba4541e588502406a49" dependencies = [ "base64 0.23.1", "clap", @@ -3338,7 +3339,7 @@ dependencies = [ [[package]] name = "stackable-operator-derive" version = "0.3.1" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.119.0#ed84d456ff0b8324db3d7444659cb207564be8c9" +source = "git+https://github.com/marc-merino/operator-rs.git?branch=knit%2Fopenshift-route-listeners#72af8c1d1c6e9f9b52b41ba4541e588502406a49" dependencies = [ "darling 0.24.1", "proc-macro2", @@ -3349,7 +3350,7 @@ dependencies = [ [[package]] name = "stackable-shared" version = "0.1.2" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.119.0#ed84d456ff0b8324db3d7444659cb207564be8c9" +source = "git+https://github.com/marc-merino/operator-rs.git?branch=knit%2Fopenshift-route-listeners#72af8c1d1c6e9f9b52b41ba4541e588502406a49" dependencies = [ "jiff", "k8s-openapi", @@ -3366,7 +3367,7 @@ dependencies = [ [[package]] name = "stackable-telemetry" version = "0.6.5" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.119.0#ed84d456ff0b8324db3d7444659cb207564be8c9" +source = "git+https://github.com/marc-merino/operator-rs.git?branch=knit%2Fopenshift-route-listeners#72af8c1d1c6e9f9b52b41ba4541e588502406a49" dependencies = [ "axum", "clap", @@ -3390,7 +3391,7 @@ dependencies = [ [[package]] name = "stackable-versioned" version = "0.11.1" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.119.0#ed84d456ff0b8324db3d7444659cb207564be8c9" +source = "git+https://github.com/marc-merino/operator-rs.git?branch=knit%2Fopenshift-route-listeners#72af8c1d1c6e9f9b52b41ba4541e588502406a49" dependencies = [ "kube", "schemars", @@ -3404,7 +3405,7 @@ dependencies = [ [[package]] name = "stackable-versioned-macros" version = "0.11.1" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.119.0#ed84d456ff0b8324db3d7444659cb207564be8c9" +source = "git+https://github.com/marc-merino/operator-rs.git?branch=knit%2Fopenshift-route-listeners#72af8c1d1c6e9f9b52b41ba4541e588502406a49" dependencies = [ "convert_case", "darling 0.24.1", @@ -3421,7 +3422,7 @@ dependencies = [ [[package]] name = "stackable-webhook" version = "0.10.0" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.119.0#ed84d456ff0b8324db3d7444659cb207564be8c9" +source = "git+https://github.com/marc-merino/operator-rs.git?branch=knit%2Fopenshift-route-listeners#72af8c1d1c6e9f9b52b41ba4541e588502406a49" dependencies = [ "arc-swap", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 364e5b3d..b788e95d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,5 +41,6 @@ tracing = "0.1.40" walkdir = "2.5.0" [patch."https://github.com/stackabletech/operator-rs.git"] +stackable-operator = { git = "https://github.com/marc-merino/operator-rs.git", branch = "knit/openshift-route-listeners" } # stackable-operator = { path = "../operator-rs/crates/stackable-operator" } # stackable-operator = { git = "https://github.com/stackabletech//operator-rs.git", branch = "main" } diff --git a/deploy/helm/listener-operator/templates/roles.yaml b/deploy/helm/listener-operator/templates/roles.yaml index b0011f7e..82afdbc7 100644 --- a/deploy/helm/listener-operator/templates/roles.yaml +++ b/deploy/helm/listener-operator/templates/roles.yaml @@ -76,6 +76,17 @@ rules: - create - patch - delete + - apiGroups: + - route.openshift.io + resources: + - routes + verbs: + - get + - list + - watch + - create + - patch + - delete # PersistentVolumes are watched to retrigger Listener reconciliation on node affinity # changes. The CSI node driver patches PV labels via Server-Side Apply to associate PVs # with their Listener. The external-provisioner sidecar creates and deletes PVs for PVC diff --git a/docs/modules/listener-operator/pages/listenerclass.adoc b/docs/modules/listener-operator/pages/listenerclass.adoc index 672aa578..c5f9e185 100644 --- a/docs/modules/listener-operator/pages/listenerclass.adoc +++ b/docs/modules/listener-operator/pages/listenerclass.adoc @@ -161,6 +161,13 @@ If a pinned node becomes unavailable, the service may become unreachable until t * **Requirements**: Kubernetes cluster must have a LoadBalancer controller * **Cost**: Cloud providers typically charge for load balancer usage +[#servicetype-openshift-route] +==== OpenShiftRoute +Exposes a single Listener port through an OpenShift Route, using a hostname assigned by OpenShift. +Set `openshiftRoute.port` to choose the port if the Listener has more than one. +By default (`openshiftRoute.tls: Passthrough`), TLS traffic is forwarded to the application on port 443; `tls: None` forwards plain HTTP on port 80. +Only hostnames that the Route has been admitted for are published. + === Advanced Configuration [#servicetype-loadbalancer-class] diff --git a/extra/crds.yaml b/extra/crds.yaml index 753531cc..932b036e 100644 --- a/extra/crds.yaml +++ b/extra/crds.yaml @@ -38,6 +38,22 @@ spec: Ignored unless serviceType is LoadBalancer. nullable: true type: string + openshiftRoute: + description: Ignored unless `serviceType` is `OpenShiftRoute`. + nullable: true + properties: + port: + description: Required when the Listener has more than one port. + nullable: true + type: string + tls: + default: Passthrough + description: '`Passthrough` (default) uses port 443; `None` serves plain HTTP on port 80.' + enum: + - Passthrough + - None + type: string + type: object pinnedNodePorts: default: false description: |- @@ -106,6 +122,7 @@ spec: - NodePort - LoadBalancer - ClusterIP + - OpenShiftRoute type: string required: - serviceType diff --git a/rust/operator-binary/Cargo.toml b/rust/operator-binary/Cargo.toml index aa1cd9cf..9e95d508 100644 --- a/rust/operator-binary/Cargo.toml +++ b/rust/operator-binary/Cargo.toml @@ -25,6 +25,7 @@ tonic.workspace = true tonic-reflection.workspace = true anyhow.workspace = true serde.workspace = true +serde_json.workspace = true serde_yaml.workspace = true snafu.workspace = true strum.workspace = true diff --git a/rust/operator-binary/src/listener_controller.rs b/rust/operator-binary/src/listener_controller.rs index a05d0cd1..d13eea4d 100644 --- a/rust/operator-binary/src/listener_controller.rs +++ b/rust/operator-binary/src/listener_controller.rs @@ -23,8 +23,8 @@ use stackable_operator::{ apimachinery::pkg::apis::meta::v1::{LabelSelector, OwnerReference}, }, kube::{ - Resource, ResourceExt, - api::{DynamicObject, ObjectMeta}, + Api, Resource, ResourceExt, + api::{ApiResource, DynamicObject, ObjectMeta}, core::{DeserializeGuard, error_boundary}, runtime::{ controller, @@ -42,6 +42,7 @@ use strum::IntoStaticStr; use crate::{ APP_NAME, OPERATOR_KEY, csi_server::node::NODE_TOPOLOGY_LABEL_HOSTNAME, + route, utils::address::{AddressCandidates, node_primary_addresses}, }; @@ -49,8 +50,11 @@ const OPERATOR_NAME: &str = "listeners.stackable.tech"; const CONTROLLER_NAME: &str = "listener"; pub const FULL_CONTROLLER_NAME: &str = concatcp!(CONTROLLER_NAME, '.', OPERATOR_NAME); -pub async fn run(client: stackable_operator::client::Client, shutdown_signal: F) -where +pub async fn run( + client: stackable_operator::client::Client, + route_resource: Option, + shutdown_signal: F, +) where F: Future + Send + Sync + 'static, { let controller = controller::Controller::new( @@ -65,7 +69,7 @@ where instance: None, }, )); - controller + let controller = controller .owns( client.get_all_api::>(), watcher::Config::default(), @@ -122,9 +126,25 @@ where .within(ns) }) }, - ) + ); + let controller = match &route_resource { + Some(resource) => controller.owns_with( + Api::::all_with(client.as_kube_client(), resource), + resource.clone(), + watcher::Config::default(), + ), + None => controller, + }; + controller .graceful_shutdown_on(shutdown_signal) - .run(reconcile, error_policy, Arc::new(Ctx { client })) + .run( + reconcile, + error_policy, + Arc::new(Ctx { + client, + route_resource, + }), + ) // We can let the reporting happen in the background .for_each_concurrent( 16, // concurrency limit @@ -143,6 +163,7 @@ where pub struct Ctx { pub client: stackable_operator::client::Client, + pub route_resource: Option, } #[derive(Debug, Snafu, IntoStaticStr)] @@ -237,6 +258,16 @@ pub enum Error { ApplyStatus { source: stackable_operator::client::Error, }, + + #[snafu(display( + "{listener_class} uses serviceType OpenShiftRoute, but the cluster does not support Routes" + ))] + RoutesNotSupported { + listener_class: ObjectRef, + }, + + #[snafu(display("failed to reconcile OpenShift Route"))] + Route { source: route::Error }, } type Result = std::result::Result; impl ReconcilerError for Error { @@ -268,6 +299,8 @@ impl ReconcilerError for Error { Self::ApplyService { source: _, svc } => Some(svc.clone().erase()), Self::DeleteOrphans { source: _ } => None, Self::ApplyStatus { source: _ } => None, + Self::RoutesNotSupported { listener_class } => Some(listener_class.clone().erase()), + Self::Route { source: _ } => None, } } } @@ -333,6 +366,24 @@ pub async fn reconcile( // Deduplicate ports by (protocol, name) .collect::>(); let svc_name = listener.metadata.name.clone().context(NoNameSnafu)?; + let route_config = match listener_class.spec.service_type { + listener::v1alpha1::ServiceType::OpenShiftRoute => { + let route_resource = + ctx.route_resource + .as_ref() + .with_context(|| RoutesNotSupportedSnafu { + listener_class: ObjectRef::from_obj(&listener_class), + })?; + let config = listener_class + .spec + .openshift_route + .clone() + .unwrap_or_default(); + let port = route::select_port(listener, &config).context(RouteSnafu)?; + Some((route_resource, port, config.tls)) + } + _ => None, + }; let mut pod_selector = listener.spec.extra_pod_selector_labels.clone(); pod_selector.extend([listener_mounted_pod_label(listener).context(ListenerPodSelectorSnafu)?]); @@ -344,24 +395,25 @@ pub async fn reconcile( .as_ref() .map(|policy| policy.to_string()), // ClusterIP services have no external traffic to apply policies to - listener::v1alpha1::ServiceType::ClusterIP => None, + listener::v1alpha1::ServiceType::ClusterIP + | listener::v1alpha1::ServiceType::OpenShiftRoute => None, }; let load_balancer_class = match listener_class.spec.service_type { listener::v1alpha1::ServiceType::LoadBalancer => { listener_class.spec.load_balancer_class.clone() } - listener::v1alpha1::ServiceType::NodePort | listener::v1alpha1::ServiceType::ClusterIP => { - None - } + listener::v1alpha1::ServiceType::NodePort + | listener::v1alpha1::ServiceType::ClusterIP + | listener::v1alpha1::ServiceType::OpenShiftRoute => None, }; let allocate_load_balancer_node_ports = match listener_class.spec.service_type { listener::v1alpha1::ServiceType::LoadBalancer => { Some(listener_class.spec.load_balancer_allocate_node_ports) } - listener::v1alpha1::ServiceType::NodePort | listener::v1alpha1::ServiceType::ClusterIP => { - None - } + listener::v1alpha1::ServiceType::NodePort + | listener::v1alpha1::ServiceType::ClusterIP + | listener::v1alpha1::ServiceType::OpenShiftRoute => None, }; let mut svc = Service { @@ -401,7 +453,8 @@ pub async fn reconcile( type_: Some(match listener_class.spec.service_type { listener::v1alpha1::ServiceType::NodePort => "NodePort".to_string(), listener::v1alpha1::ServiceType::LoadBalancer => "LoadBalancer".to_string(), - listener::v1alpha1::ServiceType::ClusterIP => "ClusterIP".to_string(), + listener::v1alpha1::ServiceType::ClusterIP + | listener::v1alpha1::ServiceType::OpenShiftRoute => "ClusterIP".to_string(), }), load_balancer_class, allocate_load_balancer_node_ports, @@ -435,6 +488,10 @@ pub async fn reconcile( ensure_existing_service_is_not_foreign(&ctx.client, &svc_name, ns, listener_uid, &svc_ref) .await?; + let desired_route = route_config + .as_ref() + .map(|(route_resource, port, tls)| route::build(route_resource, &svc, port, *tls)); + let svc = cluster_resources .add(&ctx.client, svc) .await @@ -442,10 +499,29 @@ pub async fn reconcile( let preferred_address_type = listener_class.spec.resolve_preferred_address_type(); let nodes: Vec; + let route_hosts: Vec; let kubernetes_service_fqdn: String; let addresses: Vec<(&str, listener::v1alpha1::AddressType)>; let ports: BTreeMap; match listener_class.spec.service_type { + listener::v1alpha1::ServiceType::OpenShiftRoute => { + let (route_resource, port, tls) = route_config.as_ref().expect("Route was configured"); + let route = route::apply( + ctx.client.as_kube_client(), + route_resource, + &format!("{OPERATOR_KEY}/{CONTROLLER_NAME}"), + desired_route.as_ref().expect("Route was built"), + listener_uid, + ) + .await + .context(RouteSnafu)?; + route_hosts = route::admitted_hosts(&route); + addresses = route_hosts + .iter() + .map(|host| (host.as_str(), listener::v1alpha1::AddressType::Hostname)) + .collect(); + ports = [(port.clone(), route::external_port(*tls))].into(); + } listener::v1alpha1::ServiceType::NodePort => { let node_names = node_names_for_nodeport_listener(&ctx.client, listener, ns, &svc_name).await?; @@ -555,6 +631,17 @@ pub async fn reconcile( .delete_orphaned_resources(&ctx.client) .await .context(DeleteOrphansSnafu)?; + if let (Some(route_resource), None) = (&ctx.route_resource, &route_config) { + route::delete( + ctx.client.as_kube_client(), + route_resource, + ns, + &svc_name, + listener_uid, + ) + .await + .context(RouteSnafu)?; + } ctx.client .apply_patch_status(CONTROLLER_NAME, &listener_status_meta, &listener_status) @@ -578,7 +665,7 @@ pub fn error_policy(_obj: Arc, error: &Error, _ctx: Arc) -> controlle /// Services that we previously created ourselves; refusing otherwise prevents the Listener /// primitive from being abused to clobber foreign same-named Services via the operator's elevated /// cluster-wide write permissions. -fn is_owned_by_listener(existing_owners: &[OwnerReference], listener_uid: &str) -> bool { +pub(crate) fn is_owned_by_listener(existing_owners: &[OwnerReference], listener_uid: &str) -> bool { let listener_kind = ::kind(&()); existing_owners.iter().any(|owner| { owner.controller == Some(true) diff --git a/rust/operator-binary/src/main.rs b/rust/operator-binary/src/main.rs index ebcbc5bc..0d4fdf27 100644 --- a/rust/operator-binary/src/main.rs +++ b/rust/operator-binary/src/main.rs @@ -37,6 +37,7 @@ use crate::webhooks::conversion::create_webhook_server; mod csi_server; mod listener_controller; +mod route; mod utils; mod webhooks; @@ -226,10 +227,6 @@ async fn main() -> anyhow::Result<()> { .serve_with_incoming_shutdown(csi_listener, sigterm_watcher.handle()) .map_err(|err| anyhow!(err).context("failed to run csi server")); - let controller = - listener_controller::run(client.clone(), sigterm_watcher.handle()) - .map(anyhow::Ok); - let delayed_controller = async { signal::crd_established(&client, v1alpha1::ListenerClass::crd_name()) .await?; @@ -239,7 +236,18 @@ async fn main() -> anyhow::Result<()> { pod_listeners_crd_check.mark_passed(); signal::crd_established(&client, v1alpha1::Listener::crd_name()).await?; listener_crd_check.mark_passed(); - controller.await + let route_resource = route::discover(client.as_kube_client()) + .await + .map_err(|err| { + anyhow!(err).context("failed to discover OpenShift Route API") + })?; + listener_controller::run( + client.clone(), + route_resource, + sigterm_watcher.handle(), + ) + .await; + anyhow::Ok(()) }; futures::try_join!( diff --git a/rust/operator-binary/src/route.rs b/rust/operator-binary/src/route.rs new file mode 100644 index 00000000..bb6529a5 --- /dev/null +++ b/rust/operator-binary/src/route.rs @@ -0,0 +1,236 @@ +use serde_json::json; +use snafu::{OptionExt, ResultExt, Snafu, ensure}; +use stackable_operator::{ + crd::listener::v1alpha1::{Listener, OpenShiftRouteConfig, OpenShiftRouteTls}, + k8s_openapi::api::core::v1::Service, + kube::{ + self, Api, ResourceExt, + api::{ApiResource, DeleteParams, DynamicObject, Patch, PatchParams, Preconditions}, + core::GroupVersionKind, + discovery, + }, +}; + +use crate::listener_controller::is_owned_by_listener; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display( + "the Listener has {count} ports, so the ListenerClass must select one using openshiftRoute.port" + ))] + AmbiguousPort { count: usize }, + + #[snafu(display("the Listener has no TCP port named {port:?}"))] + NoTcpPort { port: String }, + + #[snafu(display("Route has no namespace"))] + NoNamespace, + + #[snafu(display("failed to look up pre-existing Route {name:?}"))] + GetRoute { source: kube::Error, name: String }, + + #[snafu(display( + "refusing to overwrite pre-existing Route {name:?} that is not owned by this Listener" + ))] + RefuseToOverwriteForeignRoute { name: String }, + + #[snafu(display("failed to apply Route {name:?}"))] + ApplyRoute { source: kube::Error, name: String }, + + #[snafu(display("failed to delete Route {name:?}"))] + DeleteRoute { source: kube::Error, name: String }, +} +type Result = std::result::Result; + +fn gvk() -> GroupVersionKind { + GroupVersionKind::gvk("route.openshift.io", "v1", "Route") +} + +pub async fn discover(client: kube::Client) -> Result, kube::Error> { + match discovery::pinned_kind(&client, &gvk()).await { + Ok((resource, _)) => Ok(Some(resource)), + Err(kube::Error::Api(err)) if err.code == 404 => Ok(None), + Err(err) => Err(err), + } +} + +pub fn select_port(listener: &Listener, config: &OpenShiftRouteConfig) -> Result { + let ports = listener.spec.ports.iter().flatten().collect::>(); + let name = match (&config.port, ports.as_slice()) { + (Some(name), _) => name, + (None, [port]) => &port.name, + (None, ports) => return AmbiguousPortSnafu { count: ports.len() }.fail(), + }; + ports + .iter() + .find(|port| &port.name == name && port.protocol.as_deref().unwrap_or("TCP") == "TCP") + .map(|port| port.name.clone()) + .context(NoTcpPortSnafu { port: name }) +} + +pub fn external_port(tls: OpenShiftRouteTls) -> i32 { + match tls { + OpenShiftRouteTls::Passthrough => 443, + OpenShiftRouteTls::None => 80, + } +} + +pub fn build( + resource: &ApiResource, + service: &Service, + port: &str, + tls: OpenShiftRouteTls, +) -> DynamicObject { + let mut spec = json!({ + "to": {"kind": "Service", "name": service.name_any()}, + "port": {"targetPort": port}, + }); + if tls == OpenShiftRouteTls::Passthrough { + spec["tls"] = + json!({"termination": "passthrough", "insecureEdgeTerminationPolicy": "None"}); + } + let mut route = DynamicObject::new(&service.name_any(), resource).data(json!({"spec": spec})); + route.metadata.namespace = service.metadata.namespace.clone(); + route.metadata.labels = service.metadata.labels.clone(); + route.metadata.owner_references = service.metadata.owner_references.clone(); + route +} + +pub async fn apply( + client: kube::Client, + resource: &ApiResource, + field_manager: &str, + route: &DynamicObject, + listener_uid: &str, +) -> Result { + let name = route.name_any(); + let namespace = route.namespace().context(NoNamespaceSnafu)?; + let api = Api::::namespaced_with(client, &namespace, resource); + if let Some(existing) = api + .get_opt(&name) + .await + .context(GetRouteSnafu { name: &name })? + { + ensure!( + is_owned_by_listener(existing.owner_references(), listener_uid), + RefuseToOverwriteForeignRouteSnafu { name } + ); + } + api.patch( + &name, + &PatchParams::apply(field_manager).force(), + &Patch::Apply(route), + ) + .await + .context(ApplyRouteSnafu { name }) +} + +pub async fn delete( + client: kube::Client, + resource: &ApiResource, + namespace: &str, + name: &str, + listener_uid: &str, +) -> Result<()> { + let api = Api::::namespaced_with(client, namespace, resource); + if let Some(existing) = api.get_opt(name).await.context(GetRouteSnafu { name })? + && is_owned_by_listener(existing.owner_references(), listener_uid) + { + let params = DeleteParams { + preconditions: Some(Preconditions { + uid: existing.metadata.uid, + resource_version: None, + }), + ..DeleteParams::default() + }; + api.delete(name, ¶ms) + .await + .context(DeleteRouteSnafu { name })?; + } + Ok(()) +} + +pub fn admitted_hosts(route: &DynamicObject) -> Vec { + let ingresses = route.data["status"]["ingress"] + .as_array() + .into_iter() + .flatten(); + ingresses + .filter(|ingress| { + ingress["conditions"] + .as_array() + .into_iter() + .flatten() + .any(|cond| cond["type"] == "Admitted" && cond["status"] == "True") + }) + .filter_map(|ingress| ingress["host"].as_str()) + .filter(|host| !host.is_empty()) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use stackable_operator::{ + crd::listener::v1alpha1::{Listener, OpenShiftRouteConfig}, + kube::api::{ApiResource, DynamicObject}, + }; + + use super::{admitted_hosts, gvk, select_port}; + + fn listener(ports: serde_json::Value) -> Listener { + serde_json::from_value(json!({ + "apiVersion": "listeners.stackable.tech/v1alpha1", + "kind": "Listener", + "metadata": {"name": "listener"}, + "spec": {"ports": ports}, + })) + .unwrap() + } + + fn config(port: Option<&str>) -> OpenShiftRouteConfig { + OpenShiftRouteConfig { + port: port.map(str::to_string), + ..Default::default() + } + } + + #[test] + fn select_port_defaults_to_only_port() { + let listener = listener(json!([{"name": "https", "port": 8443}])); + assert_eq!(select_port(&listener, &config(None)).unwrap(), "https"); + } + + #[test] + fn select_port_requires_explicit_port_if_ambiguous() { + let listener = listener(json!([ + {"name": "https", "port": 8443}, + {"name": "metrics", "port": 9090}, + ])); + assert!(select_port(&listener, &config(None)).is_err()); + assert_eq!( + select_port(&listener, &config(Some("https"))).unwrap(), + "https" + ); + assert!(select_port(&listener, &config(Some("http"))).is_err()); + } + + #[test] + fn select_port_rejects_udp() { + let listener = listener(json!([{"name": "dns", "port": 53, "protocol": "UDP"}])); + assert!(select_port(&listener, &config(None)).is_err()); + } + + #[test] + fn admitted_hosts_ignores_unadmitted_ingresses() { + let route = DynamicObject::new("listener", &ApiResource::from_gvk(&gvk())).data(json!({ + "status": {"ingress": [ + {"host": "admitted.example.com", "conditions": [{"type": "Admitted", "status": "True"}]}, + {"host": "rejected.example.com", "conditions": [{"type": "Admitted", "status": "False"}]}, + {"host": "pending.example.com"}, + ]}, + })); + assert_eq!(admitted_hosts(&route), ["admitted.example.com"]); + } +} diff --git a/tests/templates/kuttl/openshift-route/00-patch-ns.yaml.j2 b/tests/templates/kuttl/openshift-route/00-patch-ns.yaml.j2 new file mode 100644 index 00000000..67185acf --- /dev/null +++ b/tests/templates/kuttl/openshift-route/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/openshift-route/10-assert.yaml.j2 b/tests/templates/kuttl/openshift-route/10-assert.yaml.j2 new file mode 100644 index 00000000..e3555d59 --- /dev/null +++ b/tests/templates/kuttl/openshift-route/10-assert.yaml.j2 @@ -0,0 +1,32 @@ +{% if test_scenario['values']['openshift'] == 'true' %} +--- +apiVersion: v1 +kind: Service +metadata: + name: listener +spec: + type: ClusterIP +--- +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: listener +spec: + to: + kind: Service + name: listener + port: + targetPort: https + tls: + termination: passthrough +--- +apiVersion: listeners.stackable.tech/v1alpha1 +kind: Listener +metadata: + name: listener +status: + ingressAddresses: + - addressType: Hostname + ports: + https: 443 +{% endif %} diff --git a/tests/templates/kuttl/openshift-route/10-create-listener.yaml.j2 b/tests/templates/kuttl/openshift-route/10-create-listener.yaml.j2 new file mode 100644 index 00000000..14cc7605 --- /dev/null +++ b/tests/templates/kuttl/openshift-route/10-create-listener.yaml.j2 @@ -0,0 +1,7 @@ +{% if test_scenario['values']['openshift'] == 'true' %} +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: envsubst '$NAMESPACE' < 10_listener.yaml | kubectl apply -n $NAMESPACE -f - +{% endif %} diff --git a/tests/templates/kuttl/openshift-route/10_listener.yaml b/tests/templates/kuttl/openshift-route/10_listener.yaml new file mode 100644 index 00000000..a30b94cb --- /dev/null +++ b/tests/templates/kuttl/openshift-route/10_listener.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: listeners.stackable.tech/v1alpha1 +kind: ListenerClass +metadata: + name: listener-operator-test-route-$NAMESPACE +spec: + serviceType: OpenShiftRoute +--- +apiVersion: listeners.stackable.tech/v1alpha1 +kind: Listener +metadata: + name: listener +spec: + className: listener-operator-test-route-$NAMESPACE + ports: + - name: https + port: 8443 diff --git a/tests/templates/kuttl/openshift-route/20-assert.yaml.j2 b/tests/templates/kuttl/openshift-route/20-assert.yaml.j2 new file mode 100644 index 00000000..854b841a --- /dev/null +++ b/tests/templates/kuttl/openshift-route/20-assert.yaml.j2 @@ -0,0 +1,12 @@ +{% if test_scenario['values']['openshift'] == 'true' %} +--- +apiVersion: listeners.stackable.tech/v1alpha1 +kind: Listener +metadata: + name: listener +status: + ingressAddresses: + - addressType: Hostname + ports: + https: 80 +{% endif %} diff --git a/tests/templates/kuttl/openshift-route/20-disable-tls.yaml.j2 b/tests/templates/kuttl/openshift-route/20-disable-tls.yaml.j2 new file mode 100644 index 00000000..dbac7ac2 --- /dev/null +++ b/tests/templates/kuttl/openshift-route/20-disable-tls.yaml.j2 @@ -0,0 +1,7 @@ +{% if test_scenario['values']['openshift'] == 'true' %} +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: kubectl patch listenerclass listener-operator-test-route-$NAMESPACE --type merge -p '{"spec":{"openshiftRoute":{"tls":"None"}}}' +{% endif %} diff --git a/tests/templates/kuttl/openshift-route/20-errors.yaml.j2 b/tests/templates/kuttl/openshift-route/20-errors.yaml.j2 new file mode 100644 index 00000000..2b3f677d --- /dev/null +++ b/tests/templates/kuttl/openshift-route/20-errors.yaml.j2 @@ -0,0 +1,10 @@ +{% if test_scenario['values']['openshift'] == 'true' %} +--- +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: listener +spec: + tls: + termination: passthrough +{% endif %} diff --git a/tests/templates/kuttl/openshift-route/30-assert.yaml.j2 b/tests/templates/kuttl/openshift-route/30-assert.yaml.j2 new file mode 100644 index 00000000..5b5ceced --- /dev/null +++ b/tests/templates/kuttl/openshift-route/30-assert.yaml.j2 @@ -0,0 +1,12 @@ +{% if test_scenario['values']['openshift'] == 'true' %} +--- +apiVersion: listeners.stackable.tech/v1alpha1 +kind: Listener +metadata: + name: listener +status: + ingressAddresses: + - addressType: Hostname + ports: + https: 8443 +{% endif %} diff --git a/tests/templates/kuttl/openshift-route/30-errors.yaml.j2 b/tests/templates/kuttl/openshift-route/30-errors.yaml.j2 new file mode 100644 index 00000000..cb82da56 --- /dev/null +++ b/tests/templates/kuttl/openshift-route/30-errors.yaml.j2 @@ -0,0 +1,7 @@ +{% if test_scenario['values']['openshift'] == 'true' %} +--- +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: listener +{% endif %} diff --git a/tests/templates/kuttl/openshift-route/30-switch-to-clusterip.yaml.j2 b/tests/templates/kuttl/openshift-route/30-switch-to-clusterip.yaml.j2 new file mode 100644 index 00000000..8deb2ed4 --- /dev/null +++ b/tests/templates/kuttl/openshift-route/30-switch-to-clusterip.yaml.j2 @@ -0,0 +1,7 @@ +{% if test_scenario['values']['openshift'] == 'true' %} +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: kubectl patch listenerclass listener-operator-test-route-$NAMESPACE --type merge -p '{"spec":{"serviceType":"ClusterIP"}}' +{% endif %} diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml index fbfd17e3..37b0a731 100644 --- a/tests/test-definition.yaml +++ b/tests/test-definition.yaml @@ -25,6 +25,9 @@ tests: - loadbalancer-allocatenodeports - name: overrides dimensions: [] + - name: openshift-route + dimensions: + - openshift suites: - name: nightly - name: openshift