feat(bascule-agent): replace soft-mode attestation with ConfigMap posture reader
Replace hardcoded posture return in AttestationHandler (Shellstream namespace 0x0005) with PostureReader that reads the posture-current ConfigMap written by the substrate-operator's posture evaluator. Data pipeline is now end-to-end: Keylime verifier -> posture evaluator -> ConfigMap -> bascule-agent Behavior: - posture_source='config': reads posture-current ConfigMap, maps level to PostureLevel, caches with configurable TTL (default 30s) - posture_source='static' or dev_mode: returns configured static level and wire value (replaces hardcoded string for clarity) - Graceful fallback: missing ConfigMap -> PostureLevel::Lockdown (fail-closed) + warning log New dependencies: kube, k8s-openapi, governance-types (via path). Does NOT add keylime-client — reads ConfigMap JSON directly. Signed-off-by: Tyler King <tking@guildhouse.dev> Signed-off-by: Tyler J King <tking727@gmail.com>
This commit is contained in:
parent
e3fb2a9a58
commit
47a5484614
6 changed files with 416 additions and 17 deletions
16
Cargo.lock
generated
16
Cargo.lock
generated
|
|
@ -7,6 +7,7 @@ name = "accord-core"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"governance-types",
|
||||
"serde",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
|
|
@ -390,9 +391,12 @@ dependencies = [
|
|||
"chrono",
|
||||
"clap",
|
||||
"dashmap",
|
||||
"governance-types",
|
||||
"hex",
|
||||
"hfl-types",
|
||||
"jsonwebtoken",
|
||||
"k8s-openapi",
|
||||
"kube",
|
||||
"rand",
|
||||
"reqwest",
|
||||
"rmp-serde",
|
||||
|
|
@ -689,6 +693,7 @@ dependencies = [
|
|||
"accord-core",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"governance-types",
|
||||
"hex",
|
||||
"registry-protocol",
|
||||
"serde",
|
||||
|
|
@ -1600,6 +1605,17 @@ dependencies = [
|
|||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governance-types"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ async-trait = { workspace = true }
|
|||
# Cross-workspace path deps — substrate crates
|
||||
substrate-rt = { path = "../../substrate/crates/substrate-rt" }
|
||||
hfl-types = { path = "../../substrate/crates/hfl-types", features = ["serde", "agent-extensions"] }
|
||||
governance-types = { path = "../../substrate/crates/governance-types" }
|
||||
|
||||
# Kubernetes (for posture ConfigMap reader)
|
||||
kube = { workspace = true }
|
||||
k8s-openapi = { workspace = true }
|
||||
|
||||
# Msgpack — retained for convenience constructors and legacy decode paths
|
||||
rmp-serde = "1"
|
||||
|
|
|
|||
|
|
@ -131,11 +131,29 @@ pub struct SecretsNamespaceConfig {
|
|||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct AttestationNamespaceConfig {
|
||||
/// Source of posture data: `"config"` (ConfigMap) or `"static"` (hardcoded).
|
||||
#[serde(default = "default_posture_source")]
|
||||
pub posture_source: String,
|
||||
|
||||
/// Static posture level name when `posture_source = "static"`.
|
||||
#[serde(default = "default_posture_level")]
|
||||
pub default_posture: String,
|
||||
|
||||
/// Static posture wire value (1-5) when `posture_source = "static"`.
|
||||
#[serde(default = "default_static_posture_wire")]
|
||||
pub static_posture_wire: u8,
|
||||
|
||||
/// ConfigMap name to read posture from (default: `"posture-current"`).
|
||||
#[serde(default = "default_configmap_name")]
|
||||
pub configmap_name: String,
|
||||
|
||||
/// Namespace to read ConfigMap from (default: `"guildhouse-infra"`).
|
||||
#[serde(default = "default_configmap_namespace")]
|
||||
pub configmap_namespace: String,
|
||||
|
||||
/// Cache TTL in seconds for ConfigMap reads (default: 30).
|
||||
#[serde(default = "default_cache_ttl_secs")]
|
||||
pub cache_ttl_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
|
@ -197,7 +215,19 @@ fn default_posture_source() -> String {
|
|||
"config".into()
|
||||
}
|
||||
fn default_posture_level() -> String {
|
||||
"standard".into()
|
||||
"normal".into()
|
||||
}
|
||||
fn default_static_posture_wire() -> u8 {
|
||||
5 // Normal
|
||||
}
|
||||
fn default_configmap_name() -> String {
|
||||
"posture-current".into()
|
||||
}
|
||||
fn default_configmap_namespace() -> String {
|
||||
std::env::var("BASCULE_NAMESPACE").unwrap_or_else(|_| "guildhouse-infra".into())
|
||||
}
|
||||
fn default_cache_ttl_secs() -> u64 {
|
||||
30
|
||||
}
|
||||
fn default_scope() -> String {
|
||||
"operate".into()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ mod command_filter;
|
|||
mod config;
|
||||
mod governance_server;
|
||||
mod namespace;
|
||||
mod posture_reader;
|
||||
mod session_store;
|
||||
mod shellstream;
|
||||
mod ssh_server;
|
||||
|
|
@ -90,12 +91,33 @@ async fn main() -> anyhow::Result<()> {
|
|||
Namespace::Governance,
|
||||
Arc::new(namespace::governance::GovernanceHandler::new(dev_mode)),
|
||||
);
|
||||
router.register(
|
||||
Namespace::Attestation,
|
||||
Arc::new(namespace::attestation::AttestationHandler::new(
|
||||
let attestation_handler = if dev_mode
|
||||
|| config.agent.namespaces.attestation.posture_source == "static"
|
||||
{
|
||||
info!(
|
||||
level = %config.agent.namespaces.attestation.default_posture,
|
||||
wire = config.agent.namespaces.attestation.static_posture_wire,
|
||||
"Attestation handler: static mode"
|
||||
);
|
||||
namespace::attestation::AttestationHandler::new_static(
|
||||
config.agent.namespaces.attestation.default_posture.clone(),
|
||||
)),
|
||||
);
|
||||
config.agent.namespaces.attestation.static_posture_wire,
|
||||
)
|
||||
} else {
|
||||
info!(
|
||||
configmap = %config.agent.namespaces.attestation.configmap_name,
|
||||
namespace = %config.agent.namespaces.attestation.configmap_namespace,
|
||||
cache_ttl = config.agent.namespaces.attestation.cache_ttl_secs,
|
||||
"Attestation handler: ConfigMap mode"
|
||||
);
|
||||
let reader = posture_reader::PostureReader::new(
|
||||
config.agent.namespaces.attestation.configmap_namespace.clone(),
|
||||
config.agent.namespaces.attestation.configmap_name.clone(),
|
||||
config.agent.namespaces.attestation.cache_ttl_secs,
|
||||
);
|
||||
namespace::attestation::AttestationHandler::new_configmap(Arc::new(reader))
|
||||
};
|
||||
router.register(Namespace::Attestation, Arc::new(attestation_handler));
|
||||
router.register(
|
||||
Namespace::Audit,
|
||||
Arc::new(namespace::audit::AuditHandler::new()),
|
||||
|
|
|
|||
|
|
@ -1,18 +1,52 @@
|
|||
// Copyright 2026 Guildhouse Dev
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! ATTESTATION namespace (0x0005) — posture level + soft SAT generation.
|
||||
//!
|
||||
//! Two operating modes controlled by `posture_source` in config:
|
||||
//! - `"static"` — returns a configured posture level (dev/test, no cluster)
|
||||
//! - `"config"` — reads the `posture-current` ConfigMap via [`PostureReader`]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::shellstream::{attestation, ShellstreamResponse};
|
||||
use super::NamespaceHandler;
|
||||
use crate::posture_reader::PostureReader;
|
||||
use crate::shellstream::{attestation, ShellstreamResponse};
|
||||
|
||||
/// Source of posture data for the attestation handler.
|
||||
pub enum PostureSource {
|
||||
/// Static posture level for dev/test (no cluster required).
|
||||
Static {
|
||||
level_name: String,
|
||||
level_wire: u8,
|
||||
},
|
||||
/// Read posture from the posture-current ConfigMap.
|
||||
ConfigMap { reader: Arc<PostureReader> },
|
||||
}
|
||||
|
||||
pub struct AttestationHandler {
|
||||
default_posture: String,
|
||||
source: PostureSource,
|
||||
}
|
||||
|
||||
impl AttestationHandler {
|
||||
pub fn new(default_posture: String) -> Self {
|
||||
Self { default_posture }
|
||||
/// Create a handler with static posture level (for dev/test mode).
|
||||
pub fn new_static(level_name: String, level_wire: u8) -> Self {
|
||||
Self {
|
||||
source: PostureSource::Static {
|
||||
level_name,
|
||||
level_wire,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a handler backed by the posture-current ConfigMap.
|
||||
pub fn new_configmap(reader: Arc<PostureReader>) -> Self {
|
||||
Self {
|
||||
source: PostureSource::ConfigMap { reader },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -38,10 +72,30 @@ impl NamespaceHandler for AttestationHandler {
|
|||
}
|
||||
|
||||
impl AttestationHandler {
|
||||
/// Resolve the current posture level and source label.
|
||||
async fn get_posture_info(&self) -> (String, u8, &'static str) {
|
||||
match &self.source {
|
||||
PostureSource::Static {
|
||||
level_name,
|
||||
level_wire,
|
||||
} => (level_name.clone(), *level_wire, "static"),
|
||||
PostureSource::ConfigMap { reader } => {
|
||||
let (level, source) = reader.get_posture().await;
|
||||
let name = serde_json::to_value(level)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
(name, level.to_wire(), source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn posture(&self, session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let (level_name, level_wire, source) = self.get_posture_info().await;
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"level": self.default_posture,
|
||||
"source": "config",
|
||||
"level": level_name,
|
||||
"level_wire": level_wire,
|
||||
"source": source,
|
||||
"timestamp": Utc::now().to_rfc3339(),
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
|
|
@ -49,26 +103,115 @@ impl AttestationHandler {
|
|||
}
|
||||
|
||||
async fn sat_bundle(&self, session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
// Soft SAT: a JSON bundle with session info (not cryptographically bound)
|
||||
let (level_name, _, source) = self.get_posture_info().await;
|
||||
let bundle_id = uuid::Uuid::new_v4().to_string();
|
||||
let soft_mode = matches!(&self.source, PostureSource::Static { .. });
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"bundle_id": bundle_id,
|
||||
"session_id": hex::encode(session_id),
|
||||
"posture": self.default_posture,
|
||||
"posture": level_name,
|
||||
"issued_at": Utc::now().to_rfc3339(),
|
||||
"soft_mode": true,
|
||||
"soft_mode": soft_mode,
|
||||
"source": source,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn verify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
// Soft mode: always returns valid (no TPM to verify against)
|
||||
// Verify remains soft-mode until real TPM binding is implemented
|
||||
let soft_mode = matches!(&self.source, PostureSource::Static { .. });
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"valid": true,
|
||||
"soft_mode": true,
|
||||
"soft_mode": soft_mode,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn static_handler_returns_configured_level() {
|
||||
let handler = AttestationHandler::new_static("elevated".into(), 4);
|
||||
let session_id = [0u8; 16];
|
||||
let resp = handler.handle(attestation::POSTURE, &[], &session_id).await;
|
||||
assert_eq!(resp.status, 0x00); // Ok
|
||||
let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap();
|
||||
assert_eq!(value["level"], "elevated");
|
||||
assert_eq!(value["level_wire"], 4);
|
||||
assert_eq!(value["source"], "static");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn static_sat_bundle_includes_posture() {
|
||||
let handler = AttestationHandler::new_static("normal".into(), 5);
|
||||
let session_id = [0xAA; 16];
|
||||
let resp = handler
|
||||
.handle(attestation::SAT_BUNDLE, &[], &session_id)
|
||||
.await;
|
||||
assert_eq!(resp.status, 0x00);
|
||||
let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap();
|
||||
assert_eq!(value["posture"], "normal");
|
||||
assert_eq!(value["soft_mode"], true);
|
||||
assert!(value["bundle_id"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn static_verify_returns_soft_mode() {
|
||||
let handler = AttestationHandler::new_static("normal".into(), 5);
|
||||
let session_id = [0u8; 16];
|
||||
let resp = handler
|
||||
.handle(attestation::ATTESTATION_VERIFY, &[], &session_id)
|
||||
.await;
|
||||
assert_eq!(resp.status, 0x00);
|
||||
let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap();
|
||||
assert_eq!(value["valid"], true);
|
||||
assert_eq!(value["soft_mode"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configmap_handler_falls_back_without_cluster() {
|
||||
let reader = Arc::new(PostureReader::new(
|
||||
"test-ns".into(),
|
||||
"posture-current".into(),
|
||||
30,
|
||||
));
|
||||
let handler = AttestationHandler::new_configmap(reader);
|
||||
let session_id = [0u8; 16];
|
||||
let resp = handler.handle(attestation::POSTURE, &[], &session_id).await;
|
||||
assert_eq!(resp.status, 0x00);
|
||||
let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap();
|
||||
// Without a cluster, falls back to lockdown
|
||||
assert_eq!(value["level"], "lockdown");
|
||||
assert_eq!(value["level_wire"], 1);
|
||||
assert_eq!(value["source"], "fallback");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configmap_verify_not_soft_mode() {
|
||||
let reader = Arc::new(PostureReader::new(
|
||||
"test-ns".into(),
|
||||
"posture-current".into(),
|
||||
30,
|
||||
));
|
||||
let handler = AttestationHandler::new_configmap(reader);
|
||||
let session_id = [0u8; 16];
|
||||
let resp = handler
|
||||
.handle(attestation::ATTESTATION_VERIFY, &[], &session_id)
|
||||
.await;
|
||||
let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap();
|
||||
assert_eq!(value["soft_mode"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_function_returns_error() {
|
||||
let handler = AttestationHandler::new_static("normal".into(), 5);
|
||||
let session_id = [0u8; 16];
|
||||
let resp = handler.handle(0xFFFF, &[], &session_id).await;
|
||||
assert_eq!(resp.status, 0x01); // Error
|
||||
}
|
||||
}
|
||||
|
|
|
|||
183
bascule-agent/src/posture_reader.rs
Normal file
183
bascule-agent/src/posture_reader.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
// Copyright 2026 Guildhouse Dev
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Reads posture state from the `posture-current` ConfigMap.
|
||||
//!
|
||||
//! The ConfigMap is written by the posture evaluator in substrate-operator
|
||||
//! and contains the cluster's operational posture level. This module does
|
||||
//! NOT depend on `keylime-client` — it reads the ConfigMap JSON directly
|
||||
//! to keep the dependency graph clean.
|
||||
|
||||
use governance_types::PostureLevel;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Reads posture level from the `posture-current` ConfigMap.
|
||||
///
|
||||
/// Caches the last successful read for a configurable TTL to avoid
|
||||
/// hammering the Kubernetes API server on every Shellstream POSTURE
|
||||
/// request.
|
||||
pub struct PostureReader {
|
||||
client: tokio::sync::OnceCell<kube::Client>,
|
||||
namespace: String,
|
||||
configmap_name: String,
|
||||
cache: RwLock<Option<CachedPosture>>,
|
||||
cache_ttl: Duration,
|
||||
}
|
||||
|
||||
struct CachedPosture {
|
||||
level: PostureLevel,
|
||||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
impl PostureReader {
|
||||
pub fn new(namespace: String, configmap_name: String, cache_ttl_secs: u64) -> Self {
|
||||
Self {
|
||||
client: tokio::sync::OnceCell::new(),
|
||||
namespace,
|
||||
configmap_name,
|
||||
cache: RwLock::new(None),
|
||||
cache_ttl: Duration::from_secs(cache_ttl_secs),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current posture level.
|
||||
///
|
||||
/// Returns `(PostureLevel, source_label)` where source_label is one of:
|
||||
/// - `"configmap"` — fresh read from Kubernetes
|
||||
/// - `"configmap-cached"` — served from cache within TTL
|
||||
/// - `"configmap-stale"` — cache expired but fresh read failed
|
||||
/// - `"fallback"` — no data available, returning Lockdown (fail-closed)
|
||||
pub async fn get_posture(&self) -> (PostureLevel, &'static str) {
|
||||
// Check cache first
|
||||
{
|
||||
let cache = self.cache.read().await;
|
||||
if let Some(c) = cache.as_ref() {
|
||||
if c.fetched_at.elapsed() < self.cache_ttl {
|
||||
return (c.level, "configmap-cached");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or expired — read from ConfigMap
|
||||
match self.read_from_configmap().await {
|
||||
Ok(level) => {
|
||||
debug!(
|
||||
level = ?level,
|
||||
configmap = %self.configmap_name,
|
||||
"posture read from ConfigMap"
|
||||
);
|
||||
*self.cache.write().await = Some(CachedPosture {
|
||||
level,
|
||||
fetched_at: Instant::now(),
|
||||
});
|
||||
(level, "configmap")
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
error = %e,
|
||||
configmap = %self.configmap_name,
|
||||
namespace = %self.namespace,
|
||||
"posture ConfigMap read failed"
|
||||
);
|
||||
// Serve stale cache if available, otherwise fail-closed
|
||||
let cache = self.cache.read().await;
|
||||
match cache.as_ref() {
|
||||
Some(c) => (c.level, "configmap-stale"),
|
||||
None => (PostureLevel::Lockdown, "fallback"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_from_configmap(&self) -> Result<PostureLevel, String> {
|
||||
let client = self
|
||||
.client
|
||||
.get_or_try_init(|| async {
|
||||
kube::Client::try_default()
|
||||
.await
|
||||
.map_err(|e| format!("kube client init: {e}"))
|
||||
})
|
||||
.await?;
|
||||
|
||||
use k8s_openapi::api::core::v1::ConfigMap;
|
||||
use kube::api::Api;
|
||||
|
||||
let api: Api<ConfigMap> = Api::namespaced(client.clone(), &self.namespace);
|
||||
let cm = api
|
||||
.get(&self.configmap_name)
|
||||
.await
|
||||
.map_err(|e| format!("get '{}': {e}", self.configmap_name))?;
|
||||
|
||||
let level_str = cm
|
||||
.data
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("level"))
|
||||
.ok_or_else(|| "ConfigMap missing 'level' key".to_string())?;
|
||||
|
||||
let level_u8: u8 = level_str
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid level value: '{level_str}'"))?;
|
||||
|
||||
PostureLevel::from_wire(level_u8)
|
||||
.ok_or_else(|| format!("level {level_u8} out of range 1-5"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn posture_level_wire_mapping() {
|
||||
// Verify the mapping we rely on from governance-types
|
||||
assert_eq!(PostureLevel::from_wire(1), Some(PostureLevel::Lockdown));
|
||||
assert_eq!(PostureLevel::from_wire(5), Some(PostureLevel::Normal));
|
||||
assert_eq!(PostureLevel::from_wire(0), None);
|
||||
assert_eq!(PostureLevel::from_wire(6), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posture_level_serde_names() {
|
||||
// Verify the snake_case names we use in Shellstream responses
|
||||
let json = serde_json::to_value(PostureLevel::Normal).unwrap();
|
||||
assert_eq!(json, "normal");
|
||||
|
||||
let json = serde_json::to_value(PostureLevel::Lockdown).unwrap();
|
||||
assert_eq!(json, "lockdown");
|
||||
|
||||
let json = serde_json::to_value(PostureLevel::Restricted).unwrap();
|
||||
assert_eq!(json, "restricted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reader_returns_fallback_without_cluster() {
|
||||
let reader = PostureReader::new(
|
||||
"test-ns".into(),
|
||||
"posture-current".into(),
|
||||
30,
|
||||
);
|
||||
// No kube cluster available — should fall back to Lockdown
|
||||
let (level, source) = reader.get_posture().await;
|
||||
assert_eq!(level, PostureLevel::Lockdown);
|
||||
assert_eq!(source, "fallback");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reader_caches_fallback_result() {
|
||||
// Verify that multiple calls don't spam warnings
|
||||
let reader = PostureReader::new(
|
||||
"test-ns".into(),
|
||||
"posture-current".into(),
|
||||
30,
|
||||
);
|
||||
let (l1, s1) = reader.get_posture().await;
|
||||
// The fallback isn't cached (it's returned inline), but the kube
|
||||
// client OnceCell retries on failure, so this will attempt again
|
||||
let (l2, _s2) = reader.get_posture().await;
|
||||
assert_eq!(l1, l2);
|
||||
assert_eq!(l1, PostureLevel::Lockdown);
|
||||
assert_eq!(s1, "fallback");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue