freshdock 1.2.1

A modern Rust-based Docker container auto-updater: a maintained, health-gated, single-binary successor to Watchtower.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
use std::collections::HashMap;
use std::sync::Arc;

use comfy_table::Table;
use comfy_table::presets::{NOTHING, UTF8_FULL};
use futures::future::join_all;

use crate::config::{CredentialStore, ResolvedSettings};
use crate::docker::Docker;
use crate::docker::check::DockerCheck;
use crate::errors::AppError;
use crate::format::short_digest;
use crate::labels::{self, Mode, PolicyDefaults};
use crate::probe::{self, ProbeOutcome, pinned_digest};
use crate::registry::Registry;
use crate::registry::digest::OciRegistry;

const AUTH_REQUIRED: &str = "auth required (set credentials)";
const CREDENTIALS_REJECTED: &str = "credentials rejected (check token)";
const NETWORK_UNAVAILABLE: &str = "network unavailable";
const PINNED: &str = "pinned (no check)";

/// Run the read-only check: list opted-in containers, fetch latest
/// digests for those on Docker Hub, and render a status table.
///
/// Always exits with success — including when updates are detected — per
/// issue #7's acceptance criteria. Errors that prevent the table from
/// rendering at all (e.g. cannot reach the Docker socket) propagate up.
///
pub async fn run(
    no_color: bool,
    store: Arc<CredentialStore>,
    settings: ResolvedSettings,
) -> Result<(), AppError> {
    let docker = Docker::connect(store.clone())?;
    let registry = OciRegistry::new(store);
    let cells = collect_cells(&docker, &registry, settings.policy_defaults()).await?;
    let mut table = build_table(no_color);
    for row in cells {
        table.add_row(Vec::from(row));
    }
    println!("{table}");
    Ok(())
}

/// Build the six status columns (`container, image, mode, current digest,
/// latest digest, update?`) for every opted-in container — the testable seam,
/// parameterised over the daemon read surface ([`DockerCheck`]) and the
/// [`Registry`]. Split from table formatting so unit tests assert individual
/// cells (and the once-per-unique-image fetch) without parsing rendered output.
async fn collect_cells(
    docker: &impl DockerCheck,
    registry: &impl Registry,
    defaults: PolicyDefaults,
) -> Result<Vec<[String; 6]>, AppError> {
    let containers = docker.list_running().await?;

    let empty = HashMap::new();

    let mut rows: Vec<RowPrep> = Vec::new();
    for c in containers {
        let lbls = c.labels.as_ref().unwrap_or(&empty);
        let policy = labels::parse_policy(lbls, defaults)?;
        if !policy.enabled {
            continue;
        }
        let name = c
            .names
            .as_ref()
            .and_then(|n| n.first())
            .map(|s| s.trim_start_matches('/').to_string())
            .unwrap_or_else(|| "?".to_string());
        let image_str = c.image.unwrap_or_else(|| "?".to_string());

        rows.push(RowPrep {
            name,
            image: image_str,
            mode: policy.mode,
        });
    }

    // Probe each unique image reference once. A homelab compose stack often
    // has several containers sharing the same image; firing duplicate `image
    // inspect` calls or duplicate token+HEAD requests would waste Docker Hub's
    // anonymous rate budget (100 / 6h) and multiply daemon round-trips by the
    // number of duplicate containers. [`probe::probe_image`] is the same
    // "is there an update?" path the scheduler daemon uses (DRY).
    let unique = unique_images(&rows);
    let fetches = unique.into_iter().map(|img| async move {
        (
            img.clone(),
            probe::probe_image(docker, registry, &img).await,
        )
    });
    let by_image: HashMap<String, ProbeOutcome> = join_all(fetches).await.into_iter().collect();

    let mut cells = Vec::with_capacity(rows.len());
    for row in rows.into_iter() {
        let outcome = by_image
            .get(&row.image)
            .cloned()
            .unwrap_or_else(|| ProbeOutcome::Error("internal: missing fetch result".into()));
        let (current, latest, update) = render_cells(&row.image, &outcome);
        cells.push([
            row.name,
            row.image,
            row.mode.to_string(),
            current,
            latest,
            update,
        ]);
    }

    Ok(cells)
}

/// Map a [`ProbeOutcome`] to the `(current digest, latest digest, update?)`
/// table cells.
fn render_cells(image: &str, outcome: &ProbeOutcome) -> (String, String, String) {
    let dash = || "-".to_string();
    match outcome {
        ProbeOutcome::Fetched { local, latest } => {
            let current = local.as_deref().map(short_digest).unwrap_or_else(dash);
            let update = local
                .as_deref()
                .map(|l| if l == latest { "no" } else { "yes" })
                .unwrap_or("?")
                .to_string();
            (current, short_digest(latest), update)
        }
        ProbeOutcome::Pinned => {
            let current = pinned_digest(image).map(short_digest).unwrap_or_else(dash);
            (current, PINNED.to_string(), dash())
        }
        ProbeOutcome::AuthRequired => (dash(), AUTH_REQUIRED.to_string(), dash()),
        ProbeOutcome::CredentialsRejected => (dash(), CREDENTIALS_REJECTED.to_string(), dash()),
        ProbeOutcome::NetworkUnavailable => (dash(), NETWORK_UNAVAILABLE.to_string(), dash()),
        ProbeOutcome::Error(msg) => (dash(), format!("error: {msg}"), dash()),
    }
}

