ryra-core 0.9.0

Core library for ryra: config, registry, and service generation logic
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
//! Blue/green deploy: the runtime-agnostic step sequence for a zero-downtime
//! color swap.
//!
//! A blue/green service runs two interchangeable slots, `blue` and `green`, on
//! their own ports. At any moment one is *live* (Caddy routes to it) and the
//! other is idle. A deploy readies the new version on the idle slot, proves it
//! healthy, swaps the Caddy upstream over with a graceful reload (no dropped
//! connections), then stops the old slot. Because the old slot lingers through
//! the swap, rollback is just the reverse upstream swap — no rebuild.
//!
//! The *sequence* is identical regardless of how a slot is realized — that's
//! the whole point. A slot is "an immutable artifact + a port", and the two
//! runtimes only differ in how that artifact is produced:
//!
//! - **podman**: the artifact is an image; readying the idle slot is a
//!   [`Step::PullImage`], and each color is its own quadlet/container. Immutable
//!   for free, so this is the baseline and covers every language.
//! - **native**: the artifact is the idle color's own working dir (a synced,
//!   separately-built copy of the source), so a Python/C++/Node/Rust process
//!   keeps serving from its slot while the new code builds in the other. Readying
//!   the idle slot is a [`Step::Build`] in that slot's dir.
//!
//! Either way the swap below is the same five moves, which is why this lives in
//! one runtime-agnostic builder.

use crate::GeneratedFile;
use crate::Step;
use crate::registry::service_def::Color;

/// systemd unit name (without the `.service` suffix) for one color slot.
///
/// Native units and podman quadlet-units both follow `<service>-<color>`, so
/// the swap plan never has to branch on runtime to name what it starts and
/// stops. Mirrors how a single-instance service's unit is just `<service>`.
pub fn color_unit(service_name: &str, color: Color) -> String {
    format!("{service_name}-{color}")
}

/// Expand a podman quadlet bundle for a blue/green service: replace the single
/// main `<service>.container` with its two color variants
/// (`<service>-blue.container`, `<service>-green.container`), leaving every aux
/// quadlet (a bundled DB, a network, a volume) untouched — only the routable
/// app container is doubled. The main container is identified by filename, the
/// same convention the bundle renderer uses to inject ExecStartPre, so both
/// slots inherit those hooks.
pub fn expand_color_quadlets(files: Vec<GeneratedFile>, service_name: &str) -> Vec<GeneratedFile> {
    let main = format!("{service_name}.container");
    let mut out = Vec::with_capacity(files.len() + 1);
    for f in files {
        let is_main = f.path.file_name().and_then(|n| n.to_str()) == Some(main.as_str());
        if is_main {
            for color in [Color::Blue, Color::Green] {
                out.push(GeneratedFile {
                    path: f
                        .path
                        .with_file_name(color_quadlet_filename(service_name, color)),
                    content: podman_color_quadlet(&f.content, service_name, color),
                });
            }
        } else {
            out.push(f);
        }
    }
    out
}

/// Quadlet filename for one color slot: `<service>-<color>.container`.
/// systemd's generator turns that into the `<service>-<color>.service` unit
/// that [`color_unit`] names, so the two stay in lockstep.
pub fn color_quadlet_filename(service_name: &str, color: Color) -> String {
    format!("{service_name}-{color}.container")
}

/// Env-var name carrying one color slot's host port: `SERVICE_PORT_HTTP_BLUE`
/// from the base `SERVICE_PORT_HTTP`. The two slots can't share a host port
/// (only one process binds it), so a blue/green install allocates a pair and
/// the renders below reference the color-specific one.
pub fn color_port_var(base_port_var: &str, color: Color) -> String {
    format!("{base_port_var}_{}", color.as_str().to_uppercase())
}

