Просмотр исходного кода

feat: usmrad — Rapid Application Deployment supervisor

Env-driven dev-loop supervisor: tracks a git branch, builds and installs
the manifest into A/B slots under an appdir, launches the exec from the
active slot, and swaps on new commits or upstream USM package updates
(SPM-aware dependency resolution). Failed deploys keep the previous
instance running; usmrad always exits with the child's exit code.
Includes the Alpine container (hosted-installer bootstrap, pre-wired
web-stack repository), installer usmrad shim, and 30 new tests.
clanker 1 месяц назад
Родитель
Сommit
9e020f492f

+ 47 - 1
README.md

@@ -425,4 +425,50 @@ podman load -i <name>-<version>.image.tar.xz
 podman run --rm <name>:<version>
 ```
 
-The artifact is `<name>-<version>.image.tar.xz` next to the deploy context; `podman load -i` accepts the xz-compressed archive directly. Tag characters outside `[A-Za-z0-9_.-]` (usm release suffixes like `+`, for instance) are mapped to dashes in the tag; the artifact filename keeps the unsanitised version.
+The artifact is `<name>-<version>.image.tar.xz` next to the deploy context; `podman load -i` accepts the xz-compressed archive directly. Tag characters outside `[A-Za-z0-9_.-]` (usm release suffixes like `+`, for instance) are mapped to dashes in the tag; the artifact filename keeps the unsanitised version.
+
+## usmrad — Rapid Application Deployment
+
+`usmrad` is a dev-loop supervisor for applications managed by USM manifests: point it at a git branch and it builds and installs the app into A/B slots under an application directory, runs the active instance, and swaps slots when new commits (or updated upstream USM packages) land — keeping the previous instance running when a build fails. It is configured entirely through the environment:
+
+| Variable | Meaning | Default |
+|---|---|---|
+| `USMRAD_REPO` | git repo URL (required) | — (usage error without it) |
+| `USMRAD_BRANCH` | git branch | `master` |
+| `USMRAD_POLL` | poll interval in minutes; fractional accepted (`0.25` = 15 s) | `0` = disabled |
+| `USMRAD_APPDIR` | application directory | `/app` |
+| `USMRAD_USM_REPOS` | semicolon-delimited `.usmr` URIs (http(s) URLs or local paths, same forms `usm add-repo` accepts) | empty = repos baked into the image only |
+| `USMRAD_TERM_GRACE` | seconds between SIGTERM and SIGKILL when terminating an instance | `10` |
+
+Invocation is `usmrad <exec> [args...]`: `<exec>` is resolved inside the active slot, everything after it is passed verbatim to the executable, which runs with cwd `<appdir>/pwd` and usmrad's inherited environment. The layout under the appdir:
+
+```
+<appdir>/a                install root A          (USM_DESTDIR target)
+<appdir>/b                install root B
+<appdir>/pwd              app working directory   (cwd of the launched exec)
+<appdir>/repo             git checkout, owned by usmrad
+<appdir>/build            persistent usm build path (outside the repo so it
+                          survives reset/clean → incremental rebuilds)
+<appdir>/current          symlink → a|b           (the active slot)
+<appdir>/deployed-commit  last successfully deployed commit SHA
+```
+
+Semantics:
+
+- All output — usm runs, build logs, app stdout/stderr — streams to the container logs
+- Rebuild triggers are new commits on the branch or updated dependent packages in upstream USM repositories
+- A failed build or install keeps the previous instance running; with no previous instance to fall back to, usmrad exits 251
+- When the app exits — for any reason, polling or not — usmrad exits with the child's exit code; restarts belong to the container supervisor
+- SPM integration rides the configured helper (`usm-spm-apk` in the Alpine container), so dependencies may come from `apk` as well as USM repositories
+
+An image with `usmrad` as entrypoint is built from `containers/usmrad/Containerfile`: the hosted installer at `https://packages.astrologue.nz/install-usm.sh` (downloaded to a file and run as `sh <file> -y`, never `curl | sh`, because the payload extracts relative to `$0`) brings in USM, usmrad and the apk SPM wiring; the image enrolls and adds the `web-stack` repository at build time so its packages resolve out of the box. The run pattern (USMRAD_USM_REPOS only needed for repositories beyond web-stack):
+
+```bash
+podman run --rm \
+  -e USMRAD_REPO=https://git.example/my-app.git \
+  -e USMRAD_BRANCH=main \
+  -e USMRAD_POLL=1 \
+  -e USMRAD_USM_REPOS='https://packages.astrologue.nz/repo/web-stack/repo.usmr' \
+  -v ./data:/app/pwd \
+  usmrad my-app --port 8080
+```

+ 33 - 0
containers/usmrad/Containerfile

@@ -0,0 +1,33 @@
+# usmrad — Rapid Application Deployment supervisor image.
+#
+# Build context: the repo root (nothing local is required — USM and usmrad
+# both arrive via the hosted installer, which bundles usmrad):
+#
+#   podman build -f containers/usmrad/Containerfile -t usmrad .
+#
+# bash is required by usm scripts, git by usmrad at runtime, and curl +
+# ca-certificates for https fetches. The installer auto-detects apk on PATH,
+# wires the usm-spm-apk helper plus the lib libdir into the generated
+# usm.config, and shims both usm and usmrad into /usr/bin.
+#
+# The system is enrolled and the web-stack repository added at build time so
+# its packages (statum, astralis, inversion, the invercargill libraries, …)
+# resolve immediately; USMRAD_USM_REPOS adds further repositories at runtime.
+#
+# Run pattern:
+#
+#   podman run --rm \
+#     -e USMRAD_REPO=https://git.example/my-app.git \
+#     -e USMRAD_BRANCH=main \
+#     -e USMRAD_POLL=1 \
+#     -v ./data:/app/pwd \
+#     usmrad my-app --port 8080
+
+FROM alpine:latest
+RUN apk add --no-cache bash git curl ca-certificates
+RUN curl -fsSL https://packages.astrologue.nz/install-usm.sh -o /tmp/install-usm.sh \
+    && sh /tmp/install-usm.sh -y \
+    && rm /tmp/install-usm.sh
+RUN usm enroll -y \
+    && usm add-repo -y https://packages.astrologue.nz/repo/web-stack/repo.usmr
+ENTRYPOINT ["usmrad"]

+ 34 - 16
installer/build_config.sh

@@ -184,6 +184,28 @@ build_component() {
     esac
 }
 