/// Order-preserving deduplication of image references across all rows.
fn unique_images(rows: &[RowPrep]) -> Vec<String> {
    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
    let mut out: Vec<String> = Vec::new();
    for r in rows {
        if seen.insert(&r.image) {
            out.push(r.image.clone());
        }
    }
    out
}

struct RowPrep {
    name: String,
    image: String,
    mode: Mode,
}

fn build_table(no_color: bool) -> Table {
    let mut t = Table::new();
    t.load_preset(if no_color { NOTHING } else { UTF8_FULL });
    t.set_header(vec![
        "container",
        "image",
        "mode",
        "current digest",
        "latest digest",
        "update?",
    ]);
    t
}

#[cfg(test)]
mod tests {
    use super::*;

    fn row(image: &str) -> RowPrep {
        RowPrep {
            name: "n".into(),
            image: image.into(),
            mode: Mode::Watch,
        }
    }

    #[test]
    fn unique_images_deduplicates_preserving_first_occurrence_order() {
        let rows = vec![
            row("postgres:16-alpine"),
            row("redis:7"),
            row("postgres:16-alpine"),
            row("nginx:latest"),
            row("redis:7"),
        ];
        assert_eq!(
            unique_images(&rows),
            vec!["postgres:16-alpine", "redis:7", "nginx:latest"]
        );
    }

    #[test]
    fn unique_images_treats_distinct_tags_as_distinct() {
        let rows = vec![row("postgres:16"), row("postgres:17")];
        assert_eq!(unique_images(&rows), vec!["postgres:16", "postgres:17"]);
    }

    #[test]
    fn unique_images_on_empty_input_is_empty() {
        let rows: Vec<RowPrep> = vec![];
        assert!(unique_images(&rows).is_empty());
    }

    // --- collect_cells: command-layer table assembly (#26) ---

    use crate::docker::DockerError;
    use crate::registry::{Digest, ImageRef, RegistryError};
    use async_trait::async_trait;
    use bollard::models::ContainerSummary;
    use std::sync::atomic::{AtomicUsize, Ordering};

    const DIG_A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    const DIG_B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";

    fn summary(name: &str, image: &str, labels: &[(&str, &str)]) -> ContainerSummary {
        ContainerSummary {
            names: Some(vec![format!("/{name}")]),
            image: Some(image.to_owned()),
            labels: Some(
                labels
                    .iter()
                    .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
                    .collect(),
            ),
            ..Default::default()
        }
    }

    /// Recording fake daemon: serves a fixed container list + per-image
    /// RepoDigests, and counts `inspect_image_repo_digests` calls so the
    /// dedupe contract can be asserted.
    struct FakeDocker {
        containers: Vec<ContainerSummary>,
        repo_digests: HashMap<String, Vec<String>>,
        inspect_calls: AtomicUsize,
    }

    impl FakeDocker {
        fn new(containers: Vec<ContainerSummary>, repo_digests: &[(&str, &str)]) -> Self {
            let repo_digests = repo_digests
                .iter()
                .map(|(img, rd)| ((*img).to_owned(), vec![(*rd).to_owned()]))
                .collect();
            Self {
                containers,
                repo_digests,
                inspect_calls: AtomicUsize::new(0),
            }
        }
    }

    #[async_trait]
    impl DockerCheck for FakeDocker {
        async fn list_running(&self) -> Result<Vec<ContainerSummary>, DockerError> {
            Ok(self.containers.clone())
        }
        async fn inspect_image_repo_digests(
            &self,
            image: &str,
        ) -> Result<Vec<String>, DockerError> {
            self.inspect_calls.fetch_add(1, Ordering::SeqCst);
            Ok(self.repo_digests.get(image).cloned().unwrap_or_default())
        }
    }

    /// Fake registry that returns a fixed upstream digest and counts calls —
    /// lets the dedupe assertion verify exactly one fetch per unique image
    /// without standing up a wiremock server.
    struct FakeRegistry {
        digest: Option<String>,
        calls: AtomicUsize,
    }

    impl FakeRegistry {
        fn new(digest: &str) -> Self {
            Self {
                digest: Some(digest.to_owned()),
                calls: AtomicUsize::new(0),
            }
        }
        fn auth_required() -> Self {
            Self {
                digest: None,
                calls: AtomicUsize::new(0),
            }
        }
    }

    #[async_trait]
    impl Registry for FakeRegistry {
        async fn fetch_digest(&self, _image: &ImageRef) -> Result<Digest, RegistryError> {
            self.calls.fetch_add(1, Ordering::SeqCst);
            match &self.digest {
                Some(d) => Ok(Digest(d.clone())),
                None => Err(RegistryError::Auth("no credentials".into())),
            }
        }
    }

