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
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
//! Notifications (Phase 6, PLAN §5.4).
//!
//! One [`Notifier`] trait, four backends (webhook / Discord / Telegram / SMTP),
//! and a [`Dispatcher`] that renders each lifecycle event **once** and fans it
//! out to every target subscribed to that event's [`Trigger`]. A send failure
//! is logged and swallowed — notifications must never abort an update (the
//! scheduler's "a tick never propagates an error" contract).
//!
//! Wording lives in exactly one place ([`NotifyEvent::render`]); each backend
//! only adapts the [`RenderedMessage`] to its wire format, so the three HTTP
//! payloads and the email body can never drift apart (DRY).

pub mod discord;
pub mod smtp;
pub mod telegram;
pub mod webhook;

use std::collections::HashSet;
use std::sync::Arc;

use serde::Serialize;
use tracing::warn;

use crate::config::{NotificationConfig, NotificationTarget};
use crate::format::short_digest;
use crate::rollback::RollbackReason;
use discord::DiscordNotifier;
use smtp::{SmtpNotifier, SmtpParams};
use telegram::TelegramNotifier;
use webhook::WebhookNotifier;

/// Which lifecycle event fired. The config `triggers = [...]` list and the
/// PLAN §5.4 matrix (update available / succeeded / failed) map onto these.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Trigger {
    /// A newer image exists but was not applied (watch mode).
    Available,
    /// A health-gated recreate succeeded.
    Succeeded,
    /// A recreate failed its health gate and was rolled back.
    Failed,
}

impl Trigger {
    /// Canonical lowercase token used in config and the generic webhook payload.
    pub fn as_str(self) -> &'static str {
        match self {
            Trigger::Available => "available",
            Trigger::Succeeded => "succeeded",
            Trigger::Failed => "failed",
        }
    }

    /// Parse a config token. The error carries the bad token so the caller can
    /// name the offending `[notifications.<name>]` table.
    pub fn parse(token: &str) -> Result<Self, String> {
        match token.trim().to_ascii_lowercase().as_str() {
            "available" => Ok(Trigger::Available),
            "succeeded" => Ok(Trigger::Succeeded),
            "failed" => Ok(Trigger::Failed),
            other => Err(other.to_string()),
        }
    }

    /// Every trigger — the default subscription when a target omits `triggers`.
    pub fn all() -> HashSet<Trigger> {
        [Trigger::Available, Trigger::Succeeded, Trigger::Failed]
            .into_iter()
            .collect()
    }
}

/// A notifiable lifecycle event with everything needed to render every backend.
/// Built at the scheduler's existing log points; `UpdateFailed` mirrors
/// [`crate::rollback::RollbackEvent`] so the rollback detail flows through.
#[derive(Debug, Clone)]
pub enum NotifyEvent {
    UpdateAvailable {
        container: String,
        image: String,
        latest_digest: String,
    },
    UpdateSucceeded {
        container: String,
        image: String,
        new_id: String,
    },
    UpdateFailed {
        container: String,
        reason: RollbackReason,
        old_image_ref: String,
        new_image_ref: String,
        restored_from: String,
    },
}

impl NotifyEvent {
    pub fn trigger(&self) -> Trigger {
        match self {
            NotifyEvent::UpdateAvailable { .. } => Trigger::Available,
            NotifyEvent::UpdateSucceeded { .. } => Trigger::Succeeded,
            NotifyEvent::UpdateFailed { .. } => Trigger::Failed,
        }
    }

    pub fn container(&self) -> &str {
        match self {
            NotifyEvent::UpdateAvailable { container, .. }
            | NotifyEvent::UpdateSucceeded { container, .. }
            | NotifyEvent::UpdateFailed { container, .. } => container,
        }
    }

    /// The single source of human-readable wording. Backends format the result;
    /// none re-derives the text.
    pub fn render(&self) -> RenderedMessage {
        let (title, body) = match self {
            NotifyEvent::UpdateAvailable {
                container,
                image,
                latest_digest,
            } => (
                format!("Update available: {container}"),
                format!(
                    "A newer image is available for {image} ({}). \
                     Not applied — this container is in watch mode.",
                    short_digest(latest_digest)
                ),
            ),
            NotifyEvent::UpdateSucceeded {
                container,
                image,
                new_id,
            } => (
                format!("Updated: {container}"),
                format!(
                    "{container} was updated to {image} and passed its health check \
                     (new container {}).",
                    short_digest(new_id)
                ),
            ),
            NotifyEvent::UpdateFailed {
                container,
                reason,
                old_image_ref,
                new_image_ref,
                restored_from,
            } => (
                format!("Update failed: {container}"),
                format!(
                    "Updating {container} from {old_image_ref} to {new_image_ref} failed \
                     the health gate ({}); rolled back to the previous container ({restored_from}).",
                    reason_text(*reason)
                ),
            ),
        };
        RenderedMessage {
            title,
            body,
            trigger: self.trigger(),
            container: self.container().to_string(),
        }
    }
}

