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

organicveggie / saurron / 25417229194

06 May 2026 04:55AM UTC coverage: 49.545% (-0.1%) from 49.671%
25417229194

push

github

web-flow
Merge pull request #58 from organicveggie/update-api-record-metrics

Fix Update endpoint to record metrics and send notifications

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

2 existing lines in 1 file now uncovered.

980 of 1978 relevant lines covered (49.54%)

94.88 hits per line

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

14.53
/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

74
async fn health() -> impl IntoResponse {
×
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

NEW
116
    metrics::record_cycle(&report);
×
NEW
117
    notifications::dispatch(&state.config.notifications, &report).await;
×
118

UNCOV
119
    Json(report).into_response()
×
120
}
121

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

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

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

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

186
    let response = next.run(request).await;
×
187

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

204
    response
×
205
}
206

207
/// Builds the Axum router with all configured routes and middleware.
208
/// The caller is responsible for binding a listener and calling [`axum::serve`].
NEW
209
pub fn build_router(state: AppState) -> axum::Router {
×
NEW
210
    let has_access_log = state.config.http_api.access_log.is_some();
×
211
    let mut router = axum::Router::new().route("/v1/health", get(health));
×
212
    if state.config.http_api.update {
×
213
        router = router.route("/v1/update", post(post_update));
×
214
    }
215
    if state.config.http_api.metrics {
×
216
        router = router.route("/v1/metrics", get(get_metrics));
×
217
    }
NEW
218
    let router = router.with_state(state);
×
NEW
219
    if has_access_log {
×
UNCOV
220
        router.layer(axum::middleware::from_fn(access_log_middleware))
×
221
    } else {
222
        router
×
223
    }
224
}
225

NEW
226
pub async fn start_server(state: AppState) -> anyhow::Result<()> {
×
227
    use anyhow::Context as _;
228

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

244
#[cfg(test)]
245
mod tests {
246
    use super::*;
247

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

264
    #[test]
265
    fn validate_update_without_token_is_error() {
266
        let cfg = make_http_config(true, false, None, false);
267
        assert!(validate_token_config(&cfg).is_err());
268
    }
269

270
    #[test]
271
    fn validate_update_with_token_is_ok() {
272
        let cfg = make_http_config(true, false, Some("secret"), false);
273
        assert!(validate_token_config(&cfg).is_ok());
274
    }
275

276
    #[test]
277
    fn validate_metrics_no_auth_needs_no_token() {
278
        let cfg = make_http_config(false, true, None, true);
279
        assert!(validate_token_config(&cfg).is_ok());
280
    }
281

282
    #[test]
283
    fn validate_metrics_with_auth_needs_token() {
284
        let cfg = make_http_config(false, true, None, false);
285
        assert!(validate_token_config(&cfg).is_err());
286
    }
287

288
    #[test]
289
    fn validate_no_endpoints_no_token_needed() {
290
        let cfg = make_http_config(false, false, None, false);
291
        assert!(validate_token_config(&cfg).is_ok());
292
    }
293

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

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

308
    #[test]
309
    fn check_auth_no_header_fails() {
310
        let headers = HeaderMap::new();
311
        assert!(!check_auth(&headers, "mysecret"));
312
    }
313

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

321
    #[test]
322
    fn redact_authorization_bearer() {
323
        assert_eq!(redact_authorization("Bearer mytoken"), "Bearer [REDACTED]");
324
    }
325

326
    #[test]
327
    fn redact_authorization_basic() {
328
        assert_eq!(
329
            redact_authorization("Basic dXNlcjpwYXNz"),
330
            "Basic [REDACTED]"
331
        );
332
    }
333

334
    #[test]
335
    fn redact_authorization_bare_value() {
336
        assert_eq!(redact_authorization("notype"), "[REDACTED]");
337
    }
338
}
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