/// Rewrite a podman main-container quadlet into one color slot's variant:
/// rename the container and point every published host port at the
/// color-specific env var. The image, volumes, env file, and health command
/// are untouched — both slots are the same artifact, differing only in
/// identity and port. Aux quadlets (a bundled DB) are never colorized; only
/// the routable app container is.
pub fn podman_color_quadlet(content: &str, service_name: &str, color: Color) -> String {
    let mut out = String::with_capacity(content.len() + 16);
    for line in content.lines() {
        let trimmed = line.trim_start();
        if let Some(rest) = trimmed.strip_prefix("ContainerName=") {
            let indent = &line[..line.len() - trimmed.len()];
            // Only rewrite the app container's own name; defensively leave any
            // already-suffixed name alone so a re-render is idempotent.
            if rest.trim() == service_name {
                out.push_str(&format!("{indent}ContainerName={service_name}-{color}\n"));
                continue;
            }
        }
        out.push_str(&colorize_port_vars(line, color));
        out.push('\n');
    }
    out
}

/// Suffix every `${SERVICE_PORT_<NAME>}` reference on a line with the color
/// (`${SERVICE_PORT_HTTP}` -> `${SERVICE_PORT_HTTP_BLUE}`). Leaves the
/// container-port side of `PublishPort=host:container` and everything else
/// untouched, since only the host port comes from a `SERVICE_PORT_*` var.
fn colorize_port_vars(line: &str, color: Color) -> String {
    const MARKER: &str = "${SERVICE_PORT_";
    let suffix = format!("_{}", color.as_str().to_uppercase());
    let mut out = String::with_capacity(line.len() + suffix.len());
    let mut rest = line;
    while let Some(pos) = rest.find(MARKER) {
        let (before, from_marker) = rest.split_at(pos);
        out.push_str(before);
        // The var name runs from after the marker up to the closing `}`.
        match from_marker[MARKER.len()..].find('}') {
            Some(close_rel) => {
                let close = MARKER.len() + close_rel;
                out.push_str(&from_marker[..close]); // ${SERVICE_PORT_HTTP
                // Don't double-suffix if it's already colorized.
                if !from_marker[..close].ends_with(&suffix) {
                    out.push_str(&suffix);
                }
                out.push('}');
                rest = &from_marker[close + 1..];
            }
            // Malformed (no closing brace) — emit verbatim and stop scanning.
            None => {
                out.push_str(from_marker);
                return out;
            }
        }
    }
    out.push_str(rest);
    out
}

/// Render one color slot's systemd unit for a native (non-container) service.
///
/// Shares the shape of the single-instance native unit but with two
/// differences that make blue/green work for *any* language: the process runs
/// from the color's own isolated working dir (`colors/<color>/` — a synced,
/// separately-built copy of the source, so an interpreted runtime's lazily-read
/// source files can't be mutated out from under the live slot), and an explicit
/// `Environment=` overrides the port so the two slots bind different ones. The
/// `Environment=` line comes *after* `EnvironmentFile=` so it wins over the
/// base `SERVICE_PORT_HTTP` from `.env`.
pub fn native_color_unit(p: &NativeColorUnit) -> String {
    format!(
        "[Unit]\n\
         Description={description} ({color})\n\
         After=network.target\n\
         \n\
         [Service]\n\
         Type=simple\n\
         WorkingDirectory={workdir}\n\
         EnvironmentFile={home}/.env\n\
         Environment=SERVICE_HOME={home}\n\
         Environment={port_var}={port}\n\
         Environment=PATH=%h/.local/bin:%h/.cargo/bin:%h/.bun/bin:%h/.deno/bin:%h/go/bin:/usr/local/bin:/usr/bin:/bin\n\
         ExecStart=/bin/sh -c 'exec {run}'\n\
         Restart=always\n\
         RestartSec=5\n\
         \n\
         [Install]\n\
         WantedBy=default.target\n",
        description = p.description,
        color = p.color,
        workdir = p.workdir,
        home = p.home,
        port_var = p.port_var,
        port = p.port,
        run = p.run,
    )
}

/// Inputs to [`native_color_unit`]. `workdir` is the color's isolated slot
/// dir; `port` is the slot's allocated host port; `run` is the service's
/// `[service].run` command, executed unchanged in the slot dir.
pub struct NativeColorUnit<'a> {
    pub description: &'a str,
    pub color: Color,
    pub workdir: &'a str,
    pub home: &'a str,
    pub port_var: &'a str,
    pub port: u16,
    pub run: &'a str,
}