/// Resolve a target's `triggers` config into a subscription set. Omitted
/// (`None`) subscribes to all three; an unknown token fails with the target
/// named so the operator can find it.
fn parse_triggers(
    name: &str,
    triggers: Option<Vec<String>>,
) -> Result<HashSet<Trigger>, NotifyError> {
    match triggers {
        None => Ok(Trigger::all()),
        Some(list) => list
            .iter()
            .map(|t| {
                Trigger::parse(t).map_err(|bad| NotifyError::Config {
                    name: name.to_string(),
                    reason: format!(
                        "unknown trigger `{bad}` (expected available, succeeded, or failed)"
                    ),
                })
            })
            .collect(),
    }
}

/// Build one configured target (backend + its trigger subscription). Fallible
/// so [`Dispatcher::from_config`] can skip a bad target rather than abort.
fn build_target(
    name: &str,
    target: NotificationTarget,
    http: &reqwest::Client,
) -> Result<Target, NotifyError> {
    let (raw_triggers, notifier): (Option<Vec<String>>, Box<dyn Notifier>) = match target {
        NotificationTarget::Webhook { url, triggers } => (
            triggers,
            Box::new(WebhookNotifier::new(name, url.expose(), http.clone())),
        ),
        NotificationTarget::Discord {
            webhook_url,
            triggers,
        } => (
            triggers,
            Box::new(DiscordNotifier::new(
                name,
                webhook_url.expose(),
                http.clone(),
            )),
        ),
        NotificationTarget::Telegram {
            bot_token,
            chat_id,
            triggers,
        } => (
            triggers,
            Box::new(TelegramNotifier::new(
                name,
                bot_token,
                chat_id,
                http.clone(),
            )),
        ),
        NotificationTarget::Smtp {
            host,
            port,
            username,
            password,
            from,
            to,
            starttls,
            triggers,
        } => (
            triggers,
            Box::new(SmtpNotifier::new(SmtpParams {
                name: name.to_string(),
                host,
                port,
                username,
                password,
                from,
                to,
                starttls,
            })?),
        ),
    };
    let triggers = parse_triggers(name, raw_triggers)?;
    Ok(Target { triggers, notifier })
}

/// Human phrasing for a rollback reason. Kept here (presentation) rather than on
/// the pure-data [`RollbackReason`].
fn reason_text(reason: RollbackReason) -> &'static str {
    match reason {
        RollbackReason::HealthTimeout => "health check timed out",
        RollbackReason::Crashed => "the new container crashed",
    }
}

/// The one rendered form every backend consumes. `trigger` and `container` are
/// carried as machine-readable fields for the generic webhook payload.
#[derive(Debug, Clone)]
pub struct RenderedMessage {
    pub title: String,
    pub body: String,
    pub trigger: Trigger,
    pub container: String,
}

#[derive(Debug, thiserror::Error)]
pub enum NotifyError {
    #[error("notification request failed: {0}")]
    Http(#[from] reqwest::Error),
    #[error("notification target returned HTTP {0}")]
    Status(reqwest::StatusCode),
    #[error("smtp send failed: {0}")]
    Smtp(String),
    #[error("invalid notification config for `{name}`: {reason}")]
    Config { name: String, reason: String },
}

/// Shared POST-JSON path for the three HTTP backends (DRY). Strips the URL from
/// any transport error via [`reqwest::Error::without_url`] so a webhook/Discord
/// secret or a Telegram bot token embedded in the URL can never reach a log line
/// (the [`crate::config::Secret`] invariant). A non-2xx becomes a typed
/// [`NotifyError::Status`]; the dispatcher already logs which target failed.
async fn post_json<B: Serialize + ?Sized>(
    client: &reqwest::Client,
    url: &str,
    body: &B,
) -> Result<(), NotifyError> {
    let resp = client
        .post(url)
        .json(body)
        .send()
        .await
        .map_err(reqwest::Error::without_url)?;
    let status = resp.status();
    if status.is_success() {
        Ok(())
    } else {
        Err(NotifyError::Status(status))
    }
}

/// One notification backend. `send` takes the already-rendered message so all
/// wording stays centralized in [`NotifyEvent::render`].
#[async_trait::async_trait]
pub trait Notifier: Send + Sync {
    /// The target's config name (`[notifications.<name>]`), used only in logs.
    fn name(&self) -> &str;
    async fn send(&self, msg: &RenderedMessage) -> Result<(), NotifyError>;
}

/// A configured target: a backend plus the triggers it subscribes to.
struct Target {
    triggers: HashSet<Trigger>,
    notifier: Box<dyn Notifier>,
}

/// Holds every configured target. Cheap to clone (shared `Arc`) so it can be
/// passed by value into the scheduler. An empty dispatcher is a no-op.
#[derive(Clone)]
pub struct Dispatcher {
    targets: Arc<Vec<Target>>,
}

impl Dispatcher {
    /// A dispatcher with no targets — used when notifications are unconfigured
    /// and in tests that don't care about sends.
    pub fn noop() -> Self {
        Self {
            targets: Arc::new(Vec::new()),
        }
    }

