Skip to content

Commit ce73049

Browse files
committed
feat: add linux agent data into metrics-cache
CMK-36613
1 parent e30af15 commit ce73049

8 files changed

Lines changed: 267 additions & 11 deletions

File tree

metrics-cache/src/handlers/ingest.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use axum::extract::Path;
12
use axum::{Json, extract::State};
23
use std::sync::Arc;
34
use std::time::Instant;
@@ -22,3 +23,101 @@ pub async fn kubelet_stats_summary(
2223
.await;
2324
Json("ok".to_string())
2425
}
26+
27+
/// Store the raw check_mk_agent output for a node, verbatim, keyed by node
28+
/// name. No parsing/validation is done here or by the caller.
29+
pub async fn linux_agent(
30+
State(state): State<AppState<impl TokenValidator>>,
31+
Path(node_name): Path<String>,
32+
body: String,
33+
) -> Json<String> {
34+
state
35+
.linux_agent_cache
36+
.insert(node_name, Arc::new(body))
37+
.await;
38+
Json("ok".to_string())
39+
}
40+
41+
#[cfg(test)]
42+
mod tests {
43+
use axum::body::Body;
44+
use axum::http::{Request, StatusCode};
45+
use k8s_openapi::api::authentication::v1::{TokenReview, TokenReviewStatus, UserInfo};
46+
use tower::ServiceExt;
47+
48+
use crate::auth::pull_agent::PullAgentMiddlewareConfig;
49+
use crate::handlers::app;
50+
use crate::state::tests::{MockValidator, test_app_state_with_validator};
51+
52+
fn no_pull_agent() -> PullAgentMiddlewareConfig {
53+
PullAgentMiddlewareConfig {
54+
auth_enabled: false,
55+
shared_secret: None,
56+
}
57+
}
58+
59+
fn authenticated_review(username: &str) -> TokenReview {
60+
TokenReview {
61+
status: Some(TokenReviewStatus {
62+
authenticated: Some(true),
63+
user: Some(UserInfo {
64+
username: Some(username.to_string()),
65+
..Default::default()
66+
}),
67+
..Default::default()
68+
}),
69+
..Default::default()
70+
}
71+
}
72+
73+
#[tokio::test]
74+
async fn linux_agent_ingest_populates_cache_and_returns_ok() {
75+
let state = test_app_state_with_validator(MockValidator {
76+
response: Ok(authenticated_review(
77+
"system:serviceaccount:test-ns:test-writer",
78+
)),
79+
});
80+
let cache = state.linux_agent_cache.clone();
81+
let app = app(state, no_pull_agent());
82+
83+
let resp = app
84+
.oneshot(
85+
Request::builder()
86+
.method("POST")
87+
.uri("/ingest/linux_agent/node-1")
88+
.header("Authorization", "Bearer test-token")
89+
.body(Body::from("<<<check_mk>>>\nVersion: 2.5.0\n"))
90+
.unwrap(),
91+
)
92+
.await
93+
.unwrap();
94+
95+
assert_eq!(resp.status(), StatusCode::OK);
96+
cache.run_pending_tasks().await;
97+
assert_eq!(
98+
cache.get("node-1").await.map(|v| (*v).clone()),
99+
Some("<<<check_mk>>>\nVersion: 2.5.0\n".to_string())
100+
);
101+
}
102+
103+
#[tokio::test]
104+
async fn linux_agent_ingest_requires_auth() {
105+
let state = test_app_state_with_validator(MockValidator {
106+
response: Ok(TokenReview::default()),
107+
});
108+
let app = app(state, no_pull_agent());
109+
110+
let resp = app
111+
.oneshot(
112+
Request::builder()
113+
.method("POST")
114+
.uri("/ingest/linux_agent/node-1")
115+
.body(Body::from("<<<check_mk>>>\n"))
116+
.unwrap(),
117+
)
118+
.await
119+
.unwrap();
120+
121+
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
122+
}
123+
}