/// Everything [`color_swap_steps`] needs, assembled by the caller from the
/// per-runtime render. Keeping this a plain data struct (rather than threading
/// the registry/exposure through) makes the swap logic a pure function the
/// tests can pin without a live host.
pub struct ColorSwap {
    pub service_name: String,
    /// The slot serving traffic right now. The new version rolls onto
    /// `live.other()`.
    pub live: Color,
    /// Readies the idle slot's artifact: a [`Step::Build`] (native) or
    /// [`Step::PullImage`] (podman). `None` when the artifact is already in
    /// place and nothing needs (re)building.
    pub prepare: Option<Step>,
    /// Health probe against the *idle* slot's own port — ryra won't move
    /// traffic until this returns 200.
    pub health_url: String,
    pub health_timeout_secs: u32,
    /// The re-rendered Caddyfile ([`Step::WriteFile`]) with the upstream
    /// repointed at the idle color. `None` for a loopback install with no
    /// Caddy route, where the swap still works (the new slot simply takes over
    /// once the old one stops) but there's nothing to repoint.
    pub caddy_rewrite: Option<Step>,
}

impl ColorSwap {
    /// The slot the new version rolls onto — and the value the caller should
    /// persist as the install's new `active_color` once the plan succeeds.
    pub fn target(&self) -> Color {
        self.live.other()
    }
}