    /// Build the dispatcher from parsed config, sharing one `http` client across
    /// the HTTP backends (SMTP ignores it). **Resilient**: a target that fails to
    /// build (bad trigger token, malformed SMTP relay/address) is logged and
    /// skipped, so one bad `[notifications.*]` entry can never stop the daemon
    /// from updating containers — same rule as a failed send. (Structurally
    /// broken config is already rejected earlier, when the file is parsed.)
    pub fn from_config(config: NotificationConfig, http: reqwest::Client) -> Self {
        let mut targets = Vec::with_capacity(config.targets.len());
        for (name, target) in config.targets {
            match build_target(&name, target, &http) {
                Ok(t) => targets.push(t),
                Err(e) => {
                    warn!(target = %name, error = %e, "skipping invalid notification target")
                }
            }
        }
        Self {
            targets: Arc::new(targets),
        }
    }

    #[cfg(test)]
    fn from_targets(targets: Vec<Target>) -> Self {
        Self {
            targets: Arc::new(targets),
        }
    }

    pub fn is_empty(&self) -> bool {
        self.targets.is_empty()
    }

    /// Render the event once, then send to every subscribed target. Never fails:
    /// a per-target error is logged at WARN and the next target still runs, so a
    /// flaky notifier can neither block another target nor abort the caller.
    pub async fn dispatch(&self, event: &NotifyEvent) {
        if self.targets.is_empty() {
            return;
        }
        let trigger = event.trigger();
        let msg = event.render();
        for target in self.targets.iter() {
            if !target.triggers.contains(&trigger) {
                continue;
            }
            if let Err(e) = target.notifier.send(&msg).await {
                warn!(target = %target.notifier.name(), error = %e, "notification failed; continuing");
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;
    use std::sync::atomic::{AtomicUsize, Ordering};

    const DIG: &str = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";

    fn available() -> NotifyEvent {
        NotifyEvent::UpdateAvailable {
            container: "web".into(),
            image: "nginx:latest".into(),
            latest_digest: DIG.into(),
        }
    }
    fn succeeded() -> NotifyEvent {
        NotifyEvent::UpdateSucceeded {
            container: "web".into(),
            image: "nginx:latest".into(),
            new_id: DIG.into(),
        }
    }
    fn failed() -> NotifyEvent {
        NotifyEvent::UpdateFailed {
            container: "web".into(),
            reason: RollbackReason::HealthTimeout,
            old_image_ref: "nginx:1.0".into(),
            new_image_ref: "nginx:1.1".into(),
            restored_from: "web-old-1700000000".into(),
        }
    }

    /// Records every message it's handed; can be told to fail.
    struct RecordingNotifier {
        name: String,
        fail: bool,
        seen: Arc<Mutex<Vec<RenderedMessage>>>,
        calls: Arc<AtomicUsize>,
    }

    impl RecordingNotifier {
        fn new(name: &str) -> (Self, Arc<Mutex<Vec<RenderedMessage>>>, Arc<AtomicUsize>) {
            let seen = Arc::new(Mutex::new(Vec::new()));
            let calls = Arc::new(AtomicUsize::new(0));
            (
                Self {
                    name: name.into(),
                    fail: false,
                    seen: seen.clone(),
                    calls: calls.clone(),
                },
                seen,
                calls,
            )
        }
        fn failing(name: &str) -> (Self, Arc<AtomicUsize>) {
            let calls = Arc::new(AtomicUsize::new(0));
            (
                Self {
                    name: name.into(),
                    fail: true,
                    seen: Arc::new(Mutex::new(Vec::new())),
                    calls: calls.clone(),
                },
                calls,
            )
        }
    }

    #[async_trait::async_trait]
    impl Notifier for RecordingNotifier {
        fn name(&self) -> &str {
            &self.name
        }
        async fn send(&self, msg: &RenderedMessage) -> Result<(), NotifyError> {
            self.calls.fetch_add(1, Ordering::SeqCst);
            if self.fail {
                return Err(NotifyError::Status(
                    reqwest::StatusCode::INTERNAL_SERVER_ERROR,
                ));
            }
            self.seen.lock().unwrap().push(msg.clone());
            Ok(())
        }
    }

    fn target(triggers: HashSet<Trigger>, notifier: impl Notifier + 'static) -> Target {
        Target {
            triggers,
            notifier: Box::new(notifier),
        }
    }

    #[test]
    fn trigger_parse_roundtrips_and_rejects_junk() {
        for t in [Trigger::Available, Trigger::Succeeded, Trigger::Failed] {
            assert_eq!(Trigger::parse(t.as_str()), Ok(t));
        }
        assert_eq!(Trigger::parse("  FAILED "), Ok(Trigger::Failed));
        assert_eq!(Trigger::parse("nope"), Err("nope".to_string()));
    }

    #[test]
    fn render_maps_each_event_to_its_trigger_and_wording() {
        let a = available().render();
        assert_eq!(a.trigger, Trigger::Available);
        assert!(a.title.contains("Update available"));
        assert!(a.body.contains("watch mode"));
        // Digest is truncated via the shared helper, not printed raw.
        assert!(a.body.contains("sha256:abcdef012345…"));
        assert!(!a.body.contains(DIG));

        let s = succeeded().render();
        assert_eq!(s.trigger, Trigger::Succeeded);
        assert!(s.title.contains("Updated"));

        let f = failed().render();
        assert_eq!(f.trigger, Trigger::Failed);
        assert!(f.title.contains("Update failed"));
        assert!(f.body.contains("health check timed out"));
        assert!(f.body.contains("web-old-1700000000"));
    }

    #[tokio::test]
    async fn empty_dispatcher_is_a_noop() {
        Dispatcher::noop().dispatch(&succeeded()).await; // must not panic
        assert!(Dispatcher::noop().is_empty());
    }

    #[tokio::test]
    async fn only_subscribed_targets_receive_an_event() {
        let (failures_only, seen_f, calls_f) = RecordingNotifier::new("failures");
        let (all, seen_a, calls_a) = RecordingNotifier::new("all");
        let d = Dispatcher::from_targets(vec![
            target([Trigger::Failed].into_iter().collect(), failures_only),
            target(Trigger::all(), all),
        ]);

        d.dispatch(&succeeded()).await;
        assert_eq!(
            calls_f.load(Ordering::SeqCst),
            0,
            "failures-only skips success"
        );
        assert_eq!(
            calls_a.load(Ordering::SeqCst),
            1,
            "all-subscriber gets success"
        );
        assert!(seen_f.lock().unwrap().is_empty());
        assert_eq!(seen_a.lock().unwrap().len(), 1);

        d.dispatch(&failed()).await;
        assert_eq!(
            calls_f.load(Ordering::SeqCst),
            1,
            "failures-only gets failure"
        );
        assert_eq!(
            calls_a.load(Ordering::SeqCst),
            2,
            "all-subscriber also gets failure"
        );
    }

    #[tokio::test]
    async fn a_failing_target_does_not_block_a_later_one() {
        let (boom, boom_calls) = RecordingNotifier::failing("boom");
        let (ok, seen_ok, ok_calls) = RecordingNotifier::new("ok");
        let d = Dispatcher::from_targets(vec![
            target(Trigger::all(), boom),
            target(Trigger::all(), ok),
        ]);

        d.dispatch(&succeeded()).await; // boom errors; ok must still receive
        assert_eq!(boom_calls.load(Ordering::SeqCst), 1);
        assert_eq!(ok_calls.load(Ordering::SeqCst), 1);
        assert_eq!(seen_ok.lock().unwrap().len(), 1);
    }

    #[test]
    fn omitted_triggers_subscribe_to_all_three() {
        assert_eq!(parse_triggers("x", None).unwrap(), Trigger::all());
    }

    #[test]
    fn empty_triggers_list_subscribes_to_nothing() {
        // `triggers = []` is a valid "disable this target" — distinct from
        // omitting the key (which subscribes to all).
        assert!(parse_triggers("x", Some(vec![])).unwrap().is_empty());
    }

    #[test]
    fn from_config_skips_a_target_with_an_unknown_trigger() {
        use crate::config::{NotificationConfig, NotificationTarget, Secret};
        let mut targets = std::collections::HashMap::new();
        targets.insert(
            "hook".to_string(),
            NotificationTarget::Webhook {
                url: Secret::new("https://example.com"),
                triggers: Some(vec!["bogus".to_string()]),
            },
        );
        // Resilient: the bad target is dropped, not an error — the daemon keeps
        // running (here with no targets left).
        let d = Dispatcher::from_config(NotificationConfig { targets }, crate::http::client());
        assert!(d.is_empty());
    }
}