Skip to content

Commit dc63ba4

Browse files
authored
Change forbidden watcher to drop, not degrade (#178)
Signed-off-by: Daniel Guns <danbguns@gmail.com>
1 parent 0a68f43 commit dc63ba4

2 files changed

Lines changed: 103 additions & 3 deletions

File tree

src/kube/api.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,20 @@ pub fn is_version_missing_error(error: &str) -> bool {
387387
|| lower.contains("page not found")
388388
}
389389

390+
/// Returns true if an error string indicates the user is forbidden (RBAC 403)
391+
/// from watching this resource type.
392+
///
393+
/// A 403 means the credentials are valid but lack permission to list/watch the
394+
/// resource — e.g. RBAC denies access to a particular CRD. Unlike a transient
395+
/// outage, this won't recover by retrying within the session, so watchers treat
396+
/// it like a missing CRD: log it once and stop, rather than flagging the
397+
/// "watch degraded" banner. kube-rs surfaces these as
398+
/// "... is forbidden: User \"...\" cannot list resource ..." (HTTP 403).
399+
pub fn is_forbidden_error(error: &str) -> bool {
400+
let lower = error.to_lowercase();
401+
lower.contains("forbidden") || lower.contains("403")
402+
}
403+
390404
#[cfg(test)]
391405
mod tests {
392406
use super::*;
@@ -483,6 +497,28 @@ mod tests {
483497
assert!(!is_version_missing_error("unauthorized: token expired"));
484498
}
485499

500+
#[test]
501+
fn test_is_forbidden_error_rbac_message() {
502+
// The message kube-rs surfaces when RBAC denies list/watch on a CRD
503+
assert!(is_forbidden_error(
504+
"failed to perform initial watch: Api error: artifactgenerators.swp.fluxcd.io is forbidden: User \"u\" cannot list resource \"artifactgenerators\" in API group \"swp.fluxcd.io\" at the cluster scope"
505+
));
506+
}
507+
508+
#[test]
509+
fn test_is_forbidden_error_numeric_code() {
510+
assert!(is_forbidden_error("error 403 from server"));
511+
}
512+
513+
#[test]
514+
fn test_is_forbidden_error_unrelated_error() {
515+
assert!(!is_forbidden_error("connection refused"));
516+
assert!(!is_forbidden_error(
517+
"the server could not find the requested resource"
518+
));
519+
assert!(!is_forbidden_error("timeout waiting for response"));
520+
}
521+
486522
fn api_error(code: u16, message: &str) -> kube::Error {
487523
kube::Error::Api(Box::new(
488524
kube::core::Status::failure(message, "NotFound").with_code(code),

src/watcher/mod.rs

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ use std::time::Duration;
2323
use tokio::sync::mpsc;
2424
use tokio::task::JoinHandle;
2525

26-
use crate::kube::api::{get_flux_api_resources_with_fallback, is_version_missing_error};
26+
use crate::kube::api::{
27+
get_flux_api_resources_with_fallback, is_forbidden_error, is_version_missing_error,
28+
};
2729
use crate::models::FluxResourceKind;
2830

2931
/// Maximum interval between watch-reconnect attempts.
@@ -217,7 +219,8 @@ impl ResourceWatcher {
217219
let ev = match event {
218220
Ok(ev) => ev,
219221
Err(e) => {
220-
if is_version_missing_error(&format!("{}", e)) {
222+
let err_str = format!("{}", e);
223+
if is_version_missing_error(&err_str) {
221224
// CRD not installed or version not served — stop, don't retry.
222225
// Clear any degraded state: this watcher is intentionally
223226
// stopping, not reconnecting.
@@ -236,6 +239,26 @@ impl ResourceWatcher {
236239
break;
237240
}
238241

242+
if is_forbidden_error(&err_str) {
243+
// RBAC denies access to this resource — retrying won't help
244+
// within the session, so stop rather than flag the watch
245+
// as degraded. Clear any degraded state from earlier errors.
246+
if error_count > 0 {
247+
let _ = event_tx
248+
.send(WatchEvent::WatcherRecovered(display_name.clone()));
249+
}
250+
tracing::info!(
251+
"{} watch forbidden by RBAC, stopping watcher: {}",
252+
display_name,
253+
e
254+
);
255+
let _ = event_tx.send(WatchEvent::Error(format!(
256+
"{} watch forbidden by RBAC: {}",
257+
display_name, e
258+
)));
259+
break;
260+
}
261+
239262
error_count += 1;
240263
if error_count == 1 {
241264
// First error after healthy operation: flag as degraded
@@ -331,6 +354,16 @@ impl ResourceWatcher {
331354
let ev = match event {
332355
Ok(ev) => ev,
333356
Err(e) => {
357+
if is_forbidden_error(&format!("{}", e)) {
358+
// RBAC denies access — retrying won't help, so stop rather
359+
// than flag the watch as degraded. Clear earlier degraded state.
360+
if error_count > 0 {
361+
let _ = event_tx
362+
.send(WatchEvent::WatcherRecovered(WATCHER_NAME.to_string()));
363+
}
364+
tracing::info!("Pod watcher forbidden by RBAC, stopping: {}", e);
365+
break;
366+
}
334367
error_count += 1;
335368
if error_count == 1 {
336369
let _ = event_tx
@@ -394,6 +427,16 @@ impl ResourceWatcher {
394427
let ev = match event {
395428
Ok(ev) => ev,
396429
Err(e) => {
430+
if is_forbidden_error(&format!("{}", e)) {
431+
// RBAC denies access — retrying won't help, so stop rather
432+
// than flag the watch as degraded. Clear earlier degraded state.
433+
if error_count > 0 {
434+
let _ = event_tx
435+
.send(WatchEvent::WatcherRecovered(WATCHER_NAME.to_string()));
436+
}
437+
tracing::info!("Deployment watcher forbidden by RBAC, stopping: {}", e);
438+
break;
439+
}
397440
error_count += 1;
398441
if error_count == 1 {
399442
let _ = event_tx
@@ -490,7 +533,8 @@ impl ResourceWatcher {
490533
let ev = match w.next().await {
491534
Some(Ok(ev)) => ev,
492535
Some(Err(e)) => {
493-
if is_version_missing_error(&format!("{}", e)) && !version_working {
536+
let err_str = format!("{}", e);
537+
if is_version_missing_error(&err_str) && !version_working {
494538
tracing::debug!(
495539
"{} version {} not available, trying next version",
496540
display_name,
@@ -499,6 +543,26 @@ impl ResourceWatcher {
499543
break; // Try next version
500544
}
501545

546+
if is_forbidden_error(&err_str) {
547+
// RBAC denies access to this resource — every version
548+
// would be forbidden too, so stop rather than flag the
549+
// watch as degraded. Clear any degraded state first.
550+
if degraded_sent {
551+
let _ = event_tx
552+
.send(WatchEvent::WatcherRecovered(resource_type.clone()));
553+
}
554+
tracing::info!(
555+
"{} watch forbidden by RBAC, stopping watcher: {}",
556+
display_name,
557+
e
558+
);
559+
let _ = event_tx.send(WatchEvent::Error(format!(
560+
"{} watch forbidden by RBAC: {}",
561+
display_name, e
562+
)));
563+
return;
564+
}
565+
502566
error_count += 1;
503567
if !degraded_sent {
504568
degraded_sent = true;

0 commit comments

Comments
 (0)