use std::time::Duration;
use kube::Api;
use serde::de::DeserializeOwned;
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WatchErrorClass {
NamespaceOrRbacUnready,
SchemaSkew,
}
pub(crate) const fn classify_watch_error(err: &kube::Error) -> WatchErrorClass {
match err {
kube::Error::SerdeError(_) => WatchErrorClass::SchemaSkew,
_ => WatchErrorClass::NamespaceOrRbacUnready,
}
}
fn log_watch_error(namespace: &str, kind: &str, err: &kube::Error) {
match classify_watch_error(err) {
WatchErrorClass::SchemaSkew => {
tracing::error!(
%namespace,
%kind,
error = %err,
"fan-out watch response failed to decode: the installed CRD schema is \
older than what this binary expects (schema skew, not a namespace/RBAC \
problem); apply the current CRDs with `kubectl apply -k manifests/crds`; \
retrying in 5m"
);
}
WatchErrorClass::NamespaceOrRbacUnready => {
tracing::warn!(
%namespace,
%kind,
error = %err,
"fan-out watch not available (apps namespace/RBAC unprovisioned? \
apply manifests/apps-namespace); retrying in 5m"
);
}
}
}
pub(crate) const CHECK_RETRY_INTERVAL: Duration = Duration::from_mins(5);
pub async fn await_watchable<K>(api: &Api<K>, namespace: &str, kind: &str)
where
K: Clone + DeserializeOwned + std::fmt::Debug,
{
loop {
match api.list(&kube::api::ListParams::default().limit(1)).await {
Ok(_) => break,
Err(err) => {
log_watch_error(namespace, kind, &err);
tokio::time::sleep(CHECK_RETRY_INTERVAL).await;
}
}
}
}
pub async fn await_watchable_cancellable<K>(
api: &Api<K>,
namespace: &str,
kind: &str,
shutdown: &CancellationToken,
) -> bool
where
K: Clone + DeserializeOwned + std::fmt::Debug,
{
loop {
let params = kube::api::ListParams::default().limit(1);
tokio::select! {
biased;
() = shutdown.cancelled() => return false,
res = api.list(¶ms) => match res {
Ok(_) => return true,
Err(err) => {
log_watch_error(namespace, kind, &err);
tokio::select! {
biased;
() = shutdown.cancelled() => return false,
() = tokio::time::sleep(CHECK_RETRY_INTERVAL) => {}
}
}
},
}
}
}
pub(crate) fn labels(name: &str, lineage_key: &str) -> std::collections::BTreeMap<String, String> {
std::collections::BTreeMap::from([
("app.kubernetes.io/name".to_owned(), name.to_owned()),
(MANAGED_BY_KEY.to_owned(), MANAGED_BY_VALUE.to_owned()),
(lineage_key.to_owned(), name.to_owned()),
])
}
pub(crate) const MANAGED_BY_KEY: &str = "app.kubernetes.io/managed-by";
pub(crate) const MANAGED_BY_VALUE: &str = "polychrome";
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use std::time::Duration;
use http::StatusCode;
use k8s_openapi::api::core::v1::ConfigMap;
use kube::Api;
use tokio_util::sync::CancellationToken;
use super::{WatchErrorClass, await_watchable_cancellable, classify_watch_error};
fn always_erroring_client() -> kube::Client {
let svc = tower::service_fn(|_req: http::Request<kube::client::Body>| async move {
let payload = serde_json::to_vec(&serde_json::json!({
"apiVersion": "v1",
"kind": "Status",
"status": "Failure",
"reason": "InternalError",
"code": 500,
}))
.unwrap();
let resp = http::Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("content-type", "application/json")
.body(kube::client::Body::from(payload))
.expect("build fake response");
Ok::<_, std::convert::Infallible>(resp)
});
kube::Client::new(svc, "test-ns")
}
fn always_listable_client() -> kube::Client {
let svc = tower::service_fn(|_req: http::Request<kube::client::Body>| async move {
let payload = serde_json::to_vec(&serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMapList",
"items": [],
}))
.unwrap();
let resp = http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json")
.body(kube::client::Body::from(payload))
.expect("build fake response");
Ok::<_, std::convert::Infallible>(resp)
});
kube::Client::new(svc, "test-ns")
}
fn schema_skewed_client() -> kube::Client {
let svc = tower::service_fn(|_req: http::Request<kube::client::Body>| async move {
let payload = serde_json::to_vec(&serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMapList",
"items": ["not an object"],
}))
.unwrap();
let resp = http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json")
.body(kube::client::Body::from(payload))
.expect("build fake response");
Ok::<_, std::convert::Infallible>(resp)
});
kube::Client::new(svc, "test-ns")
}
#[tokio::test(start_paused = true)]
async fn cancellation_during_the_retry_sleep_returns_false_promptly() {
let api: Api<ConfigMap> = Api::namespaced(always_erroring_client(), "test-ns");
let shutdown = CancellationToken::new();
let shutdown_task = shutdown.clone();
let task = tokio::spawn(async move {
await_watchable_cancellable(&api, "test-ns", "ConfigMap", &shutdown_task).await
});
tokio::time::sleep(Duration::from_millis(10)).await;
shutdown.cancel();
let watchable = tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("cancellation must return promptly, never wait out the 5-minute retry sleep")
.expect("task did not panic");
assert!(
!watchable,
"shutdown fired before the watch ever became serveable"
);
}
#[tokio::test]
async fn a_serveable_watch_returns_true_without_ever_sleeping() {
let api: Api<ConfigMap> = Api::namespaced(always_listable_client(), "test-ns");
let shutdown = CancellationToken::new();
let watchable = await_watchable_cancellable(&api, "test-ns", "ConfigMap", &shutdown).await;
assert!(watchable, "a successful list means the watch is serveable");
}
#[tokio::test(start_paused = true)]
async fn cancellation_during_a_schema_skew_retry_sleep_returns_false_promptly() {
let api: Api<ConfigMap> = Api::namespaced(schema_skewed_client(), "test-ns");
let shutdown = CancellationToken::new();
let shutdown_task = shutdown.clone();
let task = tokio::spawn(async move {
await_watchable_cancellable(&api, "test-ns", "ConfigMap", &shutdown_task).await
});
tokio::time::sleep(Duration::from_millis(10)).await;
shutdown.cancel();
let watchable = tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("cancellation must return promptly even mid schema-skew retry")
.expect("task did not panic");
assert!(
!watchable,
"shutdown fired before a skewed watch ever became serveable"
);
}
#[test]
fn a_body_decode_failure_classifies_as_schema_skew() {
let json_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
let err = kube::Error::SerdeError(json_err);
assert_eq!(classify_watch_error(&err), WatchErrorClass::SchemaSkew);
}
#[test]
fn an_api_error_response_classifies_as_namespace_or_rbac_unready() {
let status = kube::core::Status {
code: 403,
reason: "Forbidden".to_owned(),
..Default::default()
};
let err = kube::Error::Api(Box::new(status));
assert_eq!(
classify_watch_error(&err),
WatchErrorClass::NamespaceOrRbacUnready
);
}
}