Răsfoiți Sursa

feat: replace docker integration with USM deployment - spry deploy, MANIFEST scaffolding, repository passthrough and verbose flag

clanker 1 săptămână în urmă
părinte
comite
57b13d92f0

+ 9 - 0
.usmignore

@@ -0,0 +1,9 @@
+# Build trees at any depth (directory-only: the directory and all beneath it)
+builddir*/
+
+# Database artifacts by basename at any depth
+*.sqlite*
+db.sqlite
+
+# Local agent session state (directory-only)
+.kilo/

+ 59 - 0
MANIFEST.usm

@@ -0,0 +1,59 @@
+{
+  "name": "spry",
+  "version": "0.2",
+  "summary": "Reusable authentication and user-management services for Statum, plus the spry CLI",
+  "licences": [],
+  "flags": ["ninjaStyleProgress"],
+  "provides": {
+    "lib:libspry-0.2.so": "as-expected",
+    "bin:spry": "as-expected",
+    "inc:spry-0.2.h": "as-expected",
+    "vapi:spry-0.2.vapi": "as-expected",
+    "gir:spry-0.2.gir": "as-expected",
+    "pc:spry-0.2.pc": "as-expected"
+  },
+  "depends": {
+    "runtime": [
+      "lib:libglib-2.0.so.0",
+      "lib:libgobject-2.0.so.0",
+      "lib:libgio-2.0.so.0",
+      "lib:libjson-glib-1.0.so.0",
+      "lib:libinvercargill-1.so",
+      "lib:libinversion-0.1.so",
+      "lib:libastralis-0.1.so",
+      "lib:libsodium.so.26",
+      "lib:libinvercargill-sql.so",
+      "lib:libinvercargill-sql-inversion.so",
+      "lib:libstatum-0.1.so",
+      "lib:libc.so.6"
+    ],
+    "build": [
+      "bin:valac",
+      "bin:meson",
+      "bin:ninja",
+      "bin:python3",
+      "pc:glib-2.0.pc",
+      "pc:gobject-2.0.pc",
+      "pc:gio-2.0.pc",
+      "pc:json-glib-1.0.pc",
+      "pc:libxml-2.0.pc",
+      "pc:sqlite3.pc",
+      "pc:libsodium.pc",
+      "pc:invercargill-1.pc",
+      "pc:invercargill-json.pc",
+      "pc:inversion-0.1.pc",
+      "pc:astralis-0.1.pc",
+      "pc:invercargill-sql.pc",
+      "pc:invercargill-sql-inversion.pc",
+      "pc:statum-0.1.pc"
+    ],
+    "manage": [
+      "bin:bash"
+    ]
+  },
+  "execs": {
+    "build": "usm-scripts/build.sh",
+    "install": "usm-scripts/install.sh",
+    "postInstall": "usm-scripts/post-install.sh"
+  }
+}

+ 40 - 8
README.md

@@ -179,14 +179,46 @@ Guards (`SpryAuth.require_login`/`require_permission`) build on
 
 ## The `spry` CLI
 
