• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

organicveggie / saurron / 25415315438

06 May 2026 03:45AM UTC coverage: 49.671%. Remained the same
25415315438

push

github

web-flow
Merge pull request #57 from organicveggie/health-check-body

Update healthcheck to return OK in JSON

0 of 2 new or added lines in 1 file covered. (0.0%)

980 of 1973 relevant lines covered (49.67%)

93.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

15.18
/src/http.rs
1
use std::net::SocketAddr;
2
use std::sync::Arc;
3
use std::time::Instant;
4

5
use axum::{
6
    Json,
7
    extract::{ConnectInfo, Query, State},
8
    http::{HeaderMap, StatusCode, header},
9
    response::{IntoResponse, Response},
10
    routing::{get, post},
11
};
12
use serde::Deserialize;
13
use tracing::{error, info};
14

15
use crate::{config, docker, metrics, notifications, registry, update};
16

17
pub struct AppStateInner {
18
    pub docker: docker::DockerClient,
19
    pub registry: registry::RegistryClient,
20
    pub config: config::Config,
21
    pub selector: docker::ContainerSelector,
22
    /// Held for the duration of any update cycle. Scheduler: .lock().await; HTTP: .try_lock().
23
    pub update_lock: tokio::sync::Mutex<()>,
24
}
25

26
pub type AppState = Arc<AppStateInner>;
27

28
#[derive(Debug, Deserialize)]
29
struct UpdateQuery {
30
    container: Option<String>,
31
    image: Option<String>,
32
}
33

34
/// Validate that the HTTP API token configuration is consistent.
35
/// Called at startup before binding the port.
36
pub fn validate_token_config(cfg: &config::HttpApiConfig) -> anyhow::Result<()> {
5✔
37
    if cfg.update && cfg.token.is_none() {
9✔
38
        anyhow::bail!("--http-api-update requires --http-api-token");
1✔
39
    }
40
    if cfg.metrics && !cfg.metrics_no_auth && cfg.token.is_none() {
8✔
41
        anyhow::bail!(
1✔
42
            "--http-api-metrics requires --http-api-token (or --http-api-metrics-no-auth)"
43
        );
44
    }
45
    Ok(())
3✔
46
}
47

48
fn check_auth(headers: &HeaderMap, token: &str) -> bool {
4✔
49
    headers
4✔
50
        .get(header::AUTHORIZATION)
8✔
51
        .and_then(|v| v.to_str().ok())
13✔
52
        .and_then(|v| v.strip_prefix("Bearer "))
10✔
53
        .map(|provided| provided == token)
8✔
54
        .unwrap_or(false)
55
}
56

57
/// Run a full enumeration + update cycle using the shared application state.
58
pub async fn run_cycle_with_state(state: &AppStateInner) {
×
59
    let all = match state.docker.list_containers(&state.selector).await {
×
60
        Ok(v) => v,
×
61
        Err(e) => {
×
62
            error!(error = %e, "failed to list containers for update cycle");
×
63
            return;
×
64
        }
65
    };
66
    let selected = state.docker.select_containers(&all, &state.selector);
×
67
    let report = update::UpdateEngine::new(&state.docker, &state.registry, &state.config)
×
68
        .run_cycle(&selected)
×
69
        .await;
×
70
    metrics::record_cycle(&report);
×
71
    notifications::dispatch(&state.config.notifications, &report).await;
×
72
}
73

NEW
74
async fn health() -> impl IntoResponse {
×
NEW
75
    (StatusCode::OK, Json(serde_json::json!({"status": "ok"})))
×
76
}
77

