use std::{collections::HashMap, fmt::Debug, sync::Arc, time::Duration};
use futures_util::StreamExt;
use k8s_openapi::{
api::{
core::v1::{Secret, Service},
discovery::v1::EndpointSlice,
networking::v1::{Ingress, IngressClass},
},
NamespaceResourceScope,
};
use kube::{
api::{Patch, PatchParams},
runtime::{
events::{Event, EventType, Recorder, Reporter},
reflector, watcher, WatchStreamExt,
},
Api, Client, Resource,
};
use serde::de::DeserializeOwned;
use tokio::{sync::Notify, task::JoinHandle};
use crate::{
config::kubernetes::KubernetesConfig,
routing::{RouteSnapshot, RouteSourceKind, SnapshotSender},
};
use super::{
certificates::parse,
convert::{hosts, of_class, Cluster, Converted, HostCertificate, Invalid, AUTH_LABEL},
status,
};
const SETTLE: Duration = Duration::from_millis(500);
const STATUS_INTERVAL: Duration = Duration::from_secs(30);
const STATUS_LEASE: &str = "kubernetes:status";
pub async fn run(config: KubernetesConfig, sender: SnapshotSender) {
tracing::info!(
"watching Ingresses of class {:?} in {}",
config.ingress_class,
if config.namespaces.is_empty() {
"all namespaces".to_string()
} else {
format!("namespaces {:?}", config.namespaces)
}
);
loop {
if let Err(err) = watch(&config, &sender).await {
tracing::warn!("kubernetes: {err}, retrying in 10 seconds");
}
tokio::time::sleep(Duration::from_secs(10)).await;
}
}
async fn watch(config: &KubernetesConfig, sender: &SnapshotSender) -> Result<(), String> {
let client = Client::try_default()
.await
.map_err(|err| format!("failed to connect to the cluster: {err}"))?;
let changed = Arc::new(Notify::new());
let all = watcher::Config::default();
let ingresses = Watched::<Ingress>::namespaced(&client, &config.namespaces, &all, &changed);
let services = Watched::<Service>::namespaced(&client, &config.namespaces, &all, &changed);
let endpoint_slices =
Watched::<EndpointSlice>::namespaced(&client, &config.namespaces, &all, &changed);
let tls = watcher::Config::default().fields("type=kubernetes.io/tls");
let secrets = Watched::<Secret>::namespaced(&client, &config.namespaces, &tls, &changed);
let auth = watcher::Config::default().labels(AUTH_LABEL);
let auth_secrets = Watched::<Secret>::namespaced(&client, &config.namespaces, &auth, &changed);
let ingress_classes = Watched::<IngressClass>::cluster(&client, &changed);
ingresses.ready().await?;
services.ready().await?;
endpoint_slices.ready().await?;
secrets.ready().await?;
auth_secrets.ready().await?;
ingress_classes.ready().await?;
let recorder = Recorder::new(
client.clone(),
Reporter {
controller: "proksi".to_string(),
instance: Some(crate::stores::hostname()),
},
);
let mut reported: Vec<Invalid> = vec![];
let mut certificates = HashMap::new();
loop {
let cluster = Cluster {
ingresses: ingresses.state(),
ingress_classes: ingress_classes.state(),
services: services.state(),
endpoint_slices: endpoint_slices.state(),
secrets: [secrets.state(), auth_secrets.state()].concat(),
};
let Converted {
hosts,
certificates: wanted,
invalid,
} = hosts(&cluster, &config.ingress_class);
if invalid != reported {
for new in invalid.iter().filter(|invalid| !reported.contains(invalid)) {
tracing::warn!("ingress {}: {}", new.ingress, new.reason);
record(&recorder, &cluster, new).await;
}
reported = invalid;
}
apply_certificates(&wanted, &mut certificates).await;
sender
.send(RouteSnapshot::new(RouteSourceKind::Kubernetes, hosts))
.await
.map_err(|_| "the routing service stopped".to_string())?;
if let Some(publish) = &config.publish_service {
let served = of_class(&cluster, &config.ingress_class);
if let Err(err) = publish_status(&client, publish, &served).await {
tracing::warn!("kubernetes: failed to update the status of Ingresses: {err}");
}
}
tokio::select! {
() = changed.notified() => {}
() = tokio::time::sleep(STATUS_INTERVAL) => {}
}
tokio::time::sleep(SETTLE).await;
}
}
async fn publish_status(
client: &Client,
publish: &str,
ingresses: &[&Arc<Ingress>],
) -> Result<(), String> {
let store = crate::stores::global::get_store();
let leader = store
.try_lock(STATUS_LEASE, crate::stores::replica_id(), STATUS_INTERVAL * 3)
.await
.unwrap_or(true);
if !leader {
return Ok(());
}
let (namespace, name) = publish
.split_once('/')
.ok_or_else(|| format!("publish_service must be namespace/name, not {publish:?}"))?;
let service = Api::<Service>::namespaced(client.clone(), namespace)
.get(name)
.await
.map_err(|err| format!("failed to read service {publish}: {err}"))?;
let addresses = status::addresses(&service);
let ingresses = ingresses.iter().map(|ingress| &***ingress).collect::<Vec<&Ingress>>();
for (namespace, name) in status::outdated(&ingresses, &addresses) {
let patch = serde_json::json!({ "status": { "loadBalancer": { "ingress": addresses } } });
Api::<Ingress>::namespaced(client.clone(), &namespace)
.patch_status(&name, &PatchParams::default(), &Patch::Merge(&patch))
.await
.map_err(|err| format!("{namespace}/{name}: {err}"))?;
}
Ok(())
}
async fn record(recorder: &Recorder, cluster: &Cluster, invalid: &Invalid) {
let Some(ingress) = cluster.ingresses.iter().find(|ingress| {
let id = format!(
"{}/{}",
ingress.metadata.namespace.as_deref().unwrap_or_default(),
ingress.metadata.name.as_deref().unwrap_or_default()
);
id == invalid.ingress
}) else {
return;
};
let event = Event {
type_: EventType::Warning,
reason: "Invalid".to_string(),
note: Some(invalid.reason.clone()),
action: "Serving".to_string(),
secondary: None,
};
if let Err(err) = recorder.publish(&event, &ingress.object_ref(&())).await {
tracing::debug!("kubernetes: failed to record an event: {err}");
}
}
async fn apply_certificates(
certificates: &[HostCertificate],
applied: &mut HashMap<String, (String, Option<String>)>,
) {
let store = crate::stores::global::get_store();
for certificate in certificates {
let host = certificate.host.to_lowercase();
let version = (certificate.secret.clone(), certificate.version.clone());
if applied.get(&host) == Some(&version) {
continue;
}
match parse(&certificate.certificate, &certificate.key) {
Ok(parsed) => {
if let Err(err) = store.set_certificate(&host, parsed).await {
tracing::warn!("failed to store the certificate for {host}: {err}");
continue;
}
tracing::info!("certificate for {host} from secret {}", certificate.secret);
}
Err(err) => tracing::warn!("secret {} (for {host}): {err}", certificate.secret),
}
applied.insert(host, version);
}
}
struct Watched<K: Resource + 'static>
where
K::DynamicType: std::hash::Hash + Eq + Clone,
{
stores: Vec<reflector::Store<K>>,
tasks: Vec<JoinHandle<()>>,
}
impl<K> Watched<K>
where
K: Resource + Clone + DeserializeOwned + Debug + Send + Sync + 'static,
K::DynamicType: Default + std::hash::Hash + Eq + Clone,
{
fn namespaced(
client: &Client,
namespaces: &[String],
config: &watcher::Config,
changed: &Arc<Notify>,
) -> Self
where
K: Resource<Scope = NamespaceResourceScope>,
{
let apis = if namespaces.is_empty() {
vec![Api::<K>::all(client.clone())]
} else {
namespaces
.iter()
.map(|namespace| Api::<K>::namespaced(client.clone(), namespace))
.collect()
};
Self::watch(apis, config, changed)
}
fn cluster(client: &Client, changed: &Arc<Notify>) -> Self {
let config = watcher::Config::default();
Self::watch(vec![Api::<K>::all(client.clone())], &config, changed)
}
fn watch(apis: Vec<Api<K>>, config: &watcher::Config, changed: &Arc<Notify>) -> Self {
let mut stores = vec![];
let mut tasks = vec![];
for api in apis {
let (store, writer) = reflector::store();
let changed = changed.clone();
let stream = reflector(writer, watcher(api, config.clone())).default_backoff();
tasks.push(tokio::spawn(stream.for_each(move |event| {
if let Err(err) = &event {
tracing::warn!("kubernetes: watching failed, retrying: {err}");
}
changed.notify_one();
std::future::ready(())
})));
stores.push(store);
}
Self { stores, tasks }
}
async fn ready(&self) -> Result<(), String> {
for store in &self.stores {
store
.wait_until_ready()
.await
.map_err(|err| format!("watching stopped: {err}"))?;
}
Ok(())
}
fn state(&self) -> Vec<Arc<K>> {
self.stores
.iter()
.flat_map(reflector::Store::state)
.collect()
}
}
impl<K: Resource + 'static> Drop for Watched<K>
where
K::DynamicType: std::hash::Hash + Eq + Clone,
{
fn drop(&mut self) {
for task in &self.tasks {
task.abort();
}
}
}