-`spry new <name>` scaffolds an application; `spry add page|action|resource`
-grows it inside marker blocks; `spry add login|register|user-management`
-adds auth UI bound to `Spry.Actions`; `spry keys` maintains the static key
-pairs; `spry docker` generates (and optionally builds) the image. `spry dev`
-is the development loop: build, run, watch — saving a file rebuilds and
-restarts the app (a failed build keeps the previous process running), and
-Statum's client-carried state makes restarts transparent to the browser.
-Run `spry --help` for the full command surface.
+`spry new <name>` scaffolds an application — including its USM packaging
+(`MANIFEST.usm` + `usm-scripts/` + `.usmignore`); `spry add
+page|action|resource` grows it inside marker blocks; `spry add
+login|register|user-management` adds auth UI bound to `Spry.Actions`;
+`spry keys` maintains the static key pairs; `spry deploy` builds the USM
+container image. `spry dev` is the development loop: build, run, watch —
+saving a file rebuilds and restarts the app (a failed build keeps the
+previous process running), and Statum's client-carried state makes
+restarts transparent to the browser. Run `spry --help` for the full
+command surface.
+
+## Deployment
+
+`spry deploy` delegates to `usm manifest deploy` in the application
+directory: the application's `MANIFEST.usm`/`usm-scripts/` drive an
+in-container `usm install` where the system package manager resolves the
+platform libraries and toolchain and USM repositories resolve the
+Web-Stack, then the image is saved as `<app>-<version>.image.tar.xz`
+(`podman load -i` to import).
+
+```
+spry deploy [--exec CMD] [--base IMAGE] [--repository FILE]...
+            [--installer-url URL] [--no-build] [--usm FILE]
+```
+
+- `--exec CMD` (default `<app> 8080`) — the container entrypoint.
+- `--base IMAGE` (default usm's `fedora:43`) — the base image.
+- `--repository FILE` (repeatable) — resolve from exactly these `.usmr`
+  repositories instead of the machine's configured set; a local Web-Stack
+  repository is the usual choice while the canonical one is unavailable.
+- `--installer-url URL` — override the canonical USM installer source the
+  image installs USM from; until that URL is hosted, its `file://` form
+  carries a locally built installer into the image (the sanctioned
+  local-testing path).
+- `--no-build` — stop after generating the deploy context.
+
+Keys are never packaged (`.usmignore` excludes `web-config.json`): run
+with ephemeral keys for plain page serving, or bind-mount the config
+read-only (`ASTRALIS_CONFIG_PATH` points the app at it) for full
+authentication.
 
 ## Migrating from old Spry
 

+ 74 - 0
tools/spry/Deploy.vala

@@ -0,0 +1,74 @@
+using GLib;
+
+namespace Spry.Cli {
+
+    /**
+     * `spry deploy` — packages and deploys the application as a container
+     * image through USM, delegating to `usm manifest deploy` in the
+     * application directory.
+     *
+     * USM resolves the whole graph inside the image build: the system
+     * package manager provides the platform libraries and toolchain, the
+     * configured (or `--repository`-named) USM repositories provide the
+     * Web-Stack, and the application's own `MANIFEST.usm`/`usm-scripts/`
+     * (scaffolded by `spry new`) drive its build and install. Flags pass
+     * through to `usm manifest deploy` verbatim; `--exec` defaults to
+     * `<app> 8080` so the container serves on the documented port.
+     */
+    public class Deploy : GLib.Object {
+
+        /**
+         * Runs `usm manifest deploy` in {@link app_dir} with the collected
+         * flags. The USM binary is resolved from `--usm` or PATH; a missing
+         * binary is reported with install guidance rather than a raw spawn
+         * error. Returns the delegate process's exit status.
+         */
+        public static int run(string app_dir, string app_name, string? usm_path,
+                string? exec_command, string? base_image, string[] repositories,
+                string? installer_url, bool no_build, bool verbose = false) throws Error {
+            var usm = usm_path != null ? (!)usm_path : Environment.find_program_in_path("usm");
+            if (usm == null) {
+                stderr.printf("Error: usm not found on PATH — install USM (or pass --usm <path-to-usm>) to deploy.\n");
+                return 1;
+            }
+
+            string[] argv = { (!)usm, "manifest", "deploy",
+                "--exec", exec_command ?? @"$app_name 8080" };
+            if (base_image != null) {
+                argv += "--base";
+                argv += base_image;
+            }
+            foreach (var repository in repositories) {
+                argv += "--repository";
+                argv += repository;
+            }
+            if (installer_url != null) {
+                argv += "--installer-url";
+                argv += installer_url;
+            }
+            if (no_build) {
+                argv += "--no-build";
+            }
+            if (verbose) {
+                argv += "--verbose";
+            }
+
+            var shown = "";
+            foreach (var word in argv[1:argv.length]) {
+                shown += (shown.length > 0 ? " " : "") + (word.contains(" ") ? "\"" + word + "\"" : word);
+            }
+            stdout.printf("Running: %s\n", shown);
+
+            var launcher = new SubprocessLauncher(SubprocessFlags.INHERIT_FDS);
+            launcher.set_cwd(app_dir);
+            var process = launcher.spawnv(argv);
+            try {
+                process.wait_check();
+                return 0;
+            }
+            catch(Error e) {
+                return process.get_exit_status();
+            }
+        }
+    }
+}

+ 0 - 93
tools/spry/Docker.vala

@@ -1,93 +0,0 @@
-using GLib;
-
-namespace Spry.Cli {
-
-    /**
-     * `spry docker` — generates the multi-stage `Dockerfile` (and
-     * `.dockerignore`) for a spry application and optionally assembles the
-     * build context and runs podman/docker.
-     *
-     * The Web-Stack trees carry fixes not present on their remotes, so the
-     * image build COPYs every stack library from the local Web-Stack
-     * checkout. The context layout the Dockerfile expects is invercargill/,
-     * invercargill-json/, astralis/, invercargill-sql/,
-     * invercargill-sql-inversion/, statum/, spry/ and the application as
-     * app/; inversion, the one dependency outside Web-Stack, is cloned
-     * inside the image pinned to a reviewed commit.
-     */
-    public class Docker : Object {
-
-        /**
-         * The Web-Stack projects COPYed into the build context, in the
-         * dependency order the Dockerfile builds them.
-         */
-        private const string[] STACK_PROJECTS = {
-            "Invercargill", "Invercargill-Json", "Astralis", "Invercargill-Sql",
-            "Invercargill-Sql-Inversion", "Statum", "Spry"
-        };
-
-        /** Writes `Dockerfile` and `.dockerignore` into `app_dir`. */
-        public static void generate(string app_dir, string app_name) throws Error {
-            var vars = new HashTable<string, string>(str_hash, str_equal);
-            vars.insert("APP_NAME", app_name);
-
-            Generator.write_file(Path.build_filename(app_dir, "Dockerfile"),
-                Generator.fill(Templates.DOCKERFILE, vars));
-            Generator.write_file(Path.build_filename(app_dir, ".dockerignore"),
-                Templates.DOCKERIGNORE);
-            stdout.printf("Generated %s\n", Path.build_filename(app_dir, "Dockerfile"));
-            stdout.printf("Generated %s\n", Path.build_filename(app_dir, ".dockerignore"));
-        }
-
-        /**
-         * Assembles the build context under `app_dir/.docker-build/context`
-         * (every Web-Stack project from `stack_dir`, the app itself as
-         * app/) and builds the image with podman or docker, whichever is
-         * found.
-         */
-        public static void build(string app_dir, string app_name, string image_tag, string stack_dir) throws Error {
-            var staging = Path.build_filename(app_dir, ".docker-build");
-            Generator.remove_tree(staging);
-            var context = Path.build_filename(staging, "context");
-            foreach (var project in STACK_PROJECTS) {
-                Generator.copy_tree(find_stack_project(stack_dir, project),
-                    Path.build_filename(context, project.down()));
-            }
-            Generator.copy_tree(app_dir, Path.build_filename(context, "app"));
-            Generator.write_file(Path.build_filename(context, ".dockerignore"),
-                "*/builddir/\n*/.git/\n*.sqlite*\napp/.docker-build/\napp/web-config.json\n");
-
-            var builder = Environment.find_program_in_path("podman")
-                ?? Environment.find_program_in_path("docker");
-            if (builder == null) {
-                throw Generator.cli_error("neither podman nor docker found on PATH");
-            }
-
-            var dockerfile = Path.build_filename(context, "app", "Dockerfile");
-            stdout.printf("Running: %s build -t %s -f %s %s\n", (!)builder, image_tag, dockerfile, context);
-            var launcher = new SubprocessLauncher(SubprocessFlags.NONE);
-            var process = launcher.spawnv({ (!)builder, "build", "-t", image_tag, "-f", dockerfile, context });
-            process.wait_check();
-            stdout.printf("Built %s\n", image_tag);
-        }
-
-        private static string find_stack_project(string stack_dir, string name) throws Error {
-            foreach (var candidate in new string[] { name, name.down() }) {
-                var path = Path.build_filename(stack_dir, candidate);
-                if (FileUtils.test(path, FileTest.IS_DIR) && has_meson_manifest(path)) {
-                    return path;
-                }
-            }
-            throw Generator.cli_error("%s not found in %s — pass --stack-dir <path-to-Web-Stack>", name, stack_dir);
-        }
-
-        /**
-         * True when `path` carries a meson manifest, either at its root or
-         * in `src/` (Invercargill and Invercargill-Json build from src/).
-         */
-        private static bool has_meson_manifest(string path) {
-            return FileUtils.test(Path.build_filename(path, "meson.build"), FileTest.EXISTS)
-                || FileUtils.test(Path.build_filename(path, "src", "meson.build"), FileTest.EXISTS);
-        }
-    }
-}

+ 1 - 1
tools/spry/Generator.vala

@@ -245,7 +245,7 @@ namespace Spry.Cli {
         }
 
         private static bool skipped(string name) {
-            return name == "builddir" || name == ".git" || name == ".docker-build" || name.contains(".sqlite");
+            return name == "builddir" || name == ".git" || name == ".usm-deploy" || name.contains(".sqlite");
         }
 
         /** Builds a CLI error with a printf-style message. */

+ 8 - 6
tools/spry/meson.build

@@ -1,6 +1,6 @@
-# `spry` — the Spry application CLI: scaffolding (`spry new`), add commands,
-# static key maintenance (`spry keys`), Docker image generation, and the
-# build/run/watch dev loop (`spry dev`).
+# `spry` — the Spry application CLI: scaffolding (`spry new`, including USM
+# packaging), add commands, static key maintenance (`spry keys`), USM image
+# deployment (`spry deploy`), and the build/run/watch dev loop (`spry dev`).
 
 python3 = find_program('python3')
 
@@ -24,15 +24,17 @@ spry_templates = custom_target('spry-templates',
         'templates/UserManagementEntrypoint.vala',
         'templates/gitignore',
         'templates/README.md',
-        'templates/Dockerfile',
-        'templates/dockerignore',
+        'templates/MANIFEST.usm',
+        'templates/usm-build.sh',
+        'templates/usm-install.sh',
+        'templates/usmignore',
     ),
     output: 'Templates.vala',
     command: [python3, files('mkconst.py'), '@OUTPUT@', '@INPUT@']
 )
 
 executable('spry',
-    ['spry.vala', 'Generator.vala', 'Keys.vala', 'Docker.vala', 'Dev.vala', spry_templates],
+    ['spry.vala', 'Generator.vala', 'Keys.vala', 'Deploy.vala', 'Dev.vala', spry_templates],
     dependencies: [glib_dep, gobject_dep, gio_dep, json_glib_dep],
     install: true
 )