/// Build the ordered step list for a zero-downtime color swap.
///
/// The order is load-bearing: prepare and start the idle slot, *then* gate on
/// its health, *then* swap Caddy, and only then stop the old slot. If the
/// health gate times out the plan aborts before the Caddy swap, so the old slot
/// is still live and still routed — a failed deploy is a no-op, not an outage.
pub fn color_swap_steps(swap: ColorSwap) -> Vec<Step> {
    let target = swap.target();
    let start_unit = color_unit(&swap.service_name, target);
    let stop_unit = color_unit(&swap.service_name, swap.live);

    let mut steps = Vec::new();
    if let Some(prepare) = swap.prepare {
        steps.push(prepare);
    }
    steps.push(Step::StartService { unit: start_unit });
    steps.push(Step::WaitForHttpHealthy {
        url: swap.health_url,
        expect_status: 200,
        timeout_secs: swap.health_timeout_secs,
    });
    // Atomic cutover: rewrite the upstream, then reload Caddy (graceful — it
    // drains in-flight requests on the old upstream rather than dropping them).
    if let Some(rewrite) = swap.caddy_rewrite {
        steps.push(rewrite);
        steps.push(Step::ReloadCaddy);
    }
    steps.push(Step::StopService { unit: stop_unit });
    steps
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::GeneratedFile;
    use std::path::PathBuf;

    fn caddy_write() -> Step {
        Step::WriteFile(GeneratedFile {
            path: PathBuf::from("/etc/caddy/Caddyfile"),
            content: "reverse_proxy app-green:8080".into(),
        })
    }

    #[test]
    fn target_is_the_other_color() {
        let swap = ColorSwap {
            service_name: "app".into(),
            live: Color::Blue,
            prepare: None,
            health_url: "http://127.0.0.1:9001/healthz".into(),
            health_timeout_secs: 60,
            caddy_rewrite: None,
        };
        assert_eq!(swap.target(), Color::Green);
    }

    #[test]
    fn podman_swap_has_canonical_order() {
        let steps = color_swap_steps(ColorSwap {
            service_name: "app".into(),
            live: Color::Green,
            prepare: Some(Step::PullImage {
                image: "ghcr.io/me/app:v2".into(),
            }),
            health_url: "http://127.0.0.1:9001/healthz".into(),
            health_timeout_secs: 60,
            caddy_rewrite: Some(caddy_write()),
        });
        // prepare -> start idle (blue) -> health -> caddy write -> reload -> stop old (green)
        assert!(matches!(steps[0], Step::PullImage { .. }));
        assert!(matches!(&steps[1], Step::StartService { unit } if unit == "app-blue"));
        assert!(matches!(steps[2], Step::WaitForHttpHealthy { .. }));
        assert!(matches!(steps[3], Step::WriteFile(_)));
        assert!(matches!(steps[4], Step::ReloadCaddy));
        assert!(matches!(&steps[5], Step::StopService { unit } if unit == "app-green"));
        assert_eq!(steps.len(), 6);
    }

    #[test]
    fn native_swap_builds_the_idle_slot_first() {
        let steps = color_swap_steps(ColorSwap {
            service_name: "api".into(),
            live: Color::Blue,
            prepare: Some(Step::Build {
                dir: PathBuf::from("/srv/api/colors/green"),
                command: "cargo build --release".into(),
            }),
            health_url: "http://127.0.0.1:9002/healthz".into(),
            health_timeout_secs: 120,
            caddy_rewrite: Some(caddy_write()),
        });
        // The build runs in the *idle* (green) slot's dir, never touching the
        // live (blue) slot still serving — language-agnostic isolation.
        match &steps[0] {
            Step::Build { dir, .. } => assert!(dir.ends_with("colors/green")),
            _ => panic!("expected Build step first"),
        }
        assert!(matches!(&steps[1], Step::StartService { unit } if unit == "api-green"));
        assert!(matches!(&steps[5], Step::StopService { unit } if unit == "api-blue"));
    }

    // --- render transforms ---------------------------------------------

    const AUTHELIA_QUADLET: &str = "\
[Container]
Image=docker.io/authelia/authelia:4.39
ContainerName=authelia
Network=authelia.network
PublishPort=${SERVICE_PORT_HTTP}:9091
Volume=${SERVICE_HOME}/config:/config:Z
EnvironmentFile=%h/.local/share/services/authelia/.env
";

    #[test]
    fn podman_quadlet_renames_container_and_colorizes_port() {
        let blue = podman_color_quadlet(AUTHELIA_QUADLET, "authelia", Color::Blue);
        assert!(blue.contains("ContainerName=authelia-blue"));
        assert!(!blue.contains("ContainerName=authelia\n"));
        assert!(blue.contains("PublishPort=${SERVICE_PORT_HTTP_BLUE}:9091"));
        // Image, network, volume, env file untouched — same artifact.
        assert!(blue.contains("Image=docker.io/authelia/authelia:4.39"));
        assert!(blue.contains("Network=authelia.network"));
        assert!(blue.contains("services/authelia/.env"));

        let green = podman_color_quadlet(AUTHELIA_QUADLET, "authelia", Color::Green);
        assert!(green.contains("ContainerName=authelia-green"));
        assert!(green.contains("PublishPort=${SERVICE_PORT_HTTP_GREEN}:9091"));
    }

    #[test]
    fn podman_quadlet_render_is_idempotent() {
        // Re-rendering an already-colorized quadlet must not double-suffix.
        let once = podman_color_quadlet(AUTHELIA_QUADLET, "authelia", Color::Blue);
        let twice = podman_color_quadlet(&once, "authelia", Color::Blue);
        assert_eq!(once, twice);
    }

    #[test]
    fn color_port_var_appends_uppercased_color() {
        assert_eq!(
            color_port_var("SERVICE_PORT_HTTP", Color::Blue),
            "SERVICE_PORT_HTTP_BLUE"
        );
        assert_eq!(
            color_port_var("SERVICE_PORT_HTTP", Color::Green),
            "SERVICE_PORT_HTTP_GREEN"
        );
    }

    #[test]
    fn native_color_unit_isolates_workdir_and_overrides_port() {
        let unit = native_color_unit(&NativeColorUnit {
            description: "Demo API",
            color: Color::Green,
            workdir: "/home/u/.local/share/services/api/colors/green",
            home: "/home/u/.local/share/services/api",
            port_var: "SERVICE_PORT_HTTP",
            port: 9002,
            run: "python -m app",
        });
        assert!(unit.contains("WorkingDirectory=/home/u/.local/share/services/api/colors/green"));
        // The port override must come AFTER EnvironmentFile so it wins.
        let envfile = unit.find("EnvironmentFile=").unwrap();
        let port_override = unit.find("Environment=SERVICE_PORT_HTTP=9002").unwrap();
        assert!(
            port_override > envfile,
            "port override must follow EnvironmentFile"
        );
        assert!(unit.contains("ExecStart=/bin/sh -c 'exec python -m app'"));
        assert!(unit.contains("Description=Demo API (green)"));
    }

    // --- plan + render consistency, across several service shapes -------

    /// The unit names the swap plan starts/stops MUST match the unit names the
    /// renders produce, or a deploy would start a slot that doesn't exist.
    /// This pins that contract for a podman service end to end.
    #[test]
    fn e2e_podman_service_plan_matches_rendered_slots() {
        let svc = "authelia";
        let live = Color::Blue;

        // Render both slots the way the install path would.
        let blue_file = color_quadlet_filename(svc, Color::Blue);
        let green_file = color_quadlet_filename(svc, Color::Green);
        assert_eq!(blue_file, "authelia-blue.container");
        assert_eq!(green_file, "authelia-green.container");

        // Build the deploy plan (live=blue, so it rolls onto green).
        let swap = ColorSwap {
            service_name: svc.into(),
            live,
            prepare: Some(Step::PullImage {
                image: "authelia:4.40".into(),
            }),
            health_url: "http://127.0.0.1:9002/api/health".into(),
            health_timeout_secs: 60,
            caddy_rewrite: Some(caddy_write()),
        };
        let target = swap.target();
        let steps = color_swap_steps(swap);

        // The unit started is the green slot's unit, whose quadlet file we render.
        let started = match &steps[1] {
            Step::StartService { unit } => unit.clone(),
            _ => panic!("expected StartService at index 1"),
        };
        assert_eq!(started, color_unit(svc, target));
        assert_eq!(format!("{started}.container"), green_file);
    }

    /// Same contract for a native (here: Python) service — proving the swap
    /// choreography is runtime-agnostic.
    #[test]
    fn e2e_native_python_service_plan_matches_rendered_slots() {
        let svc = "api";
        let green_unit = native_color_unit(&NativeColorUnit {
            description: "API",
            color: Color::Green,
            workdir: "/srv/api/colors/green",
            home: "/srv/api",
            port_var: "SERVICE_PORT_HTTP",
            port: 9002,
            run: "python -m app",
        });
        // The rendered green slot runs on 9002; the plan's health probe must
        // hit that same port.
        assert!(green_unit.contains("Environment=SERVICE_PORT_HTTP=9002"));

        let steps = color_swap_steps(ColorSwap {
            service_name: svc.into(),
            live: Color::Blue,
            prepare: Some(Step::Build {
                dir: "/srv/api/colors/green".into(),
                command: "pip install -r requirements.txt".into(),
            }),
            health_url: "http://127.0.0.1:9002/healthz".into(),
            health_timeout_secs: 90,
            caddy_rewrite: None,
        });
        assert!(matches!(&steps[1], Step::StartService { unit } if unit == "api-green"));
        match &steps[0] {
            Step::Build { dir, .. } => assert_eq!(dir.to_str().unwrap(), "/srv/api/colors/green"),
            _ => panic!("expected Build in the green slot dir"),
        }
    }

    #[test]
    fn loopback_install_skips_caddy_but_still_swaps() {
        let steps = color_swap_steps(ColorSwap {
            service_name: "app".into(),
            live: Color::Blue,
            prepare: None,
            health_url: "http://127.0.0.1:9002/healthz".into(),
            health_timeout_secs: 30,
            caddy_rewrite: None,
        });
        // No prepare, no caddy: start idle -> health -> stop old.
        assert!(matches!(&steps[0], Step::StartService { unit } if unit == "app-green"));
        assert!(matches!(steps[1], Step::WaitForHttpHealthy { .. }));
        assert!(matches!(&steps[2], Step::StopService { unit } if unit == "app-blue"));
        assert!(!steps.iter().any(|s| matches!(s, Step::ReloadCaddy)));
        assert_eq!(steps.len(), 3);
    }
}