+# Install one shim: a /usr/bin wrapper exporting the bundled lib directory
+# and exec'ing the named binary from target_dir
+write_shim() {
+    local shim_path="$1"
+    local binary_name="$2"
+    local target_dir="$3"
+    local sudo="$4"
+
+    local shim_content="#!/bin/bash
+export LD_LIBRARY_PATH=\"${target_dir}/lib\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}\"
+exec \"${target_dir}/bin/${binary_name}\" \"\$@\"
+"
+
+    if [[ -n "$sudo" ]]; then
+        echo "$shim_content" | $sudo tee "$shim_path" >/dev/null
+        $sudo chmod +x "$shim_path"
+    else
+        echo "$shim_content" > "$shim_path"
+        chmod +x "$shim_path"
+    fi
+}
+
 # Install shim to /usr/bin
 install_shim() {
     local target_dir="$1"
@@ -191,27 +213,23 @@ install_shim() {
     local config_dir="/etc/usm"
     local config_path="$config_dir/usm.config"
     local sudo=""
-    
+
     if ! is_root; then
         sudo=$(get_sudo)
     fi
-    
+
     log_step "Installing shim to $shim_path..."
-    
-    local shim_content="#!/bin/bash
-export LD_LIBRARY_PATH=\"${target_dir}/lib\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}\"
-exec \"${target_dir}/bin/usm\" \"\$@\"
-"
-    
-    if [[ -n "$sudo" ]]; then
-        echo "$shim_content" | $sudo tee "$shim_path" >/dev/null
-        $sudo chmod +x "$shim_path"
-    else
-        echo "$shim_content" > "$shim_path"
-        chmod +x "$shim_path"
-    fi
-    
+
+    write_shim "$shim_path" "usm" "$target_dir" "$sudo"
+
     log_info "Shim installed to $shim_path"
+
+    # usmrad ships in the same payload; shim it only when the binary is
+    # present so older payloads (without src/rad) still install cleanly
+    if [[ -x "${target_dir}/bin/usmrad" ]]; then
+        write_shim "/usr/bin/usmrad" "usmrad" "$target_dir" "$sudo"
+        log_info "usmrad shim installed to /usr/bin/usmrad"
+    fi
     
     # Generate USM configuration file only if it doesn't already exist
     if [[ -f "$config_path" ]]; then

+ 2 - 2
installer/ui.sh

@@ -126,7 +126,7 @@ show_installation_summary() {
     print_list_item "3" "USM - Universal Source Manifest"
     
     echo ""
-    echo -e "${YELLOW}Step $step_num:${NC} Create shim at /usr/bin/usm"
+    echo -e "${YELLOW}Step $step_num:${NC} Create shims at /usr/bin (usm, plus usmrad when bundled)"
     
     echo ""
     if ! confirm "Do you want to continue with the installation?" "n"; then
@@ -144,7 +144,7 @@ show_completion() {
     echo -e "${GREEN}✓ Installation Complete!${NC}"
     echo ""
     echo -e "  ${CYAN}Binary:${NC}     ${target_dir}/bin/usm"
-    echo -e "  ${CYAN}Shim:${NC}       /usr/bin/usm"
+    echo -e "  ${CYAN}Shims:${NC}      /usr/bin/usm, /usr/bin/usmrad (when bundled)"
     echo ""
     echo -e "Run ${BOLD}usm --help${NC} to get started."
     echo ""

+ 1 - 0
src/meson.build

@@ -18,4 +18,5 @@ install_data(spm_shims,
 
 subdir('lib')
 subdir('cli')
+subdir('rad')
 subdir('tests')

+ 100 - 0
src/rad/Deployer.vala

@@ -0,0 +1,100 @@
+namespace Usm.Rad {
+
+/**
+ * Deploys the tracked branch into an A/B slot on behalf of the check cycle:
+ * synchronises the repository, satisfies the manifest's dependencies and
+ * installs into a freshly wiped slot through the `usm` CLI with every
+ * stream inherited, so build and install output lands in the container log
+ * verbatim. A successful deployment flips the `current` symlink and records
+ * the deployed commit; a failure throws before the active slot or a
+ * running instance is disturbed.
+ */
+public class Deployer : Object {
+
+    Config config;
+    Slots slots;
+    Source source;
+    System system;
+    string usm_binary;
+
+    /**
+     * Prepares a deployer for the appdir configured in {@link config},
+     * deploying the branch tracked by {@link source} into the A/B layout
+     * modelled by {@link slots}, with dependencies satisfied through
+     * {@link system}; {@link usm_binary} is the `usm` executable spawned
+     * for the install itself.
+     */
+    public Deployer(Config config, Slots slots, Source source, System system, string usm_binary) {
+        this.config = config;
+        this.slots = slots;
+        this.source = source;
+        this.system = system;
+        this.usm_binary = usm_binary;
+    }
+
+    /**
+     * Deploys the tracked branch into {@link slot}, which the caller
+     * selects as the inactive one. Resets the repository to the remote
+     * head (a git failure propagates as {@link RadError.GIT}), satisfies
+     * the manifest's dependencies ({@link RadError.DEPENDENCY} when a
+     * resource cannot be provided), wipes and recreates the slot
+     * directory, then runs `usm manifest install <build path>` from within
+     * the repository with `USM_DESTDIR` steered at the slot ({@link
+     * RadError.DEPLOY} when the install fails). On success the `current`
+     * symlink is flipped to {@link slot} and the deployed commit recorded.
+     */
+    public async void deploy(string slot) throws RadError {
+        var slot_path = slots.slot_path(slot);
+        Log.info(@"Deploying \"$(config.branch)\" into slot \"$slot\"");
+
+        source.sync_to_remote();
+        yield system.ensure_deps(slots.repo_dir);
+
+        // The inactive slot is rebuilt from scratch so nothing from the
+        // previous deployment leaks into the new installation
+        try {
+            if (FileUtils.test(slot_path, FileTest.EXISTS)) {
+                Usm.Util.delete_tree(slot_path);
+            }
+            DirUtils.create_with_parents(slot_path, 0755);
+        }
+        catch (Error e) {
+            throw new RadError.DEPLOY(@"Could not reset the slot directory \"$slot_path\": $(e.message)");
+        }
+
+        var launcher = new SubprocessLauncher(SubprocessFlags.INHERIT_FDS);
+        launcher.set_cwd(slots.repo_dir);
+        launcher.setenv("USM_DESTDIR", slot_path, true);
+
+        Subprocess install;
+        try {
+            install = launcher.spawnv(new string[] { usm_binary, "manifest", "install", slots.build_dir });
+        }
+        catch (Error e) {
+            throw new RadError.DEPLOY(@"Could not spawn \"$(usm_binary) manifest install\": $(e.message)");
+        }
+
+        try {
+            install.wait_check();
+        }
+        catch (Error e) {
+            var failure = install.get_if_signaled()
+                ? @"was terminated by signal $(install.get_term_sig())"
+                : @"exited with code $(install.get_exit_status())";
+            throw new RadError.DEPLOY(@"usm manifest install failed ($failure)");
+        }
+
+        var deployed_commit = source.remote_head();
+        try {
+            slots.flip_current(slot);
+            slots.write_deployed_commit(deployed_commit);
+        }
+        catch (Error e) {
+            throw new RadError.DEPLOY(@"The install succeeded but slot \"$slot\" could not be activated: $(e.message)");
+        }
+
+        Log.info(@"Slot \"$slot\" is now current at commit $deployed_commit");
+    }
+}
+
+}

+ 179 - 0
src/rad/Slots.vala

@@ -0,0 +1,179 @@
+namespace Usm.Rad {
+
+/**
+ * The A/B slot layout under an application directory: two install roots,
+ * the working and build directories, the git checkout and the atomically
+ * flipped `current` symlink that marks the active slot.
+ *
+ * All state lives on disk — {@link read_current_slot} and
+ * {@link read_deployed_commit} derive it from the layout — so a restarted
+ * supervisor continues from whatever the previous one deployed.
+ */
+public class Slots : Object {
+
+    /** Directories of the layout, as ensured by {@link ensure_layout}. */
+    private const string[] LAYOUT_DIRECTORIES = { "a", "b", "pwd", "repo", "build" };
+
+    /** Slot-relative directories searched by {@link resolve_exec}, in order. */
+    private const string[] EXEC_SEARCH_DIRECTORIES = {
+        "usr/bin", "bin", "usr/sbin", "sbin", "usr/libexec"
+    };
+
+    private string appdir;
+
+    /** Path of the git checkout owned by usmrad (supervised by {@link Usm.Rad.Source}). */
+    public string repo_dir { owned get { return Path.build_filename(appdir, "repo"); } }
+
+    /** Path used as the working directory of the launched instance ({@link Slots} layout). */
+    public string pwd { owned get { return Path.build_filename(appdir, "pwd"); } }
+
+    /** Path of the persistent build tree, deliberately outside {@link repo_dir} so it survives resets. */
+    public string build_dir { owned get { return Path.build_filename(appdir, "build"); } }
+
+    /** Path of the symlink marking the active slot; flipped by {@link flip_current}. */
+    public string current_link { owned get { return Path.build_filename(appdir, "current"); } }
+
+    /** Path of the file recording the last deployed commit, read by {@link read_deployed_commit}. */
+    public string deployed_commit_path { owned get { return Path.build_filename(appdir, "deployed-commit"); } }
+
+    /**
+     * Roots the layout at {@link appdir}; nothing touches the filesystem
+     * until {@link ensure_layout} runs.
+     */
+    public Slots(string appdir) {
+        this.appdir = appdir;
+    }
+
+    /**
+     * Creates the five layout directories (see {@link LAYOUT_DIRECTORIES}),
+     * tolerating any that already exist.
+     */
+    public void ensure_layout() throws Error {
+        foreach(var name in LAYOUT_DIRECTORIES) {
+            try {
+                File.new_for_path(Path.build_filename(appdir, name)).make_directory_with_parents();
+            }
+            catch(Error e) {
+                if(!(e is IOError.EXISTS)) {
+                    throw e;
+                }
+            }
+        }
+    }
+
+    /**
+     * Resolves {@link current_link} and returns "a" or "b" when it names an
+     * existing slot, null when the link is missing, dangling or points
+     * elsewhere — the caller's cue that no previous good slot exists.
+     */
+    public string? read_current_slot() {
+        string target;
+        try {
+            target = FileUtils.read_link(current_link);
+        }
+        catch(Error e) {
+            return null;
+        }
+        var slot = Path.get_basename(target);
+        // A wiped slot directory must not count as deployable even when the
+        // link still names it
+        if(!is_valid_slot(slot) || !FileUtils.test(slot_path(slot), FileTest.IS_DIR)) {
+            return null;
+        }
+        return slot;
+    }
+
+    /**
+     * Returns the slot that {@link read_current_slot} does not name — the
+     * deployment target — or null when no slot is current.
+     */
+    public string? inactive_slot() {
+        var current = read_current_slot();
+        if(current == null) {
+            return null;
+        }
+        return current == "a" ? "b" : "a";
+    }
+
+    /**
+     * Points {@link current_link} at {@link slot} atomically: the new symlink
+     * is staged under a temporary name and moved over `current` with
+     * rename(2), so a reader always observes a complete link to one slot or
+     * the other, never a missing one.
+     */
+    public void flip_current(string slot) throws Error {
+        if(!is_valid_slot(slot)) {
+            throw new IOError.INVALID_ARGUMENT(@"Unknown slot \"$slot\" (expected \"a\" or \"b\")");
+        }
+        var staged_path = Path.build_filename(appdir, ".current.tmp");
+        try {
+            File.new_for_path(staged_path).delete();
+        }
+        catch(Error e) {
+            if(!(e is IOError.NOT_FOUND)) {
+                throw e;
+            }
+        }
+        // A relative target keeps the layout valid when the appdir moves wholesale
+        File.new_for_path(staged_path).make_symbolic_link(slot);
+        if(FileUtils.rename(staged_path, current_link) != 0) {
+            throw new FileError.FAILED(@"Could not move the staged link over \"current\": $(Posix.strerror(Posix.errno))");
+        }
+    }
+
+    /**
+     * Reads {@link deployed_commit_path}, tolerating a trailing newline;
+     * null when the file is missing or unreadable (nothing deployed yet).
+     */
+    public string? read_deployed_commit() {
+        string contents;
+        try {
+            FileUtils.get_contents(deployed_commit_path, out contents);
+        }
+        catch(Error e) {
+            return null;
+        }
+        var commit = contents.chomp();
+        return commit.length > 0 ? commit : null;
+    }
+
+    /**
+     * Records {@link sha} in {@link deployed_commit_path} as the commit of
+     * the last successful deployment.
+     */
+    public void write_deployed_commit(string sha) throws Error {
+        FileUtils.set_contents(deployed_commit_path, sha + "\n");
+    }
+
+    /**
+     * PATH-style search for {@link exec_name} within {@link slot}, trying
+     * each slot-relative bin directory in turn and returning the first
+     * candidate whose executable bit is set, null when none matches. An
+     * exec name containing "/" is rejected — usage rules allow bare names
+     * only.
+     */
+    public string? resolve_exec(string slot, string exec_name) {
+        if(exec_name.contains("/")) {
+            return null;
+        }
+        foreach(var directory in EXEC_SEARCH_DIRECTORIES) {
+            var candidate = Path.build_filename(slot_path(slot), directory, exec_name);
+            if(FileUtils.test(candidate, FileTest.IS_EXECUTABLE)) {
+                return candidate;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Absolute path of the install root for {@link slot} ("a" or "b").
+     */
+    public string slot_path(string slot) {
+        return Path.build_filename(appdir, slot);
+    }
+
+    private bool is_valid_slot(string slot) {
+        return slot == "a" || slot == "b";
+    }
+}
+}

+ 131 - 0
src/rad/Source.vala

@@ -0,0 +1,131 @@
+using Invercargill.DataStructures;
+
+namespace Usm.Rad {
+
+/**
+ * Supervision of the git checkout usmrad deploys from: an idempotent clone,
+ * a branch-tracked fetch, remote-head resolution and hard synchronisation
+ * of the working tree to the remote branch.
+ *
+ * The checkout lives at the {@link Slots.repo_dir} position of the appdir
+ * layout and is owned by usmrad — nothing else writes to it.
+ */
+public class Source : Object {
+
+    private string repo_url;
+    private string branch;
+    private string repo_dir;
+
+    /**
+     * Tracks {@link repo_url}'s {@link branch} inside {@link repo_dir},
+     * cloning on demand rather than in the constructor.
+     */
+    public Source(string repo_url, string branch, string repo_dir) {
+        this.repo_url = repo_url;
+        this.branch = branch;
+        this.repo_dir = repo_dir;
+    }
+
+    /**
+     * Clones {@link repo_url} into {@link repo_dir} on the tracked branch
+     * when the checkout does not already exist; an existing checkout is a
+     * no-op. Clone progress streams into the container log.
+     */
+    public void ensure_cloned() throws RadError {
+        if(FileUtils.test(Path.build_filename(repo_dir, ".git"), FileTest.EXISTS)) {
+            return;
+        }
+        var command = new Vector<string>();
+        command.add("git");
+        command.add("clone");
+        command.add("--branch");
+        command.add(branch);
+        command.add(repo_url);
+        command.add(repo_dir);
+        run_command(command.to_array());
+    }
+
+    /**
+     * Fetches the tracked branch from origin, leaving the working tree for
+     * {@link sync_to_remote} to move.
+     */
+    public void fetch() throws RadError {
+        run_git({ "fetch", "origin", branch });
+    }
+
+    /**
+     * Resolves the tracked branch's head on origin, chomped; throws
+     * {@link RadError.GIT} carrying git's own stderr when the ref cannot
+     * be read.
+     */
+    public string remote_head() throws RadError {
+        var command = new Vector<string>();
+        command.add("git");
+        command.add("-C");
+        command.add(repo_dir);
+        command.add("rev-parse");
+        command.add(@"origin/$branch");
+        try {
+            // Read-only: capture both streams so the thrown message keeps
+            // git's diagnostics instead of splicing them into the log
+            var proc = new Subprocess.newv(command.to_array(),
+                SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
+            string git_stdout;
+            string git_stderr;
+            proc.communicate_utf8(null, null, out git_stdout, out git_stderr);
+            if(proc.get_exit_status() != 0) {
+                var detail = git_stderr.chomp();
+                if(detail.length > 0) {
+                    throw new RadError.GIT(@"git rev-parse origin/$branch failed: $detail");
+                }
+                throw new RadError.GIT(@"git rev-parse origin/$branch failed");
+            }
+            return git_stdout.chomp();
+        }
+        catch(RadError e) {
+            throw e;
+        }
+        catch(Error e) {
+            throw new RadError.GIT(@"Could not resolve \"origin/$branch\": $(e.message)");
+        }
+    }
+
+    /**
+     * Synchronises the working tree to the tracked branch's remote head:
+     * `reset --hard` discards local edits and `clean -xfd` removes
+     * untracked files, so every build starts from the reviewed state.
+     */
+    public void sync_to_remote() throws RadError {
+        run_git({ "reset", "--hard", @"origin/$branch" });
+        run_git({ "clean", "-xfd" });
+    }
+
+    /**
+     * Runs git with {@link arguments} scoped to the checkout directory.
+     */
+    private void run_git(string[] arguments) throws RadError {
+        var command = new Vector<string>();
+        command.add("git");
+        command.add("-C");
+        command.add(repo_dir);
+        foreach(var argument in arguments) {
+            command.add(argument);
+        }
+        run_command(command.to_array());
+    }
+
+    /**
+     * Waits for {@link command} to succeed with inherited fds, so git's
+     * progress and diagnostics land in the container log verbatim.
+     */
+    private void run_command(string[] command) throws RadError {
+        try {
+            new Subprocess.newv(command, SubprocessFlags.INHERIT_FDS).wait_check();
+        }
+        catch(Error e) {
+            var joined = string.joinv(" ", command);
+            throw new RadError.GIT(@"Could not run \"$joined\": $(e.message)");
+        }
+    }
+}
+}

+ 200 - 0
src/rad/Supervisor.vala

@@ -0,0 +1,200 @@
+namespace Usm.Rad {
+
+/**
+ * Owns the supervised application process: launches it with inherited
+ * descriptors from a fixed working directory, reports spontaneous exits,
+ * and terminates it on request — SIGTERM, a grace period, then SIGKILL —
+ * on both the slot-swap path and the SIGTERM/SIGINT shutdown path, never
+ * mistaking a deliberate termination for a spontaneous exit. Only one
+ * child is supervised at a time.
+ */
+public class Supervisor : Object {
+
+    /**
+     * Emitted when the supervised process exits without the supervisor
+     * terminating it; the status is the child's exit code, or 128 plus the
+     * signal number when it died by a signal. Deliberate terminations —
+     * {@link terminate} and the shutdown path armed by {@link run} — are
+     * suppressed and reported through {@link shutdown} instead.
+     */
+    public signal void exited(int status);
+
+    /**
+     * Emitted once a SIGTERM or SIGINT received by usmrad has been
+     * forwarded and the child is gone (grace period elapsed, SIGKILL if
+     * needed) — or immediately when no child was running.
+     */
+    public signal void shutdown(int signal_number);
+
+    string working_directory;
+    int term_grace_seconds;
+    Subprocess child = null;
+    bool suppress_exit = false;
+
+    /** Notifies a pending {@link stop_child} that the child was reaped. */
+    signal void reaped();
+
+    /**
+     * Whether a supervised child is currently alive; false until
+     * {@link launch} succeeds and once the child has been reaped.
+     */
+    public bool running {
+        get {
+            return child != null;
+        }
+    }
+
+    /**
+     * Prepares supervision of children launched in the given working
+     * directory, allowing the given number of seconds between SIGTERM and
+     * SIGKILL on both termination paths.
+     */
+    public Supervisor(string working_directory, int term_grace_seconds) {
+        this.working_directory = working_directory;
+        this.term_grace_seconds = term_grace_seconds;
+    }
+
+    /**
+     * Installs the SIGTERM and SIGINT handlers: each is forwarded to the
+     * child as an immediate SIGTERM, escalated to a SIGKILL after the
+     * grace period, and followed by {@link shutdown} once the child is
+     * gone. With no child running, {@link shutdown} is emitted
+     * immediately. Call before the main loop runs.
+     */
+    public void run() {
+        Unix.signal_add(Posix.SIGTERM, () => {
+            graceful_shutdown.begin(Posix.SIGTERM);
+            return false;
+        });
+        Unix.signal_add(Posix.SIGINT, () => {
+            graceful_shutdown.begin(Posix.SIGINT);
+            return false;
+        });
+    }
+
+    /**
+     * Launches {@link executable} with {@link args} as the supervised
+     * child, in the supervisor's working directory and with every file
+     * descriptor inherited so the application's output streams into the
+     * container log; arms the exit watch that reports spontaneous exits
+     * through {@link exited}.
+     */
+    public void launch(string executable, string[] args) throws Error {
+        if (child != null) {
+            Log.warning("Launching while the previous supervised instance is still running; it is not terminated");
+        }
+
+        var argv = new string[1 + args.length];
+        argv[0] = executable;
+        for (var index = 0; index < args.length; index++) {
+            argv[index + 1] = args[index];
+        }
+
+        var launcher = new SubprocessLauncher(SubprocessFlags.INHERIT_FDS);
+        launcher.set_cwd(working_directory);
+        child = launcher.spawnv(argv);
+        child.wait_async.begin(null, on_wait_complete);
+    }
+
+    /**
+     * Terminates the supervised child for a slot swap: SIGTERM, then
+     * SIGKILL once the grace period elapses, resolving only when the child
+     * is gone. The exit is suppressed — never reported through
+     * {@link exited}. A no-op when no child is running.
+     */
+    public async void terminate() {
+        if (child == null) {
+            return;
+        }
+        Log.info("Terminating the supervised instance (SIGTERM)");
+        yield stop_child();
+    }
+
+    /**
+     * The signal-shutdown path: forward SIGTERM to the child, wait out the
+     * grace period with a SIGKILL escalation, then emit {@link shutdown};
+     * with no child running the shutdown is emitted straight away.
+     */
+    private async void graceful_shutdown(int signal_number) {
+        var signal_name = signal_number == Posix.SIGTERM ? "SIGTERM" : "SIGINT";
+        if (child != null) {
+            Log.info(@"Received $signal_name; forwarding SIGTERM to the supervised instance");
+            yield stop_child();
+        }
+        else {
+            Log.info(@"Received $signal_name with no supervised instance running");
+        }
+        shutdown(signal_number);
+    }
+
+    /**
+     * The shared termination core: suppress the pending exit, SIGTERM the
+     * child and SIGKILL it after the grace period (immediately for a zero
+     * grace), resuming once the reaped notification fires.
+     */
+    private async void stop_child() {
+        var proc = child;
+        if (proc == null) {
+            return;
+        }
+
+        suppress_exit = true;
+        proc.send_signal(Posix.SIGTERM);
+
+        ulong handler_id = 0;
+        handler_id = reaped.connect(() => {
+            SignalHandler.disconnect(this, handler_id);
+            Idle.add(stop_child.callback);
+        });
+
+        if (term_grace_seconds > 0) {
+            Timeout.add_seconds(term_grace_seconds, () => {
+                // Only the child this stop belongs to may be killed: a
+                // newer instance launched after a completed swap must
+                // survive a stale grace timer
+                if (child == proc) {
+                    Log.info(@"The supervised instance did not exit within $(term_grace_seconds) seconds; sending SIGKILL");
+                    proc.force_exit();
+                }
+                return false;
+            });
+        }
+        else {
+            proc.force_exit();
+        }
+
+        yield;
+        suppress_exit = false;
+    }
+
+    /**
+     * Completes the exit watch armed by {@link launch}: reaps the child
+     * and either resumes a pending {@link stop_child} (the exit was
+     * suppressed) or reports a spontaneous exit through {@link exited}
+     * with the child's exit code, or 128 plus the signal number when it
+     * died by a signal. Stale watches from replaced children are ignored.
+     */
+    private void on_wait_complete(Object? source_object, AsyncResult result) {
+        var proc = (Subprocess) source_object;
+        try {
+            proc.wait_async.end(result);
+        }
+        catch (Error e) {
+            Log.warning(@"Could not reap the supervised process: $(e.message)");
+        }
+
+        if (proc != child) {
+            return;
+        }
+
+        child = null;
+        if (suppress_exit) {
+            reaped();
+            return;
+        }
+
+        exited(proc.get_if_signaled() ? 128 + proc.get_term_sig() : proc.get_exit_status());
+    }
+}
+
+}

+ 453 - 0
src/rad/System.vala

@@ -0,0 +1,453 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Usm.Rad {
+
+/**
+ * USM system integration for the rapid-deployment supervisor.
+ *
+ * The cheap queries ({@link Usm.ResourceFinder}, {@link Usm.Manifest}
+ * parsing, {@link Usm.SystemPackageManager}) run in-process against
+ * libusm, while every stateful operation (enrolment, repository wiring,
+ * updates, installs) spawns the `usm` CLI with inherited fds so its
+ * progress and errors land in the container log stream verbatim.
+ */
+public class System : Object {
+
+    // The enrolled managed-state root; src/cli/Enroll.vala keeps its copy
+    // private, so the default is replicated here for the snapshot fallback
+    private const string DEFAULT_STATE_PATH = "/var/usm";
+
+    private Config config;
+    private string usm_binary;
+
+    /**
+     * Creates an integration bound to the supervisor's {@link Config} and
+     * the resolved `usm` executable, which every spawned operation uses.
+     */
+    public System(Config config, string usm_binary) {
+        this.config = config;
+        this.usm_binary = usm_binary;
+    }
+
+    /**
+     * Ensures this system is managed by usm by spawning
+     * `usm enroll -y`. Enrolment is idempotent on the usm side (the image
+     * may already be pre-enrolled), so this is safe to run at every
+     * startup; a non-zero exit throws {@link RadError.DEPLOY} carrying the
+     * exit status.
+     */
+    public void ensure_enrolled() throws RadError {
+        Log.info("Enrolling the system with usm");
+        run_usm(new string[] { "enroll", "-y" });
+    }
+
+    /**
+     * Wires every {@link Config.usm_repos} entry into usm's repos.d by
+     * spawning `usm add-repo -y <entry>` per repository. The first failing
+     * entry throws {@link RadError.DEPLOY} naming it — whether that is
+     * fatal is the caller's decision (the supervisor warns and continues).
+     */
+    public void wire_repos() throws RadError {
+        foreach(var entry in config.usm_repos) {
+            Log.info(@"Wiring USM repository \"$entry\"");
+            try {
+                run_usm(new string[] { "add-repo", "-y", entry });
+            }
+            catch(RadError e) {
+                throw new RadError.DEPLOY(@"Could not wire USM repository \"$entry\": $(e.message)");
+            }
+        }
+    }
+
+    /**
+     * Refreshes repository listings and updates installed packages by
+     * spawning `usm update -y`, then reports whether the set of installed
+     * USM packages changed (the supervisor's trigger for redeploying
+     * behind an unchanged commit).
+     *
+     * The snapshot names and versions every installed package through
+     * {@link Usm.SystemState}; an unenrolled or unreadable state falls
+     * back to listing the managed `installed` marks directory, and an
+     * unreadable directory degrades to an empty snapshot with a warning —
+     * a failed comparison at worst triggers one redundant deployment.
+     */
+    public async bool refresh_and_update() throws RadError {
+        var before = installed_packages_snapshot();
+        Log.info("Refreshing USM repositories and updating installed packages");
+        yield run_usm_async(new string[] { "update", "-y" });
+        var after = installed_packages_snapshot();
+        return before != after;
+    }
+
+    /**
+     * Satisfies the dependencies of the manifest in {@link repo_dir}
+     * before `usm manifest install` runs (that command exits 252 on
+     * unmet dependencies). Every ref from the manifest's build, manage
+     * and runtime phases is checked with one {@link Usm.ResourceFinder};
+     * missing refs are resolved through one batched
+     * {@link Usm.SystemPackageManager.query_sync} against the configured
+     * helper, then through `usm provides` per remaining ref, and the
+     * collected packages installed with `usm install -y` (USM names) and
+     * {@link Usm.SystemPackageManager.install} (native names). A fresh
+     * finder re-checks afterwards; refs still missing throw
+     * {@link RadError.DEPENDENCY}.
+     *
+     * Grouped candidate phases contribute every alternative ref, so an
+     * unchosen alternative is installed rather than treated as optional.
+     *
+     * Limitation: version-constrained refs (for example `lib:foo>=2`)
+     * are stripped to their names for provider lookup — SPM helpers and
+     * `usm provides` match names exactly — so the constraint holds only
+     * insofar as `usm install`'s own resolution enforces it.
+     */
+    public async void ensure_deps(string repo_dir) throws RadError {
+        var manifest_path = Path.build_filename(repo_dir, "MANIFEST.usm");
+        Usm.Manifest manifest;
+        try {
+            manifest = new Usm.Manifest.from_file(manifest_path);
+        }
+        catch(Error e) {
+            throw new RadError.DEPLOY(@"Could not read \"$manifest_path\": $(e.message)");
+        }
+
+        var seen = new HashSet<Usm.ResourceRef>();
+        var refs = new Vector<Usm.ResourceRef>();
+        var dependencies = manifest.dependencies ?? new Usm.Dependencies();
+        collect_phase_refs(dependencies.manage, seen, refs);
+        collect_phase_refs(dependencies.build, seen, refs);
+        collect_phase_refs(dependencies.runtime, seen, refs);
+
+        var finder = new Usm.ResourceFinder();
+        var missing = refs.where(r => !finder.has_resource(r)).to_vector();
+        if(missing.length == 0) {
+            return;
+        }
+        Log.info(@"$(missing.length) dependency ref(s) of \"$(manifest.name)\" missing");
+
+        var spm = load_system_package_manager();
+        var spm_names = new Vector<string>();
+        var unresolved = missing;
+        if(spm != null && spm.enabled) {
+            try {
+                var result = spm.query_sync(missing);
+                if(result != null) {
+                    var remaining = new Vector<Usm.ResourceRef>();
+                    foreach(var dependency in missing) {
+                        if(!covered_by_system_packages(dependency, result)) {
+                            remaining.add(dependency);
+                        }
+                    }
+                    foreach(var candidate in result.packages) {
+                        if(!spm_names.contains(candidate.name)) {
+                            spm_names.add(candidate.name);
+                        }
+                    }
+                    unresolved = remaining;
+                }
+            }
+            catch(Error e) {
+                Log.warning(@"System package manager query failed: $(e.message); continuing with USM repositories");
+            }
+        }
+        else {
+            Log.warning("No system package manager is configured; resolving missing dependencies from USM repositories alone");
+        }
+
+        var usm_names = new Vector<string>();
+        if(unresolved.length > 0) {
+            foreach(var dependency in unresolved) {
+                usm_names.add_all(usm_providers(@"$(dependency.resource_type.to_string()):$(dependency.base_name)"));
+            }
+        }
+
+        if(usm_names.length > 0) {
+            Log.info(@"Installing USM packages: $(string.joinv(" ", usm_names.to_array()))");
+            var arguments = new Vector<string>();
+            arguments.add("install");
+            arguments.add("-y");
+            arguments.add_all(usm_names);
+            yield run_usm_async(arguments.to_array());
+        }
+
+        if(spm_names.length > 0) {
+            Log.info(@"Installing system packages: $(string.joinv(" ", spm_names.to_array()))");
+            var system_installed = false;
+            try {
+                system_installed = yield spm.install(spm_names, event => log_install_progress(event));
+            }
+            catch(Error e) {
+                throw new RadError.DEPLOY(@"Could not run the system package manager install: $(e.message)");
+            }
+            if(!system_installed) {
+                throw new RadError.DEPLOY(@"The system package manager failed to install: $(string.joinv(" ", spm_names.to_array()))");
+            }
+        }
+
+        var recheck = new Usm.ResourceFinder();
+        var unmet = missing.where(r => !recheck.has_resource(r)).to_vector();
+        if(unmet.length > 0) {
+            var listing = string.joinv(", ", unmet.select<string>(r => r.to_string()).to_array());
+            throw new RadError.DEPENDENCY(@"Dependencies not satisfied after installation: $listing");
+        }
+    }
+
+    /**
+     * Spawns `usm` with inherited fds and waits for it, throwing
+     * {@link RadError.DEPLOY} with the exit-status (or signal) detail on
+     * failure.
+     */
+    private void run_usm(string[] arguments) throws RadError {
+        var argv = usm_argv(arguments);
+        var command = string.joinv(" ", argv);
+        Subprocess process;
+        try {
+            process = new Subprocess.newv(argv, SubprocessFlags.INHERIT_FDS);
+        }
+        catch(Error e) {
+            throw new RadError.DEPLOY(@"Could not spawn \"$command\": $(e.message)");
+        }
+        process.wait();
+        check_usm_exit(command, process);
+    }
+
+    /** The asynchronous twin of {@link run_usm} for long-running updates. */
+    private async void run_usm_async(string[] arguments) throws RadError {
+        var argv = usm_argv(arguments);
+        var command = string.joinv(" ", argv);
+        Subprocess process;
+        try {
+            process = new Subprocess.newv(argv, SubprocessFlags.INHERIT_FDS);
+        }
+        catch(Error e) {
+            throw new RadError.DEPLOY(@"Could not spawn \"$command\": $(e.message)");
+        }
+        try {
+            yield process.wait_async(null);
+        }
+        catch(Error e) {
+            throw new RadError.DEPLOY(@"Could not wait for \"$command\": $(e.message)");
+        }
+        check_usm_exit(command, process);
+    }
+
+    /** Prepends the resolved usm executable to a command's arguments. */
+    private string[] usm_argv(string[] arguments) {
+        var argv = new string[arguments.length + 1];
+        argv[0] = usm_binary;
+        for(int index = 0; index < arguments.length; index++) {
+            argv[index + 1] = arguments[index];
+        }
+        return argv;
+    }
+
+    /** Throws {@link RadError.DEPLOY} naming {@link command} unless it exited cleanly. */
+    private static void check_usm_exit(string command, Subprocess process) throws RadError {
+        if(process.get_if_signaled()) {
+            throw new RadError.DEPLOY(@"\"$command\" was terminated by signal $(process.get_term_sig())");
+        }
+        if(process.get_exit_status() != 0) {
+            throw new RadError.DEPLOY(@"\"$command\" exited with status $(process.get_exit_status())");
+        }
+    }
+
+    /**
+     * Collects every ref of one dependency phase (the union across
+     * candidate groups for grouped phases) into {@link refs}, skipping
+     * refs already collected through an earlier phase.
+     */
+    private static void collect_phase_refs(Usm.DependencyPhase? phase, HashSet<Usm.ResourceRef> seen, Vector<Usm.ResourceRef> refs) {
+        if(phase == null) {
+            return;
+        }
+        foreach(var resource in phase.all_refs()) {
+            if(seen.contains(resource)) {
+                continue;
+            }
+            seen.add(resource);
+            refs.add(resource);
+        }
+    }
+
+    /**
+     * The system package manager for the usm configuration, or null when
+     * the configuration cannot be read — dependency resolution then runs
+     * against USM repositories alone.
+     */
+    private static Usm.SystemPackageManager? load_system_package_manager() {
+        try {
+            return new Usm.SystemPackageManager(new Usm.Configuration.from_paths(new Usm.Paths()));
+        }
+        catch(Error e) {
+            Log.warning(@"Could not read the usm configuration, continuing without a system package manager: $(e.message)");
+            return null;
+        }
+    }
+
+    /**
+     * Whether any package of a query {@link result} provides
+     * {@link dependency}: {@link Usm.ResourceRef.satisfied_by} against
+     * each candidate's resources, with an exact string match as fallback
+     * for constrained refs whose helper echoed the constraint back.
+     */
+    private static bool covered_by_system_packages(Usm.ResourceRef dependency, Usm.SystemQueryResult result) {
+        foreach(var candidate in result.packages) {
+            foreach(var resource in candidate.resources) {
+                if(dependency.satisfied_by(resource) || dependency.to_string() == resource.to_string()) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * The package names providing {@link resource_ref}, collected from
+     * `usm provides` stdout lines of the form
+     * `name-version (origin) provides ref` (see src/cli/Provides.vala).
+     * Exit status 1 (nothing provides the ref) is an expected miss; other
+     * failures warn and return what was read — the closing re-check
+     * turns silent gaps into a {@link RadError.DEPENDENCY}.
+     */
+    private Vector<string> usm_providers(string resource_ref) {
+        var names = new Vector<string>();
+        var argv = usm_argv(new string[] { "provides", resource_ref });
+        var command = string.joinv(" ", argv);
+        Subprocess process;
+        try {
+            // Stdout is captured for parsing; stderr inherits by default,
+            // so repository refresh chatter reaches the container log
+            process = new Subprocess.newv(argv, SubprocessFlags.STDOUT_PIPE);
+        }
+        catch(Error e) {
+            Log.warning(@"Could not spawn \"$command\": $(e.message)");
+            return names;
+        }
+        try {
+            var lines = new DataInputStream(process.get_stdout_pipe());
+            string line = null;
+            while((line = lines.read_line()) != null) {
+                var name = provides_package_name(line);
+                if(name != null && !names.contains(name)) {
+                    names.add(name);
+                }
+            }
+        }
+        catch(Error e) {
+            Log.warning(@"Could not read the output of \"$command\": $(e.message)");
+        }
+        process.wait();
+        var status = process.get_exit_status();
+        if(status != 0 && status != 1) {
+            Log.warning(@"\"$command\" exited with status $status");
+        }
+        return names;
+    }
+
+    /**
+     * The package name of one `usm provides` result line, or null for
+     * lines that do not match the `name-version (origin) provides ref`
+     * shape. Package names may contain dashes; usm versions never do, so
+     * the name ends at the final dash before the origin parenthesis.
+     */
+    private static string? provides_package_name(string line) {
+        var origin = line.index_of(" (");
+        var provides = line.index_of(") provides ");
+        if(origin < 1 || provides < origin) {
+            return null;
+        }
+        var name_and_version = line.substring(0, origin);
+        var separator = name_and_version.last_index_of("-");
+        if(separator < 1) {
+            return null;
+        }
+        return name_and_version.substring(0, separator);
+    }
+
+    /**
+     * One line per streamed system package install event; the per-package
+     * progress fraction events stay unlogged to keep the supervisor's
+     * output readable.
+     */
+    private static void log_install_progress(Usm.SystemInstallEvent event) {
+        switch(event.event_type) {
+            case Usm.SystemInstallEventType.BEGIN:
+                Log.info(@"System package transaction started ($(event.total) packages)");
+                break;
+            case Usm.SystemInstallEventType.PACKAGE_COMPLETE:
+                Log.info(@"Installed system package \"$(event.name ?? "")\"");
+                break;
+            case Usm.SystemInstallEventType.COMPLETE:
+                Log.info(@"System package transaction completed ($(event.installed) packages installed)");
+                break;
+            case Usm.SystemInstallEventType.ERROR:
+                Log.warning(@"System package install failed: $(event.message ?? "")");
+                break;
+            default:
+                break;
+        }
+    }
+
+    /**
+     * A deterministic fingerprint of the installed-package set: sorted
+     * `name-version` lines. An unreadable managed state degrades to
+     * {@link installed_marks_snapshot}.
+     */
+    private static string installed_packages_snapshot() {
+        var entries = new Vector<string>();
+        try {
+            var state = new Usm.SystemState(new Usm.Paths());
+            foreach(var installed in state.get_installed_packages()) {
+                try {
+                    var manifest = installed.get_manifest();
+                    entries.add(@"$(manifest.name)-$(manifest.version.to_string())");
+                }
+                catch(Error e) {
+                    Log.warning(@"Skipping unreadable installed package \"$(installed.package_name)\": $(e.message)");
+                }
+            }
+        }
+        catch(Error e) {
+            return installed_marks_snapshot();
+        }
+        return string.joinv("\n", entries.sort((a, b) => a.collate(b)).to_array());
+    }
+
+    /**
+     * The snapshot fallback for an unenrolled or unreadable state: the
+     * names of the `installed` marks directory, which are themselves
+     * `name-version`. An unreadable directory warns and yields an empty
+     * snapshot.
+     */
+    private static string installed_marks_snapshot() {
+        var entries = new Vector<string>();
+        try {
+            foreach(var mark in Iterate.directory(Path.build_filename(managed_state_path(), "installed"))) {
+                entries.add(mark);
+            }
+        }
+        catch(Error e) {
+            Log.warning(@"Could not list the usm installed-packages directory, treating the snapshot as empty: $(e.message)");
+            return "";
+        }
+        return string.joinv("\n", entries.sort((a, b) => a.collate(b)).to_array());
+    }
+
+    /**
+     * The managed state root from usm.config's managed section, or the
+     * enrolment default when no readable configuration carries one.
+     */
+    private static string managed_state_path() {
+        try {
+            var configuration = new Usm.Configuration.from_paths(new Usm.Paths());
+            if(configuration.managed_config != null && configuration.managed_config.state_path != null) {
+                return configuration.managed_config.state_path;
+            }
+        }
+        catch(Error e) {
+            // Unenrolled or unreadable: the enrolment default below
+        }
+        return DEFAULT_STATE_PATH;
+    }
+}
+}

+ 395 - 0
src/rad/Usmrad.vala

@@ -0,0 +1,395 @@
+/**
+ * usmrad — USM Rapid Application Deployment supervisor.
+ *
+ * Tracks a git branch, builds and installs the branch's manifest into an
+ * A/B slot layout under the application directory, and keeps an instance of
+ * the application's executable running from the active slot. A deployment
+ * failure never disturbs a running instance; a successful deployment
+ * terminates the old instance and launches the new one. When the supervised
+ * executable exits, usmrad exits with its exit code.
+ *
+ * Configuration is entirely environmental; see {@link Config}.
+ */
+namespace Usm.Rad {
+
+/**
+ * Failure modes of the rapid-deployment supervisor.
+ */
+public errordomain RadError {
+    /** A USMRAD_* environment variable is missing or malformed */
+    CONFIGURATION,
+    /** A git operation (clone, fetch, reset) failed */
+    GIT,
+    /** A build or install step failed */
+    DEPLOY,
+    /** A dependency resource could not be satisfied by the SPM or USM repositories */
+    DEPENDENCY
+}
+
+/**
+ * Bracketed-origin logging for the usmrad supervisor, per STYLE.md.
+ */
+public class Log {
+
+    /**
+     * Prints an informational line to stdout with the {@link Log} origin
+     * prefix.
+     */
+    public static void info(string message) {
+        print("[usmrad] %s\n", message);
+    }
+
+    /**
+     * Prints a warning line to stderr with the {@link Log} origin prefix.
+     */
+    public static void warning(string message) {
+        printerr("[usmrad] %s\n", message);
+    }
+}
+
+/**
+ * usmrad configuration, parsed from the USMRAD_* environment.
+ */
+public class Config : Object {
+
+    /** Git repository URL to deploy (USMRAD_REPO, required). */
+    public string repo { get; set; }
+    /** Branch to track (USMRAD_BRANCH, default "master"). */
+    public string branch { get; set; default = "master"; }
+    /** Poll interval in minutes; 0 disables polling (USMRAD_POLL). */
+    public double poll_minutes { get; set; default = 0; }
+    /** Application directory (USMRAD_APPDIR, default "/app"). */
+    public string appdir { get; set; default = "/app"; }
+    /** USMR URIs wired into usm at startup (USMRAD_USM_REPOS, semicolon-delimited). */
+    public string[] usm_repos { get; set; default = new string[] {}; }
+    /** Seconds between SIGTERM and SIGKILL when terminating an instance (USMRAD_TERM_GRACE). */
+    public int term_grace_seconds { get; set; default = 10; }
+
+    /**
+     * Poll interval in whole seconds; 0 when polling is disabled. Fractional
+     * minutes round down, so USMRAD_POLL=0.25 polls every 15 seconds.
+     */
+    public uint poll_interval_seconds {
+        get {
+            return (uint)(poll_minutes * 60.0);
+        }
+    }
+
+    /**
+     * Reads the USMRAD_* environment applying defaults, throwing
+     * {@link RadError.CONFIGURATION} with a user-facing message when a
+     * variable is missing or malformed.
+     */
+    public static Config from_environment() throws RadError {
+        var config = new Config();
+
+        var repo = Environment.get_variable("USMRAD_REPO");
+        if(repo == null || repo.strip().length == 0) {
+            throw new RadError.CONFIGURATION("USMRAD_REPO is required (a git repository URL)");
+        }
+        config.repo = repo.strip();
+
+        var branch = Environment.get_variable("USMRAD_BRANCH");
+        if(branch != null && branch.strip().length > 0) {
+            config.branch = branch.strip();
+        }
+
+        var poll = Environment.get_variable("USMRAD_POLL");
+        if(poll != null && poll.strip().length > 0) {
+            double parsed_poll;
+            if(!double.try_parse(poll.strip(), out parsed_poll) || parsed_poll < 0) {
+                throw new RadError.CONFIGURATION(@"USMRAD_POLL must be a non-negative number of minutes, got \"$poll\"");
+            }
+            config.poll_minutes = parsed_poll;
+        }
+
+        var appdir = Environment.get_variable("USMRAD_APPDIR");
+        if(appdir != null && appdir.strip().length > 0) {
+            config.appdir = appdir.strip();
+        }
+
+        var repos = Environment.get_variable("USMRAD_USM_REPOS");
+        if(repos != null && repos.strip().length > 0) {
+            var entries = new Invercargill.DataStructures.Vector<string>();
+            foreach(var entry in repos.split(";")) {
+                if(entry.strip().length > 0) {
+                    entries.add(entry.strip());
+                }
+            }
+            config.usm_repos = entries.to_array();
+        }
+
+        var grace = Environment.get_variable("USMRAD_TERM_GRACE");
+        if(grace != null && grace.strip().length > 0) {
+            int64 parsed_grace;
+            if(!int64.try_parse(grace.strip(), out parsed_grace) || parsed_grace < 0) {
+                throw new RadError.CONFIGURATION(@"USMRAD_TERM_GRACE must be a non-negative number of seconds, got \"$grace\"");
+            }
+            config.term_grace_seconds = (int)parsed_grace;
+        }
+
+        return config;
+    }
+}
+
+Config rad_config;
+Slots rad_slots;
+Source rad_source;
+System rad_system;
+Deployer rad_deployer;
+Supervisor rad_supervisor;
+MainLoop rad_loop;
+string rad_exec_name;
+string[] rad_exec_args;
+int rad_exit_code = 0;
+bool rad_cycle_busy = false;
+bool rad_instance_running = false;
+
+// The test target links every rad source for pure-logic coverage; only the
+// real executable may own the process entry point
+#if !USMRAD_TESTS
+public static int main(string[] args) {
+    if(args.length < 2 || args[1].has_prefix("-")) {
+        usage();
+        return 255;
+    }
+    rad_exec_name = args[1];
+    rad_exec_args = args[2:args.length];
+
+    try {
+        rad_config = Config.from_environment();
+    }
+    catch(RadError e) {
+        Log.warning(e.message);
+        usage();
+        return 255;
+    }
+
+    rad_slots = new Slots(rad_config.appdir);
+    try {
+        rad_slots.ensure_layout();
+    }
+    catch(Error e) {
+        Log.warning(@"Could not create the appdir layout in \"$(rad_config.appdir)\": $(e.message)");
+        return 255;
+    }
+
+    var usm_binary = find_usm_binary();
+    if(usm_binary == null) {
+        Log.warning("Could not locate the usm executable next to usmrad or on PATH");
+        return 255;
+    }
+
+    rad_source = new Source(rad_config.repo, rad_config.branch, rad_slots.repo_dir);
+    rad_system = new System(rad_config, usm_binary);
+    rad_deployer = new Deployer(rad_config, rad_slots, rad_source, rad_system, usm_binary);
+    rad_supervisor = new Supervisor(rad_slots.pwd, rad_config.term_grace_seconds);
+
+    rad_loop = new MainLoop();
+    rad_supervisor.exited.connect(status => {
+        rad_exit_code = status;
+        rad_loop.quit();
+    });
+    rad_supervisor.shutdown.connect(signal_number => {
+        rad_exit_code = 128 + signal_number;
+        rad_loop.quit();
+    });
+    rad_supervisor.run();
+
+    // Launch the previous good instance first so a running deployment cycle
+    // never adds downtime for an instance that already works
+    rad_instance_running = launch_from_current();
+    if(rad_instance_running) {
+        Log.info(@"Launched previous instance from slot \"$(rad_slots.read_current_slot())\"");
+    }
+
+    Idle.add(() => {
+        startup_cycle.begin();
+        return false;
+    });
+
+    if(rad_config.poll_interval_seconds > 0) {
+        Timeout.add_seconds(rad_config.poll_interval_seconds, () => {
+            run_cycle.begin();
+            return true;
+        });
+    }
+
+    rad_loop.run();
+    return rad_exit_code;
+}
+
+/**
+ * Runs the startup check cycle: enrollment, repository wiring and one
+ * deployment check. With no previous instance a failed first deployment is
+ * fatal (exit 251); with one, failures merely warn.
+ */
+private static async void startup_cycle() {
+    try {
+        rad_system.ensure_enrolled();
+    }
+    catch(RadError e) {
+        Log.warning(@"usm enroll failed: $(e.message)");
+        rad_exit_code = 245;
+        rad_loop.quit();
+        return;
+    }
+
+    try {
+        rad_system.wire_repos();
+    }
+    catch(RadError e) {
+        Log.warning(@"Could not wire USMRAD_USM_REPOS repositories: $(e.message); continuing");
+    }
+
+    var deployed = yield run_cycle();
+    if(!deployed && !rad_instance_running) {
+        Log.warning("First deployment failed and no previous instance exists");
+        rad_exit_code = 251;
+        rad_loop.quit();
+    }
+}
+
+/**
+ * Runs one check cycle, guarded against re-entry: synchronise the source
+ * repository, refresh and update USM packages, and when new commits or
+ * package updates are available, deploy into the inactive slot and swap.
+ * Returns true when a deployment went through and a new instance launched.
+ */
+private static async bool run_cycle() {
+    if(rad_cycle_busy) {
+        return false;
+    }
+    rad_cycle_busy = true;
+    var deployed = false;
+    try {
+        deployed = yield run_cycle_inner();
+    }
+    finally {
+        rad_cycle_busy = false;
+    }
+    return deployed;
+}
+
+private static async bool run_cycle_inner() {
+    try {
+        rad_source.ensure_cloned();
+        rad_source.fetch();
+    }
+    catch(RadError e) {
+        Log.warning(@"Could not synchronise \"$(rad_config.repo)\": $(e.message); skipping cycle");
+        return false;
+    }
+
+    var deployed_commit = rad_slots.read_deployed_commit();
+    string remote_head = null;
+    try {
+        remote_head = rad_source.remote_head();
+    }
+    catch(RadError e) {
+        Log.warning(@"Could not resolve the remote head: $(e.message); skipping cycle");
+        return false;
+    }
+    var new_commits = deployed_commit == null || deployed_commit != remote_head;
+
+    var deps_changed = false;
+    try {
+        deps_changed = yield rad_system.refresh_and_update();
+    }
+    catch(RadError e) {
+        Log.warning(@"usm update failed: $(e.message); continuing with the commit check");
+    }
+
+    if(!new_commits && !deps_changed) {
+        return false;
+    }
+
+    Log.info(new_commits
+        ? @"New commits on \"$(rad_config.branch)\" ($remote_head)"
+        : "Upstream USM package updates detected");
+
+    var inactive_slot = rad_slots.inactive_slot() ?? "a";
+    try {
+        yield rad_deployer.deploy(inactive_slot);
+    }
+    catch(RadError e) {
+        Log.warning(@"Deployment into slot \"$inactive_slot\" failed: $(e.message)");
+        return false;
+    }
+
+    if(rad_instance_running) {
+        Log.info("Terminating previous instance");
+        yield rad_supervisor.terminate();
+        rad_instance_running = false;
+    }
+
+    if(launch_from_current()) {
+        rad_instance_running = true;
+        Log.info(@"Launched new instance from slot \"$(rad_slots.read_current_slot())\" ($remote_head)");
+        return true;
+    }
+
+    // The deployment succeeded but nothing launchable came out of it; the
+    // container supervisor is better placed to recover than a dead poll loop
+    Log.warning(@"\"$(rad_exec_name)\" does not resolve or launch from the deployed slot");
+    rad_exit_code = 251;
+    rad_loop.quit();
+    return true;
+}
+
+/**
+ * Launches the configured exec from the current slot. Returns false without
+ * ceremony when no slot is current; warns when a current slot exists but the
+ * exec does not resolve or launch within it.
+ */
+private static bool launch_from_current() {
+    var current_slot = rad_slots.read_current_slot();
+    if(current_slot == null) {
+        return false;
+    }
+
+    var executable = rad_slots.resolve_exec(current_slot, rad_exec_name);
+    if(executable == null) {
+        Log.warning(@"\"$(rad_exec_name)\" does not resolve in slot \"$current_slot\" (PATH-style search within the slot)");
+        return false;
+    }
+
+    try {
+        rad_supervisor.launch(executable, rad_exec_args);
+    }
+    catch(Error e) {
+        Log.warning(@"Could not launch \"$executable\": $(e.message)");
+        return false;
+    }
+    return true;
+}
+
+/**
+ * Locates the usm executable: a sibling of usmrad's own executable first
+ * (both install into the same bindir), then the PATH.
+ */
+private static string? find_usm_binary() {
+    try {
+        var own_path = FileUtils.read_link("/proc/self/exe");
+        var candidate = Path.build_filename(Path.get_dirname(own_path), "usm");
+        if(FileUtils.test(candidate, FileTest.IS_EXECUTABLE)) {
+            return candidate;
+        }
+    }
+    catch(Error e) {
+        // Fall through to the PATH search
+    }
+    return Environment.find_program_in_path("usm");
+}
+
+private static void usage() {
+    printerr("USAGE:\n\tusmrad <exec> [args...]\n\nEnvironment:\n");
+    printerr("\tUSMRAD_REPO (required) git repository URL\n");
+    printerr("\tUSMRAD_BRANCH branch to track (default: master)\n");
+    printerr("\tUSMRAD_POLL poll interval in minutes, 0 disables (default: 0)\n");
+    printerr("\tUSMRAD_APPDIR application directory (default: /app)\n");
+    printerr("\tUSMRAD_USM_REPOS semicolon-delimited .usmr URIs\n");
+    printerr("\tUSMRAD_TERM_GRACE seconds between SIGTERM and SIGKILL (default: 10)\n");
+}
+#endif
+}

+ 13 - 0
src/rad/meson.build

@@ -0,0 +1,13 @@
+
+rad_sources = files('Usmrad.vala')
+rad_sources += files('Slots.vala')
+rad_sources += files('Source.vala')
+rad_sources += files('System.vala')
+rad_sources += files('Deployer.vala')
+rad_sources += files('Supervisor.vala')
+
+rad_deps = dependencies
+rad_deps += usm_dep
+rad_deps += meson.get_compiler('vala').find_library('linux')
+
+executable('usmrad', rad_sources, dependencies: rad_deps, install: true)

+ 216 - 0
src/tests/TestMain.vala

@@ -2184,6 +2184,182 @@ echo "Installing libmeson-pkg.so to /usr/lib"
         Usm.Util.delete_tree(scratch);
     }
 
+    // ---- usmrad: configuration parsing ---------------------------------------------
+
+    const string[] USMRAD_VARIABLES = {
+        "USMRAD_REPO", "USMRAD_BRANCH", "USMRAD_POLL", "USMRAD_APPDIR", "USMRAD_USM_REPOS", "USMRAD_TERM_GRACE"
+    };
+
+    string?[] saved_usmrad_values;
+
+    /** Unsets every USMRAD_* variable, stashing the prior values for {@link restore_usmrad_env}. */
+    void clear_usmrad_env() {
+        saved_usmrad_values = new string?[USMRAD_VARIABLES.length];
+        for(int index = 0; index < USMRAD_VARIABLES.length; index++) {
+            saved_usmrad_values[index] = Environment.get_variable(USMRAD_VARIABLES[index]);
+            Environment.unset_variable(USMRAD_VARIABLES[index]);
+        }
+    }
+
+    /** Restores the values {@link clear_usmrad_env} stashed; other tests share this process. */
+    void restore_usmrad_env() {
+        for(int index = 0; index < USMRAD_VARIABLES.length; index++) {
+            var value = saved_usmrad_values[index];
+            if(value != null) {
+                Environment.set_variable(USMRAD_VARIABLES[index], value, true);
+            }
+            else {
+                Environment.unset_variable(USMRAD_VARIABLES[index]);
+            }
+        }
+    }
+
+    void test_rad_config_defaults() throws Error {
+        try {
+            clear_usmrad_env();
+            Environment.set_variable("USMRAD_REPO", "https://git.example/app.git", true);
+
+            var config = Usm.Rad.Config.from_environment();
+
+            check(config.repo == "https://git.example/app.git", "USMRAD_REPO is carried through");
+            check(config.branch == "master", "branch defaults to master");
+            check(config.poll_minutes == 0, "polling defaults to disabled");
+            check(config.poll_interval_seconds == 0, "a disabled poll yields no interval");
+            check(config.appdir == "/app", "appdir defaults to /app");
+            check(config.usm_repos.length == 0, "usm_repos defaults to empty");
+            check(config.term_grace_seconds == 10, "term grace defaults to 10 seconds");
+        }
+        finally {
+            restore_usmrad_env();
+        }
+    }
+
+    void test_rad_config_errors() throws Error {
+        try {
+            clear_usmrad_env();
+
+            try {
+                Usm.Rad.Config.from_environment();
+                check(false, "missing USMRAD_REPO is a configuration error");
+            }
+            catch(Usm.Rad.RadError e) {
+                check(e is Usm.Rad.RadError.CONFIGURATION, "missing USMRAD_REPO is a configuration error");
+            }
+
+            Environment.set_variable("USMRAD_REPO", "https://git.example/app.git", true);
+
+            Environment.set_variable("USMRAD_POLL", "abc", true);
+            try {
+                Usm.Rad.Config.from_environment();
+                check(false, "non-numeric USMRAD_POLL is rejected");
+            }
+            catch(Usm.Rad.RadError e) {
+                check(e is Usm.Rad.RadError.CONFIGURATION, "non-numeric USMRAD_POLL is rejected");
+            }
+
+            Environment.set_variable("USMRAD_POLL", "-1", true);
+            try {
+                Usm.Rad.Config.from_environment();
+                check(false, "negative USMRAD_POLL is rejected");
+            }
+            catch(Usm.Rad.RadError e) {
+                check(e is Usm.Rad.RadError.CONFIGURATION, "negative USMRAD_POLL is rejected");
+            }
+
+            Environment.unset_variable("USMRAD_POLL");
+            Environment.set_variable("USMRAD_TERM_GRACE", "x", true);
+            try {
+                Usm.Rad.Config.from_environment();
+                check(false, "non-numeric USMRAD_TERM_GRACE is rejected");
+            }
+            catch(Usm.Rad.RadError e) {
+                check(e is Usm.Rad.RadError.CONFIGURATION, "non-numeric USMRAD_TERM_GRACE is rejected");
+            }
+
+            Environment.set_variable("USMRAD_TERM_GRACE", "10", true);
+            Environment.set_variable("USMRAD_POLL", "0.25", true);
+            var config = Usm.Rad.Config.from_environment();
+            check(config.poll_minutes == 0.25, "fractional USMRAD_POLL parses");
+            check(config.poll_interval_seconds == 15, "0.25 minutes converts to a 15 second interval");
+        }
+        finally {
+            restore_usmrad_env();
+        }
+    }
+
+    // ---- usmrad: slot model ---------------------------------------------------------
+
+    void test_rad_slots_fresh_reads_null() throws Error {
+        var appdir = DirUtils.make_tmp("usmrad-tests-XXXXXX");
+        var slots = new Usm.Rad.Slots(appdir);
+        try {
+            check(slots.read_current_slot() == null, "read_current_slot is null on a fresh appdir");
+            check(slots.inactive_slot() == null, "inactive_slot is null on a fresh appdir");
+            Usm.Util.delete_tree(appdir);
+        }
+        catch(Error e) {
+            Usm.Util.delete_tree(appdir);
+            throw e;
+        }
+    }
+
+    void test_rad_slots_layout_flip_and_deployed_commit() throws Error {
+        var appdir = DirUtils.make_tmp("usmrad-tests-XXXXXX");
+        var slots = new Usm.Rad.Slots(appdir);
+        try {
+            slots.ensure_layout();
+            foreach(var name in new string[] { "a", "b", "pwd", "repo", "build" }) {
+                check(FileUtils.test(Path.build_filename(appdir, name), FileTest.IS_DIR), @"ensure_layout creates \"$name\"");
+            }
+            slots.ensure_layout();
+            check(slots.read_deployed_commit() == null, "read_deployed_commit returns null before anything is deployed");
+
+            slots.flip_current("a");
+            check(slots.read_current_slot() == "a", "flip_current makes slot \"a\" current");
+            check(slots.inactive_slot() == "b", "slot \"b\" is inactive while \"a\" is current");
+
+            slots.write_deployed_commit("0123456789abcdef");
+            check(slots.read_deployed_commit() == "0123456789abcdef", "the deployed commit round-trips");
+
+            slots.flip_current("b");
+            check(slots.read_current_slot() == "b", "flipping the symlink back makes slot \"b\" current");
+            check(slots.inactive_slot() == "a", "slot \"a\" is inactive while \"b\" is current");
+            check(slots.read_deployed_commit() == "0123456789abcdef", "the deployed commit survives a slot flip");
+            Usm.Util.delete_tree(appdir);
+        }
+        catch(Error e) {
+            Usm.Util.delete_tree(appdir);
+            throw e;
+        }
+    }
+
+    void test_rad_slots_resolve_exec_order() throws Error {
+        var appdir = DirUtils.make_tmp("usmrad-tests-XXXXXX");
+        var slots = new Usm.Rad.Slots(appdir);
+        try {
+            slots.ensure_layout();
+
+            var usr_bin_prog = Path.build_filename(slots.slot_path("a"), "usr", "bin", "prog");
+            DirUtils.create_with_parents(Path.get_dirname(usr_bin_prog), 0755);
+            FileUtils.set_contents(usr_bin_prog, "#!/bin/sh\nexit 0\n");
+            Posix.chmod(usr_bin_prog, 0755);
+
+            var bin_prog = Path.build_filename(slots.slot_path("a"), "bin", "prog");
+            DirUtils.create_with_parents(Path.get_dirname(bin_prog), 0755);
+            FileUtils.set_contents(bin_prog, "#!/bin/sh\nexit 0\n");
+            Posix.chmod(bin_prog, 0755);
+
+            check(slots.resolve_exec("a", "prog") == usr_bin_prog, "usr/bin wins over bin in the exec search order");
+            check(slots.resolve_exec("a", "absent") == null, "a missing exec name resolves to null");
+            check(slots.resolve_exec("a", "sub/prog") == null, "an exec name containing \"/\" is rejected");
+            Usm.Util.delete_tree(appdir);
+        }
+        catch(Error e) {
+            Usm.Util.delete_tree(appdir);
+            throw e;
+        }
+    }
+
     int main() {
         test_default_ignore();
         try {
@@ -2327,6 +2503,46 @@ echo "Installing libmeson-pkg.so to /usr/lib"
             print("FAIL progress reporting test threw: %s\n", e.message);
         }
 
+        try {
+            test_rad_config_defaults();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL rad config defaults test threw: %s\n", e.message);
+        }
+
+        try {
+            test_rad_config_errors();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL rad config errors test threw: %s\n", e.message);
+        }
+
+        try {
+            test_rad_slots_fresh_reads_null();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL rad fresh slot test threw: %s\n", e.message);
+        }
+
+        try {
+            test_rad_slots_layout_flip_and_deployed_commit();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL rad slot layout test threw: %s\n", e.message);
+        }
+
+        try {
+            test_rad_slots_resolve_exec_order();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL rad exec resolution test threw: %s\n", e.message);
+        }
+
         print("%d passed, %d failed\n", passes, failures);
         return failures == 0 ? 0 : 1;
     }

+ 2 - 1
src/tests/meson.build

@@ -3,8 +3,9 @@
 # lines and exits non-zero on failure.
 
 usm_tests = executable('usm-tests',
-    ['TestMain.vala'],
+    ['TestMain.vala'] + rad_sources,
     dependencies: [usm_dep] + dependencies,
+    vala_args: ['--define=USMRAD_TESTS'],
     install: false
 )