metrics-cache/src/handlers/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub fn ingest_app<V: TokenValidator>(state: AppState<V>) -> Router {
3636
"/kubelet_stats_summary",
3737
post(ingest::kubelet_stats_summary),
3838
)
39+
.route("/linux_agent/{node_name}", post(ingest::linux_agent))
3940
.route_layer(middleware::from_fn_with_state(
4041
state.clone(),
4142
auth::kubernetes::authenticate,

metrics-cache/src/state.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub struct AppState<V: TokenValidator> {
2222
pub reader_allowlist: Vec<String>,
2323
pub writer_allowlist: Vec<String>,
2424
pub kubelet_stats_summary_cache: Cache<String, Arc<MetricsFetcherIngestion<StatsSummary>>>,
25+
pub linux_agent_cache: Cache<String, Arc<String>>,
2526
pub host_settings: Arc<HostSettings>,
2627
}
2728

@@ -47,6 +48,9 @@ impl AppState<Client> {
4748
kubelet_stats_summary_cache: Cache::builder()
4849
.max_capacity(MAX_SUPPORTED_KUBERNETES_NODES)
4950
.build(),
51+
linux_agent_cache: Cache::builder()
52+
.max_capacity(MAX_SUPPORTED_KUBERNETES_NODES)
53+
.build(),
5054
host_settings: host_settings.into(),
5155
};
5256
Ok(state)
@@ -101,6 +105,10 @@ pub mod tests {
101105
.time_to_live(Duration::from_secs(120))
102106
.max_capacity(10000)
103107
.build(),
108+
linux_agent_cache: Cache::builder()
109+
.time_to_live(Duration::from_secs(120))
110+
.max_capacity(10000)
111+
.build(),
104112
host_settings: HostSettings {
105113
cluster_name: "testcluster".to_string(),
106114
cluster_host_name: "testclusterhost".to_string(),

metrics-fetcher/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ clap = { workspace = true }
1414
reqwest = { version = "0.13.3", features = ["blocking", "query", "json", "rustls-no-provider", "http2", "system-proxy"], default-features = false }
1515
rustls = { workspace = true }
1616
thiserror = { workspace = true }
17-
tokio = { workspace = true }
17+
tokio = { workspace = true, features = ["process"] }
1818
tracing = { workspace = true }
1919
tracing-subscriber = { workspace = true }
2020

metrics-fetcher/src/error.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,13 @@ pub(crate) enum Error {
1414
#[source]
1515
source: std::env::VarError,
1616
},
17+
#[error("check_mk_agent timed out after {0:?}")]
18+
AgentTimeout(std::time::Duration),
19+
#[error("check_mk_agent exited with {status}: {stderr}")]
20+
AgentExitStatus {
21+
status: std::process::ExitStatus,
22+
stderr: String,
23+
},
24+
#[error("check_mk_agent produced empty output")]
25+
AgentEmptyOutput,
1726
}

metrics-fetcher/src/linux_agent.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
use bytes::Bytes;
2+
use reqwest::Client;
3+
use std::process::Stdio;
4+
use std::sync::Arc;
5+
use tokio::process::Command;
6+
use tokio::time::{Duration, timeout};
7+
use tracing::{debug, trace};
8+
9+
use crate::cli_args::CliArgs;
10+
use crate::error::{Error, Result};
11+
use crate::payload::Payload;
12+
use crate::scraper::Scraper;
13+
14+
const AGENT_PATH: &str = "/usr/local/bin/check_mk_agent";
15+
const AGENT_TIMEOUT: Duration = Duration::from_secs(5);
16+
17+
pub(crate) struct LinuxAgentScraper {
18+
relay_client: Client,
19+
args: Arc<CliArgs>,
20+
}
21+
22+
impl LinuxAgentScraper {
23+
pub(crate) fn new(args: Arc<CliArgs>, metrics_cache_client: Client) -> LinuxAgentScraper {
24+
LinuxAgentScraper {
25+
relay_client: metrics_cache_client,
26+
args,
27+
}
28+
}
29+
}
30+
31+
impl Scraper for LinuxAgentScraper {
32+
fn relay_client(&self) -> Client {
33+
self.relay_client.clone()
34+
}
35+
36+
fn args(&self) -> Arc<CliArgs> {
37+
self.args.clone()
38+
}
39+
40+
/// Run the local check_mk_agent script and capture its raw stdout.
41+
///
42+
/// We do not parse the output or perform any calculations on it here,
43+
/// leaving these tasks for metrics-cache (and later, Checkmk itself) to do.
44+
async fn scrape(&self) -> Result<Payload> {
45+
let node_name = match std::env::var("NODE_NAME") {
46+
Ok(val) => val,
47+
Err(err) => {
48+
return Err(Error::EnvVar {
49+
name: "NODE_NAME".to_string(),
50+
source: err,
51+
});
52+
}
53+
};
54+
55+
debug!("running check_mk_agent");
56+
let child = Command::new(AGENT_PATH)
57+
.env("PYTHONDONTWRITEBYTECODE", "1")
58+
.stdin(Stdio::null())
59+
.stdout(Stdio::piped())
60+
.stderr(Stdio::piped())
61+
.kill_on_drop(true)
62+
.spawn()?;
63+
64+
let output = match timeout(AGENT_TIMEOUT, child.wait_with_output()).await {
65+
Ok(result) => result?,
66+
Err(_elapsed) => return Err(Error::AgentTimeout(AGENT_TIMEOUT)),
67+
};
68+
69+
if !output.status.success() {
70+
return Err(Error::AgentExitStatus {
71+
status: output.status,
72+
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
73+
});
74+
}
75+
76+
if output.stdout.is_empty() {
77+
return Err(Error::AgentEmptyOutput);
78+
}
79+
80+
trace!(bytes = output.stdout.len(), "check_mk_agent run complete");
81+
Ok(Payload::CheckmkLinuxAgent {
82+
node_name,
83+
body: Bytes::from(output.stdout),
84+
})
85+
}
86+
}

metrics-fetcher/src/main.rs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod cli_args;
22
mod error;
33
mod kubelet_stats_summary;
4+
mod linux_agent;
45
mod payload;
56
mod scraper;
67

@@ -13,6 +14,7 @@ use tracing_subscriber::EnvFilter;
1314

1415
use crate::cli_args::CliArgs;
1516
use crate::kubelet_stats_summary::KubeletStatsSummaryScraper;
17+
use crate::linux_agent::LinuxAgentScraper;
1618
use crate::scraper::Scraper;
1719

1820
#[tokio::main]
@@ -30,19 +32,33 @@ async fn main() -> Result<()> {
3032
.install_default()
3133
.expect("Failed to install rustls crypto provider");
3234

33-
// Client to communicate with metrics cache; we allocate it just once, up front.
35+
// Client to communicate with metrics cache; we allocate it just once, up front,
36+
// and share it between scrapers.
3437
let metrics_cache_client = match args.metrics_cache_ca_cert_file.as_deref() {
3538
Some(file) => {
3639
let pem = tokio::fs::read(file).await?;
3740
let ca = reqwest::Certificate::from_pem(&pem)?;
3841
ClientBuilder::new().tls_certs_only([ca])
3942
}
4043
None => ClientBuilder::new(),
41-
};
44+
}
45+
.build()?;
4246

47+
let args = Arc::new(args);
4348
let kubelet_stats_summary_scraper =
44-
KubeletStatsSummaryScraper::new(Arc::new(args), metrics_cache_client.build()?);
49+
KubeletStatsSummaryScraper::new(args.clone(), metrics_cache_client.clone());
50+
let linux_agent_scraper = LinuxAgentScraper::new(args.clone(), metrics_cache_client);
4551
let kubelet_scrape = tokio::spawn(kubelet_stats_summary_scraper.loop_push_scrape());
46-
let _ = tokio::try_join!(kubelet_scrape);
47-
Ok(())
52+
let linux_agent_scrape = tokio::spawn(linux_agent_scraper.loop_push_scrape());
53+
54+
tokio::select! {
55+
res = kubelet_scrape => {
56+
tracing::error!(error = ?res, "kubelet stats summary scrape loop exited unexpectedly");
57+
anyhow::bail!("kubelet stats summary scrape loop terminated unexpectedly");
58+
}
59+
res = linux_agent_scrape => {
60+
tracing::error!(error = ?res, "linux agent scrape loop exited unexpectedly");
61+
anyhow::bail!("linux agent scrape loop terminated unexpectedly");
62+
}
63+
}
4864
}

metrics-fetcher/src/payload.rs

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,28 @@ use crate::error::Result;
77
#[derive(Debug)]
88
pub(crate) enum Payload {
99
KubeletStatsSummary(Bytes),
10-
// TODO:
11-
// CheckmkLinuxAgent(Bytes),
10+
CheckmkLinuxAgent { node_name: String, body: Bytes },
1211
}
1312

1413
impl Payload {
15-
fn metrics_cache_endpoint(&self) -> &str {
14+
fn metrics_cache_endpoint(&self) -> String {
1615
match self {
17-
Self::KubeletStatsSummary(_) => "/kubelet_stats_summary",
16+
Self::KubeletStatsSummary(_) => "/kubelet_stats_summary".to_string(),
17+
Self::CheckmkLinuxAgent { node_name, .. } => format!("/linux_agent/{node_name}"),
18+
}
19+
}
20+
21+
fn content_type(&self) -> &'static str {
22+
match self {
23+
Self::KubeletStatsSummary(_) => "application/json",
24+
Self::CheckmkLinuxAgent { .. } => "text/plain; charset=utf-8",
1825
}
1926
}
2027

2128
fn extract(&self) -> Bytes {
2229
match self {
2330
Self::KubeletStatsSummary(bytes) => bytes.clone(),
31+
Self::CheckmkLinuxAgent { body, .. } => body.clone(),
2432
}
2533
}
2634

@@ -54,7 +62,7 @@ impl Payload {
5462
.post(&url)
5563
.bearer_auth(token.trim())
5664
.body(self.extract())
57-
.header("content-type", "application/json")
65+
.header("content-type", self.content_type())
5866
.send()
5967
.await?;
6068
if response.status().is_success() {
@@ -65,3 +73,32 @@ impl Payload {
6573
Ok(response)
6674
}
6775
}
76+
77+
#[cfg(test)]
78+
mod tests {
79+
use super::*;
80+
81+
#[test]
82+
fn kubelet_stats_summary_endpoint_and_content_type() {
83+
let payload = Payload::KubeletStatsSummary(Bytes::from_static(b"{}"));
84+
assert_eq!(payload.metrics_cache_endpoint(), "/kubelet_stats_summary");
85+
assert_eq!(payload.content_type(), "application/json");
86+
assert_eq!(payload.extract(), Bytes::from_static(b"{}"));
87+
}
88+
89+
#[test]
90+
fn checkmk_linux_agent_endpoint_and_content_type() {
91+
for node_name in ["node-1", "node-with-dashes.example.com"] {
92+
let payload = Payload::CheckmkLinuxAgent {
93+
node_name: node_name.to_string(),
94+
body: Bytes::from_static(b"<<<check_mk>>>\n"),
95+
};
96+
assert_eq!(
97+
payload.metrics_cache_endpoint(),
98+
format!("/linux_agent/{node_name}")
99+
);
100+
assert_eq!(payload.content_type(), "text/plain; charset=utf-8");
101+
assert_eq!(payload.extract(), Bytes::from_static(b"<<<check_mk>>>\n"));
102+
}
103+
}
104+
}

0 commit comments

Comments
 (0)