    #[tokio::test]
    async fn matching_local_and_upstream_digest_renders_no() {
        let docker = FakeDocker::new(
            vec![summary(
                "web",
                "alpine:3.19",
                &[("freshdock.enable", "true")],
            )],
            &[("alpine:3.19", &format!("alpine@{DIG_A}"))],
        );
        let registry = FakeRegistry::new(DIG_A);

        let cells = collect_cells(&docker, &registry, PolicyDefaults::default())
            .await
            .unwrap();
        assert_eq!(cells.len(), 1);
        assert_eq!(cells[0][0], "web");
        assert_eq!(
            cells[0][2], "watch",
            "enable=true with no mode defaults to watch"
        );
        assert_eq!(cells[0][5], "no", "equal digests must report no update");
    }

    #[tokio::test]
    async fn differing_digest_renders_yes() {
        let docker = FakeDocker::new(
            vec![summary(
                "web",
                "alpine:3.19",
                &[("freshdock.enable", "true")],
            )],
            &[("alpine:3.19", &format!("alpine@{DIG_A}"))],
        );
        let registry = FakeRegistry::new(DIG_B);

        let cells = collect_cells(&docker, &registry, PolicyDefaults::default())
            .await
            .unwrap();
        assert_eq!(
            cells[0][5], "yes",
            "differing digests must report an update"
        );
    }

    #[tokio::test]
    async fn registry_without_credentials_renders_auth_required() {
        // Phase 5: a non-Docker-Hub image is now probed. With no credentials the
        // registry reports auth-required — a clean status cell, not an error row.
        let docker = FakeDocker::new(
            vec![summary(
                "priv",
                "ghcr.io/owner/repo:v1",
                &[("freshdock.enable", "true")],
            )],
            &[(
                "ghcr.io/owner/repo:v1",
                &format!("ghcr.io/owner/repo@{DIG_A}"),
            )],
        );
        let registry = FakeRegistry::auth_required();

        let cells = collect_cells(&docker, &registry, PolicyDefaults::default())
            .await
            .unwrap();
        assert_eq!(cells[0][4], AUTH_REQUIRED);
        assert_eq!(cells[0][5], "-");
        assert_eq!(
            registry.calls.load(Ordering::SeqCst),
            1,
            "the image is probed now (no more Phase-5 short-circuit)"
        );
    }

    #[test]
    fn credentials_rejected_renders_distinct_status() {
        // A rejected token (private image) must read differently from "no creds"
        // so the operator rotates rather than sets a credential.
        let (current, latest, update) =
            render_cells("alpine:3.19", &ProbeOutcome::CredentialsRejected);
        assert_eq!(latest, CREDENTIALS_REJECTED);
        assert_ne!(latest, AUTH_REQUIRED);
        assert_eq!(current, "-");
        assert_eq!(update, "-");
    }

    #[tokio::test]
    async fn disabled_container_is_omitted() {
        let docker = FakeDocker::new(
            vec![
                summary("on", "alpine:3.19", &[("freshdock.enable", "true")]),
                summary("off", "redis:7", &[]),
            ],
            &[("alpine:3.19", &format!("alpine@{DIG_A}"))],
        );
        let registry = FakeRegistry::new(DIG_A);

        let cells = collect_cells(&docker, &registry, PolicyDefaults::default())
            .await
            .unwrap();
        assert_eq!(cells.len(), 1, "only the opted-in container gets a row");
        assert_eq!(cells[0][0], "on");
    }

    #[tokio::test]
    async fn global_default_mode_applies_when_container_omits_mode_label() {
        // enable=true with no freshdock.mode: the [settings] default_mode wins
        // over the built-in `watch` fallback.
        let docker = FakeDocker::new(
            vec![summary(
                "web",
                "alpine:3.19",
                &[("freshdock.enable", "true")],
            )],
            &[("alpine:3.19", &format!("alpine@{DIG_A}"))],
        );
        let registry = FakeRegistry::new(DIG_A);

        let cells = collect_cells(
            &docker,
            &registry,
            PolicyDefaults {
                mode: Some(Mode::Live),
                ..Default::default()
            },
        )
        .await
        .unwrap();
        assert_eq!(
            cells[0][2], "live",
            "the global default_mode applies when no freshdock.mode label is set"
        );
    }

    #[tokio::test]
    async fn duplicate_image_across_containers_fetches_once() {
        let docker = FakeDocker::new(
            vec![
                summary("a", "redis:7", &[("freshdock.enable", "true")]),
                summary("b", "redis:7", &[("freshdock.enable", "true")]),
            ],
            &[("redis:7", &format!("redis@{DIG_A}"))],
        );
        let registry = FakeRegistry::new(DIG_A);

        let cells = collect_cells(&docker, &registry, PolicyDefaults::default())
            .await
            .unwrap();
        assert_eq!(cells.len(), 2, "both containers still get their own row");
        assert_eq!(
            registry.calls.load(Ordering::SeqCst),
            1,
            "the shared image must be fetched exactly once (rate-budget contract)"
        );
        assert_eq!(
            docker.inspect_calls.load(Ordering::SeqCst),
            1,
            "local digest inspect must also dedupe to one call per unique image"
        );
    }
}