78
async fn post_update(
×
79
    State(state): State<AppState>,
80
    Query(query): Query<UpdateQuery>,
81
    headers: HeaderMap,
82
) -> Response {
83
    if let Some(token) = &state.config.http_api.token
×
84
        && !check_auth(&headers, token)
×
85
    {
86
        return StatusCode::UNAUTHORIZED.into_response();
×
87
    }
88

89
    let Ok(_guard) = state.update_lock.try_lock() else {
×
90
        metrics::record_skipped_cycle();
×
91
        return StatusCode::CONFLICT.into_response();
×
92
    };
93

94
    let all = match state.docker.list_containers(&state.selector).await {
×
95
        Ok(v) => v,
×
96
        Err(e) => {
×
97
            error!(error = %e, "failed to list containers for HTTP-triggered update");
×
98
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
×
99
        }
100
    };
101
    let mut selected = state.docker.select_containers(&all, &state.selector);
×
102

103
    if let Some(ref names) = query.container {
×
104
        let allowed: std::collections::HashSet<&str> = names.split(',').map(str::trim).collect();
×
105
        selected.retain(|c| allowed.contains(c.name.as_str()));
×
106
    }
107
    if let Some(ref image_filter) = query.image {
×
108
        let images: Vec<&str> = image_filter.split(',').map(str::trim).collect();
×
109
        selected.retain(|c| images.iter().any(|img| c.image.starts_with(img)));
×
110
    }
111

112
    let report = update::UpdateEngine::new(&state.docker, &state.registry, &state.config)
×
113
        .run_cycle(&selected)
×
114
        .await;
×
115

116
    Json(report).into_response()
×
117
}
118

119
async fn get_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
×
120
    if !state.config.http_api.metrics_no_auth
×
121
        && let Some(token) = &state.config.http_api.token
×
122
        && !check_auth(&headers, token)
×
123
    {
124
        return StatusCode::UNAUTHORIZED.into_response();
×
125
    }
126

127
    use prometheus::Encoder as _;
128
    let encoder = prometheus::TextEncoder::new();
×
129
    let metric_families = prometheus::gather();
×
130
    let mut buffer = Vec::new();
×
131
    if let Err(e) = encoder.encode(&metric_families, &mut buffer) {
×
132
        error!(error = %e, "failed to encode prometheus metrics");
×
133
        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
×
134
    }
135
    let body = String::from_utf8_lossy(&buffer).into_owned();
×
136
    (
137
        StatusCode::OK,
×
138
        [(
139
            header::CONTENT_TYPE,
×
140
            "text/plain; version=0.0.4; charset=utf-8",
×
141
        )],
142
        body,
×
143
    )
144
        .into_response()
145
}
146

147
fn redact_authorization(value: &str) -> String {
3✔
148
    let mut parts = value.splitn(2, ' ');
9✔
149
    match (parts.next(), parts.next()) {
12✔
150
        (Some(scheme), Some(_)) => format!("{scheme} [REDACTED]"),
4✔
151
        _ => "[REDACTED]".to_string(),
2✔
152
    }
153
}
154

155
async fn access_log_middleware(
×
156
    ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
157
    request: axum::extract::Request,
158
    next: axum::middleware::Next,
159
) -> axum::response::Response {
160
    let start = Instant::now();
×
161
    let method = request.method().clone();
×
162
    let uri = request.uri().clone();
×
163
    let version = request.version();
×
164
    let host = request
×
165
        .headers()
166
        .get(header::HOST)
×
167
        .and_then(|v| v.to_str().ok())
×
168
        .unwrap_or("<not set>")
169
        .to_string();
170
    let user_agent = request
×
171
        .headers()
172
        .get(header::USER_AGENT)
×
173
        .and_then(|v| v.to_str().ok())
×
174
        .unwrap_or("<not set>")
175
        .to_string();
176
    let authorization = request
×
177
        .headers()
178
        .get(header::AUTHORIZATION)
×
179
        .and_then(|v| v.to_str().ok())
×
180
        .map(redact_authorization)
×
181
        .unwrap_or_else(|| "<not set>".to_string());
×
182

183
    let response = next.run(request).await;
×
184

185
    tracing::info!(
×
186
        target: "saurron::access",
187
        remote_addr = %remote_addr,
188
        host        = %host,
189
        uri         = %uri,
190
        path        = uri.path(),
×
191
        method      = %method,
192
        protocol    = ?version,
193
        scheme      = uri.scheme_str().unwrap_or("http"),
×
194
        user_agent  = %user_agent,
195
        authorization = %authorization,
196
        status      = response.status().as_u16(),
×
197
        duration_ms = start.elapsed().as_millis() as u64,
×
198
        "",
199
    );
200

201
    response
×
202
}
203