+ 72 - 29
tools/spry/spry.vala

@@ -5,13 +5,14 @@ namespace Spry.Cli {
     /**
      * `spry` — the Spry application CLI.
      *
-     * Scaffolds Statum + Spry applications (`spry new`), grows them with
-     * app-owned page/action/resource/auth templates (`spry add …`), maintains
-     * the static key material in `web-config.json` (`spry keys`), and
-     * generates/builds the multi-stage Docker image (`spry docker`). Edits to
-     * generated projects happen only within `// spry:*-begin/end` markers in
-     * `src/main.vala` and `# spry:*-begin/end` markers in `meson.build`, so
-     * `add` commands are idempotent and never reformat surrounding user code.
+     * Scaffolds Statum + Spry applications (`spry new`, including their USM
+     * packaging), grows them with app-owned page/action/resource/auth
+     * templates (`spry add …`), maintains the static key material in
+     * `web-config.json` (`spry keys`), and ships them as container images
+     * through USM (`spry deploy`). Edits to generated projects happen only
+     * within `// spry:*-begin/end` markers in `src/main.vala` and
+     * `# spry:*-begin/end` markers in `meson.build`, so `add` commands are
+     * idempotent and never reformat surrounding user code.
      */
     public class SpryCli : Object {
 
@@ -20,9 +21,14 @@ namespace Spry.Cli {
         private static bool force = false;
         private static string? route = null;
         private static string? out_path = null;
-        private static string? tag = null;
-        private static bool no_build = false;
-        private static string? stack_dir = null;
+        private static string? usm = null;
+        private static string? deploy_exec = null;
+        private static string? deploy_base = null;
+        [CCode (array_length = false, null_terminated = true)]
+        private static string[] deploy_repositories = null;
+        private static string? deploy_installer_url = null;
+        private static bool deploy_no_build = false;
+        private static bool deploy_verbose = false;
         private static int dev_port = 8080;
         private static bool dev_fresh = false;
         private static bool dev_no_run = false;
@@ -48,10 +54,14 @@ namespace Spry.Cli {
             { null }
         };
 
-        private const OptionEntry[] docker_options = {
-            { "tag", 't', 0, OptionArg.STRING, ref tag, "Image tag (default: the application name)", "TAG" },
-            { "no-build", '\0', 0, OptionArg.NONE, ref no_build, "Only generate Dockerfile/.dockerignore; do not run the image build", null },
-            { "stack-dir", '\0', 0, OptionArg.FILENAME, ref stack_dir, "Web-Stack checkout to COPY Statum/Spry from (default: the app's sibling directory)", "DIR" },
+        private const OptionEntry[] deploy_options = {
+            { "usm", '\0', 0, OptionArg.FILENAME, ref usm, "usm binary to delegate to (default: search PATH)", "FILE" },
+            { "exec", '\0', 0, OptionArg.STRING, ref deploy_exec, "Container command (default: \"<app> 8080\")", "CMD" },
+            { "base", '\0', 0, OptionArg.STRING, ref deploy_base, "Base image (default: usm's fedora:43)", "IMAGE" },
+            { "repository", '\0', 0, OptionArg.STRING_ARRAY, ref deploy_repositories, "USM repository (.usmr) to resolve from; repeatable (default: the machine's configured repositories)", "FILE" },
+            { "installer-url", '\0', 0, OptionArg.STRING, ref deploy_installer_url, "USM installer source override (file://… for local testing)", "URL" },
+            { "no-build", '\0', 0, OptionArg.NONE, ref deploy_no_build, "Only generate the deploy context; do not build the image", null },
+            { "verbose", 'v', 0, OptionArg.NONE, ref deploy_verbose, "Stream package script output to the terminal during the image build", null },
             { null }
         };
 
@@ -102,8 +112,8 @@ namespace Spry.Cli {
                         return cmd_add(slice(args, 1));
                     case "keys":
                         return cmd_keys(slice(args, 1));
-                    case "docker":
-                        return cmd_docker(slice(args, 1));
+                    case "deploy":
+                        return cmd_deploy(slice(args, 1));
                     case "dev":
                         return cmd_dev(slice(args, 1));
                     default:
@@ -134,7 +144,9 @@ namespace Spry.Cli {
                 "  add register                     Add the registration page\n" +
                 "  add user-management              Add the admin user-management page\n" +
                 "  keys [--out FILE]                Generate/merge static keys into web-config.json\n" +
-                "  docker [--tag T] [--no-build]    Generate (and optionally build) a Dockerfile\n" +
+                "  deploy [--exec CMD] [--base I]   Build the USM container image (usm manifest\n" +
+                "                                   deploy; --repository/--installer-url/\n" +
+                "                                   --no-build pass through)\n" +
                 "  dev [--port N] [--fresh]         Build, run and watch: rebuild + restart on save\n" +
                 "\n" +
                 "Options:\n" +
@@ -185,7 +197,13 @@ namespace Spry.Cli {
             write_app_file(app_dir, "src/entrypoints/HomeEntrypoint.vala", Generator.fill(Templates.HOME_ENTRYPOINT, vars));
             write_app_file(app_dir, ".gitignore", Generator.fill(Templates.GITIGNORE, vars));
             write_app_file(app_dir, "README.md", Generator.fill(Templates.README, vars));
-            Docker.generate(app_dir, name);
+
+            // USM packaging: the manifest + scripts that let `spry deploy`
+            // (usm manifest deploy) build and install the app in-container
+            write_app_file(app_dir, "MANIFEST.usm", Generator.fill(Templates.MANIFEST_USM, vars));
+            write_app_file(app_dir, "usm-scripts/build.sh", Templates.USM_BUILD_SH, 0755);
+            write_app_file(app_dir, "usm-scripts/install.sh", Templates.USM_INSTALL_SH, 0755);
+            write_app_file(app_dir, ".usmignore", Templates.USMIGNORE);
 
             var config_path = Path.build_filename(app_dir, "web-config.json");
             try {
@@ -378,7 +396,7 @@ namespace Spry.Cli {
         }
 
         // ----------------------------------------------------------------------
-        // spry keys / spry docker
+        // spry keys / spry deploy
         // ----------------------------------------------------------------------
 
         private static int cmd_keys(string[] args) throws Error, OptionError {
@@ -387,16 +405,41 @@ namespace Spry.Cli {
             return 0;
         }
 
-        private static int cmd_docker(string[] args) throws Error, OptionError {
-            parse(args, "- generate (and optionally build) the Dockerfile", docker_options);
+        private static int cmd_deploy(string[] args) throws Error, OptionError {
+            string[] repositories;
+            string[] remaining;
+            extract_repositories(args, out repositories, out remaining);
+            parse(remaining, "- build the USM container image via usm manifest deploy", deploy_options);
             var app = require_app();
-            Docker.generate(app.dir, app.name);
-            if (no_build) {
-                return 0;
+            return Deploy.run(app.dir, app.name, usm, deploy_exec, deploy_base,
+                repositories, deploy_installer_url, deploy_no_build, deploy_verbose);
+        }
+
+        /**
+         * Collects repeatable `--repository FILE` / `--repository=FILE`
+         * arguments into {@link repositories}, leaving the rest in
+         * {@link remaining} so the GLib option context (whose string-array
+         * capture is unreliable here) never sees them.
+         */
+        private static void extract_repositories(string[] args, out string[] repositories, out string[] remaining) {
+            var found = new string[0];
+            var rest = new string[args.length];
+            rest[0] = args[0];
+            int out_index = 1;
+            for (int i = 1; i < args.length; i++) {
+                if (args[i] == "--repository" && i + 1 < args.length) {
+                    found += args[++i];
+                }
+                else if (args[i].has_prefix("--repository=")) {
+                    found += args[i].substring("--repository=".length);
+                }
+                else {
+                    rest[out_index++] = args[i];
+                }
             }
-            var stack = stack_dir ?? Path.get_dirname(app.dir);
-            Docker.build(app.dir, app.name, tag ?? app.name, stack);
-            return 0;
+            rest.length = out_index;
+            repositories = found;
+            remaining = rest;
         }
 
         // ----------------------------------------------------------------------
@@ -497,8 +540,8 @@ namespace Spry.Cli {
             Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet));
         }
 
-        private static void write_app_file(string app_dir, string relative, string contents) throws Error {
-            Generator.write_file(Path.build_filename(app_dir, relative), contents);
+        private static void write_app_file(string app_dir, string relative, string contents, uint32 mode = 0644) throws Error {
+            Generator.write_file(Path.build_filename(app_dir, relative), contents, mode);
         }
 
         /**

+ 0 - 150
tools/spry/templates/Dockerfile

@@ -1,150 +0,0 @@
-# Multi-stage Dockerfile for {{APP_NAME}}, generated by `spry docker`.
-#
-# Expected build context layout (assembled by `spry docker --stack-dir`):
-#   invercargill/                ← Web-Stack/Invercargill
-#   invercargill-json/           ← Web-Stack/Invercargill-Json
-#   astralis/                    ← Web-Stack/astralis
-#   invercargill-sql/            ← Web-Stack/Invercargill-Sql
-#   invercargill-sql-inversion/  ← Web-Stack/Invercargill-Sql-Inversion
-#   statum/                      ← Web-Stack/Statum
-#   spry/                        ← Web-Stack/Spry
-#   app/                         ← this application
-# The Web-Stack trees carry fixes not present on their remotes, so every
-# stack library is COPYed from the local checkout instead of cloned. Only
-# inversion is not part of Web-Stack; it is cloned below, pinned to a
-# reviewed commit.
-
-# Base image pinned by digest (docker.io/library/fedora:43 at generation
-# time); refresh with `skopeo inspect docker://docker.io/library/fedora:43`
-# and update both FROM lines.
-FROM docker.io/library/fedora:43@sha256:a651ddf48ea28a06ed4e1e6519f51c9f47e7a5a138722ade87369b8fbb7e5b42 AS builder
-
-# Install build dependencies
-RUN dnf install -y \
-    meson \
-    ninja-build \
-    gcc \
-    vala \
-    glib2-devel \
-    gobject-introspection-devel \
-    json-glib-devel \
-    libxml2-devel \
-    zlib-devel \
-    libzstd-devel \
-    libgee-devel \
-    brotli-devel \
-    libmicrohttpd-devel \
-    libsodium-devel \
-    sqlite-devel \
-    git \
-    pkg-config \
-    && dnf clean all
-
-# --- invercargill (COPYed; meson project lives in src/) ------------------------
-
-COPY invercargill /build/invercargill
-RUN meson setup invercargill/src invercargill/builddir --prefix=/usr && \
-    ninja -C invercargill/builddir && \
-    ninja -C invercargill/builddir install
-
-# --- invercargill-json (COPYed; meson project lives in src/) -------------------
-
-COPY invercargill-json /build/invercargill-json
-RUN meson setup invercargill-json/src invercargill-json/builddir --prefix=/usr && \
-    ninja -C invercargill-json/builddir && \
-    ninja -C invercargill-json/builddir install
-
-# --- inversion (cloned; the one dependency outside Web-Stack) ------------------
-# Pinned to a reviewed commit — bump it in lockstep with the local checkout
-# at ~/Projects/inversion when it changes.
-
-RUN git clone https://fabrica.unitatem.net/Tilo15/inversion.git inversion && \
-    cd inversion && \
-    git checkout fc350827451e2c95af4d8e86d3ab5386e1e1a756 && \
-    meson setup . builddir --prefix=/usr && \
-    ninja -C builddir && \
-    ninja -C builddir install
-
-# --- astralis (COPYed) ----------------------------------------------------------
-
-COPY astralis /build/astralis
-RUN meson setup astralis astralis/builddir --prefix=/usr && \
-    ninja -C astralis/builddir && \
-    ninja -C astralis/builddir install
-
-# --- invercargill-sql (COPYed) --------------------------------------------------
-
-COPY invercargill-sql /build/invercargill-sql
-RUN meson setup invercargill-sql invercargill-sql/builddir --prefix=/usr && \
-    ninja -C invercargill-sql/builddir && \
-    ninja -C invercargill-sql/builddir install
-
-# --- invercargill-sql-inversion (COPYed) ----------------------------------------
-
-COPY invercargill-sql-inversion /build/invercargill-sql-inversion
-RUN meson setup invercargill-sql-inversion invercargill-sql-inversion/builddir --prefix=/usr && \
-    ninja -C invercargill-sql-inversion/builddir && \
-    ninja -C invercargill-sql-inversion/builddir install
-
-# --- statum (COPYed) ------------------------------------------------------------
-
-COPY statum /build/statum
-RUN meson setup statum statum/builddir --prefix=/usr && \
-    ninja -C statum/builddir && \
-    ninja -C statum/builddir install
-
-# --- spry (COPYed) --------------------------------------------------------------
-
-COPY spry /build/spry
-RUN meson setup spry spry/builddir --prefix=/usr && \
-    ninja -C spry/builddir && \
-    ninja -C spry/builddir install
-
-# --- The application ------------------------------------------------------------
-# web-config.json is excluded from the context by .dockerignore — keys are
-# never baked into the image (mount the config at runtime; see README.md).
-
-COPY app /build/app
-RUN meson setup app app/builddir --prefix=/usr && \
-    ninja -C app/builddir && \
-    ninja -C app/builddir install
-
-# --- Runtime ---------------------------------------------------------------------
-
-# Same pinned base as the builder stage.
-FROM docker.io/library/fedora:43@sha256:a651ddf48ea28a06ed4e1e6519f51c9f47e7a5a138722ade87369b8fbb7e5b42 AS runtime
-
-# Install runtime dependencies only
-RUN dnf install -y \
-    glib2 \
-    json-glib \
-    libxml2 \
-    zlib \
-    libzstd \
-    brotli \
-    libmicrohttpd \
-    libgee \
-    libsodium \
-    sqlite \
-    && dnf clean all
-
-# Copy built libraries and the application binary from the builder
-COPY --from=builder /usr/lib64/libinvercargill*.so* /usr/lib64/
-COPY --from=builder /usr/lib64/libinvercargill-json*.so* /usr/lib64/
-COPY --from=builder /usr/lib64/libinversion*.so* /usr/lib64/
-COPY --from=builder /usr/lib64/libastralis*.so* /usr/lib64/
-COPY --from=builder /usr/lib64/libstatum*.so* /usr/lib64/
-COPY --from=builder /usr/lib64/libspry*.so* /usr/lib64/
-COPY --from=builder /usr/lib64/girepository-1.0/ /usr/lib64/girepository-1.0/
-COPY --from=builder /usr/bin/{{APP_NAME}} /usr/bin/
-
-# Persistent state lives outside /app: the SQLite store in a named volume,
-# configuration bind-mounted read-only at runtime (see README.md).
-ENV SPRY_DB_PATH=/var/lib/{{APP_NAME}}/{{APP_NAME}}.sqlite
-VOLUME /var/lib/{{APP_NAME}}
-WORKDIR /app
-RUN ldconfig
-
-# Expose the default port and run the application
-EXPOSE 8080
-CMD ["{{APP_NAME}}", "8080"]

+ 60 - 0
tools/spry/templates/MANIFEST.usm

@@ -0,0 +1,60 @@
+{
+  "name": "{{APP_NAME}}",
+  "version": "0.1",
+  "summary": "A Statum + Spry application",
+  "licences": [],
+  "flags": ["ninjaStyleProgress"],
+  "provides": {
+    "bin:{{APP_NAME}}": "as-expected"
+  },
+  "depends": {
+    "runtime": [
+      "lib:libglib-2.0.so.0",
+      "lib:libgobject-2.0.so.0",
+      "lib:libgio-2.0.so.0",
+      "lib:libjson-glib-1.0.so.0",
+      "lib:libxml2.so.2",
+      "lib:libsodium.so.26",
+      "lib:libsqlite3.so.0",
+      "lib:libmicrohttpd.so.12",
+      "lib:libinvercargill-1.so",
+      "lib:libinvercargill-json.so",
+      "lib:libinvercargill-sql.so",
+      "lib:libinvercargill-sql-inversion.so",
+      "lib:libinversion-0.1.so",
+      "lib:libastralis-0.1.so",
+      "lib:libstatum-0.1.so",
+      "lib:libspry-0.2.so",
+      "lib:libc.so.6"
+    ],
+    "build": [
+      "bin:valac",
+      "bin:meson",
+      "bin:ninja",
+      "bin:python3",
+      "bin:statum-mkpstm",
+      "bin:statum-mkres",
+      "bin:statum-genkeys",
+      "pc:glib-2.0.pc",
+      "pc:gobject-2.0.pc",
+      "pc:gio-2.0.pc",
+      "pc:json-glib-1.0.pc",
+      "pc:libxml-2.0.pc",
+      "pc:invercargill-1.pc",
+      "pc:invercargill-json.pc",
+      "pc:invercargill-sql.pc",
+      "pc:invercargill-sql-inversion.pc",
+      "pc:inversion-0.1.pc",
+      "pc:astralis-0.1.pc",
+      "pc:statum-0.1.pc",
+      "pc:spry-0.2.pc"
+    ],
+    "manage": [
+      "bin:bash"
+    ]
+  },
+  "execs": {
+    "build": "usm-scripts/build.sh",
+    "install": "usm-scripts/install.sh"
+  }
+}

+ 26 - 34
tools/spry/templates/README.md

@@ -57,6 +57,8 @@ export SPRY_SEED_ADMIN_PASSWORD=change-me
 | `src/entrypoints/` | one `StatumEntrypoint` per page |
 | `src/actions/` | `StatumAction` subclasses |
 | `src/resources/` | static resources embedded by `statum-mkres` |
+| `MANIFEST.usm` | USM package manifest: `provides bin:{{APP_NAME}}`, build/runtime/manage deps |
+| `usm-scripts/` | USM build + install scripts (meson `--prefix=/usr` staging) |
 | `web-config.json` | static keys and other configuration (gitignored, 0600) |
 
 Registrations live between `// spry:*-begin/end` markers in `src/main.vala`,
@@ -76,49 +78,39 @@ spry add login
 spry add register
 spry add user-management
 spry keys
-spry docker
+spry deploy
 ```
 
 `add login`, `add register` and `add user-management` also grow the layout
 nav (a Login/Register link when logged out, a Log-out button once login is
 added, a Users link for admins) inside the nav markers.
 
-## Docker
+## Deployment (USM)
 
-`spry docker [--stack-dir <path>] [--tag <tag>]` generates the multi-stage
-`Dockerfile` and `.dockerignore`. The Web-Stack trees carry fixes not on
-their remotes, so the image build COPYs every stack library from the local
-Web-Stack checkout: `spry docker` assembles the context itself (default
-stack dir: the app's sibling directory; override with `--stack-dir`). Only
-inversion is cloned inside the image, pinned to a reviewed commit. The
-context layout the Dockerfile expects is:
+`spry deploy [--exec CMD] [--base IMAGE] [--repository FILE]...
+[--installer-url URL] [--no-build]` packages the application and builds a
+single-stage container image through USM (`usm manifest deploy`): the
+generated `MANIFEST.usm` + `usm-scripts/` drive the in-container build and
+install, the system package manager provides the platform libraries and
+toolchain, and USM repositories provide the Web-Stack. The result is
+`{{APP_NAME}}-<version>.image.tar.xz`, loadable with `podman load -i`.
 
-```
-<context>/invercargill                ← Web-Stack/Invercargill
-<context>/invercargill-json           ← Web-Stack/Invercargill-Json
-<context>/astralis                    ← Web-Stack/astralis
-<context>/invercargill-sql            ← Web-Stack/Invercargill-Sql
-<context>/invercargill-sql-inversion  ← Web-Stack/Invercargill-Sql-Inversion
-<context>/statum                      ← Web-Stack/Statum
-<context>/spry                        ← Web-Stack/Spry
-<context>/app                         ← this application
-```
+By default USM resolves from the machine's configured repositories — point
+it at a specific repository (for example a local Web-Stack repository) with
+repeatable `--repository <file.usmr>`. The image installs USM itself via a
+canonical installer URL baked into usm; until that URL is hosted, pass
+`--installer-url file:///…/install-usm.sh` to carry an installer script
+into the image. `--exec` defaults to `{{APP_NAME}} 8080`.
 
-The base image is pinned by digest; refresh it with
-`skopeo inspect docker://docker.io/library/fedora:43` and update both
-`FROM` lines. The inversion commit is pinned too — bump it in lockstep with
-the local checkout when it changes.
-
-`web-config.json` is excluded from the build context (`.dockerignore`), so
-keys are never baked into the image: mount the config read-only at runtime
-and keep the SQLite store in the declared volume:
+`web-config.json` is never packaged (`.usmignore`), so keys are never
+baked into the image. Serve pages with ephemeral keys by running the image
+directly, or mount the config read-only for full authentication:
 
 ```bash
-podman run -p 8080:8080 \
-    -v $PWD/web-config.json:/app/web-config.json:ro \
-    -v {{APP_NAME}}-data:/var/lib/{{APP_NAME}} \
-    {{APP_NAME}}
+podman load -i {{APP_NAME}}-0.1.image.tar.xz
+podman run -d -p 8080:8080 \
+    -v $PWD/web-config.json:/run/web-config.json:ro \
+    -e ASTRALIS_CONFIG_PATH=/run/web-config.json \
+    -e SPRY_DB_PATH=/var/lib/{{APP_NAME}}/{{APP_NAME}}.sqlite \
+    localhost/{{APP_NAME}}:0.1
 ```
-
-To build by hand, recreate the layout above and run
-`podman build -f Dockerfile -t {{APP_NAME}} <context>`.

+ 0 - 5
tools/spry/templates/dockerignore

@@ -1,5 +0,0 @@
-builddir/
-*.sqlite*
-.docker-build/
-.git/
-web-config.json

+ 10 - 0
tools/spry/templates/usm-build.sh

@@ -0,0 +1,10 @@
+#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+
+cd ${build_dir}
+
+meson setup ${src_dir} --prefix=${PREFIX} --libdir=${LIBDIR} --bindir=${BINDIR} --includedir=${INCLUDEDIR}
+ninja

+ 10 - 0
tools/spry/templates/usm-install.sh

@@ -0,0 +1,10 @@
+#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+install_dir=$2
+
+cd ${build_dir}
+
+meson install --destdir ${install_dir}

+ 12 - 0
tools/spry/templates/usmignore

@@ -0,0 +1,12 @@
+# Build trees at any depth (directory-only: the directory and all beneath it)
+builddir*/
+
+# Database artifacts by basename at any depth
+*.sqlite*
+db.sqlite
+
+# Static keys are never packaged (mount web-config.json at runtime instead)
+web-config.json
+
+# Local agent session state (directory-only)
+.kilo/

+ 10 - 0
usm-scripts/build.sh

@@ -0,0 +1,10 @@
+#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+
+cd ${build_dir}
+
+meson setup ${src_dir} --prefix=${PREFIX} --libdir=${LIBDIR} --bindir=${BINDIR} --includedir=${INCLUDEDIR}
+ninja

+ 10 - 0
usm-scripts/install.sh

@@ -0,0 +1,10 @@
+#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+install_dir=$2
+
+cd ${build_dir}
+
+meson install --destdir ${install_dir}

+ 2 - 0
usm-scripts/post-install.sh

@@ -0,0 +1,2 @@
+#!/bin/bash
+ldconfig