204
pub async fn start_server(state: AppState) -> anyhow::Result<()> {
×
205
    use anyhow::Context as _;
206

207
    let mut router = axum::Router::new().route("/v1/health", get(health));
×
208
    if state.config.http_api.update {
×
209
        router = router.route("/v1/update", post(post_update));
×
210
    }
211
    if state.config.http_api.metrics {
×
212
        router = router.route("/v1/metrics", get(get_metrics));
×
213
    }
214
    let router = router.with_state(state.clone());
×
215
    let router = if state.config.http_api.access_log.is_some() {
×
216
        router.layer(axum::middleware::from_fn(access_log_middleware))
×
217
    } else {
218
        router
×
219
    };
220

221
    let addr = std::net::SocketAddr::from(([0, 0, 0, 0], state.config.http_api.port));
×
222
    let listener = tokio::net::TcpListener::bind(addr).await.with_context(|| {
×
223
        format!(
×
224
            "failed to bind HTTP API port {}",
225
            state.config.http_api.port
×
226
        )
227
    })?;
228
    info!(
×
229
        port = state.config.http_api.port,
×
230
        "HTTP API server listening"
231
    );
232
    axum::serve(
233
        listener,
×
234
        router.into_make_service_with_connect_info::<SocketAddr>(),
×
235
    )
236
    .await
×
237
    .context("HTTP API server error")
238
}
239

240
#[cfg(test)]
241
mod tests {
242
    use super::*;
243

244
    fn make_http_config(
245
        update: bool,
246
        metrics: bool,
247
        token: Option<&str>,
248
        metrics_no_auth: bool,
249
    ) -> config::HttpApiConfig {
250
        config::HttpApiConfig {
251
            update,
252
            metrics,
253
            token: token.map(|s| s.to_string()),
254
            port: 8080,
255
            metrics_no_auth,
256
            access_log: None,
257
        }
258
    }
259

260
    #[test]
261
    fn validate_update_without_token_is_error() {
262
        let cfg = make_http_config(true, false, None, false);
263
        assert!(validate_token_config(&cfg).is_err());
264
    }
265

266
    #[test]
267
    fn validate_update_with_token_is_ok() {
268
        let cfg = make_http_config(true, false, Some("secret"), false);
269
        assert!(validate_token_config(&cfg).is_ok());
270
    }
271

272
    #[test]
273
    fn validate_metrics_no_auth_needs_no_token() {
274
        let cfg = make_http_config(false, true, None, true);
275
        assert!(validate_token_config(&cfg).is_ok());
276
    }
277

278
    #[test]
279
    fn validate_metrics_with_auth_needs_token() {
280
        let cfg = make_http_config(false, true, None, false);
281
        assert!(validate_token_config(&cfg).is_err());
282
    }
283

284
    #[test]
285
    fn validate_no_endpoints_no_token_needed() {
286
        let cfg = make_http_config(false, false, None, false);
287
        assert!(validate_token_config(&cfg).is_ok());
288
    }
289

290
    #[test]
291
    fn check_auth_valid_bearer_token() {
292
        let mut headers = HeaderMap::new();
293
        headers.insert(header::AUTHORIZATION, "Bearer mysecret".parse().unwrap());
294
        assert!(check_auth(&headers, "mysecret"));
295
    }
296

297
    #[test]
298
    fn check_auth_wrong_token_fails() {
299
        let mut headers = HeaderMap::new();
300
        headers.insert(header::AUTHORIZATION, "Bearer wrongtoken".parse().unwrap());
301
        assert!(!check_auth(&headers, "mysecret"));
302
    }
303

304
    #[test]
305
    fn check_auth_no_header_fails() {
306
        let headers = HeaderMap::new();
307
        assert!(!check_auth(&headers, "mysecret"));
308
    }
309

310
    #[test]
311
    fn check_auth_basic_auth_prefix_rejected() {
312
        let mut headers = HeaderMap::new();
313
        headers.insert(header::AUTHORIZATION, "Basic dXNlcjpwYXNz".parse().unwrap());
314
        assert!(!check_auth(&headers, "dXNlcjpwYXNz"));
315
    }
316

317
    #[test]
318
    fn redact_authorization_bearer() {
319
        assert_eq!(redact_authorization("Bearer mytoken"), "Bearer [REDACTED]");
320
    }
321

322
    #[test]
323
    fn redact_authorization_basic() {
324
        assert_eq!(
325
            redact_authorization("Basic dXNlcjpwYXNz"),
326
            "Basic [REDACTED]"
327
        );
328
    }
329

330
    #[test]
331
    fn redact_authorization_bare_value() {
332
        assert_eq!(redact_authorization("notype"), "[REDACTED]");
333
    }
334
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc