Skip to main content

paygress/
templates.rs

1// Killer templates — the real-workload definitions consumers spawn
2// via `paygress deploy <template>`.
3//
4// Each template is a deliberate intersection of:
5//   - a working public Docker image (no `ubuntu:22.04` placeholders),
6//   - a port profile (host-port mapping the consumer needs to know),
7//   - per-template environment defaults,
8//   - a sensible replication mode (relay = warm-standby; browser =
9//     none; etc.) tied to the workload's recovery semantics,
10//   - a `compose_path` pointing at a checked-in `docker-compose.yml`
11//     so anyone can reproduce the workload locally.
12//
13// The CLI's `deploy` command consumes these definitions; the
14// provider's spawn handler currently spawns the image only (port and
15// env wiring lands when the Durable Workload state machine wires the
16// fourth concurrent loop in `ProviderService::run`).
17
18use std::collections::HashMap;
19
20/// Replication mode at the **template-default** level — "what does
21/// the workload's recovery model look like, before consumer
22/// overrides?". Distinct from `durable_workload::ReplicationMode`
23/// (which carries runtime data like the standby provider list) and
24/// `cli::commands::deploy::ReplicationMode` (the CLI flag enum).
25/// This one is a const-friendly tag for template tables.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ReplicationMode {
28    /// Crash → consumer retries on a fresh provider. Cheapest;
29    /// suitable for stateless workloads.
30    None,
31    /// Periodic state checkpoints (Blossom). Restart from latest
32    /// checkpoint on the same or a fresh provider.
33    Checkpointed,
34    /// Periodic checkpoints PLUS a hot standby on a second
35    /// provider. Single-writer always.
36    WarmStandby,
37}
38
39/// Templates the marketplace knows about. Adding one is a
40/// compatibility-bearing decision: consumers may pin by name.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub enum TemplateName {
43    NostrRelay,
44    InferenceEndpoint,
45    HeadlessBrowser,
46    BitcoinNode,
47    /// Generic compute sandbox for AI agents, CI/test runners, and
48    /// map-reduce / batch shards. Python + Node + git in a writable
49    /// `/workspace` volume; no browser (the `HeadlessBrowser`
50    /// template covers that case). Stateless by default — a crash
51    /// means "retry from scratch", which is what the upstream caller
52    /// already does.
53    AgentSandbox,
54    /// OpenClaw — open-source personal AI assistant Gateway
55    /// (openclaw.ai). Connects to chat apps (WhatsApp/Telegram/
56    /// Discord/Slack/Signal/iMessage) outbound, holds persistent
57    /// memory + tool credentials in `~/.openclaw`, exposes a local
58    /// HTTP control plane on 18789 for the user's companion app.
59    /// Checkpointed because the memory + chat-app credentials are
60    /// personal and should survive a provider restart.
61    OpenClaw,
62    /// ngit CI/CD runner — one-shot container that clones a repo
63    /// (ngit-based or plain git), checks out a commit, parses
64    /// `.ngit/ci.yml`, and runs each step. Result is reported back
65    /// via stdout/exit code today; the follow-up event-publishing
66    /// step (Nostr kind 38401, ngit-ci-status) lands once the
67    /// bridge daemon and event schema are agreed upon.
68    ///
69    /// Stateless and replication=None — CI runs are naturally
70    /// idempotent at the bridge level (re-spawn on a fresh provider
71    /// is the recovery model). Warm-standby would burn money for
72    /// no recovery benefit.
73    NgitRunner,
74}
75
76impl TemplateName {
77    /// Wire-format slug used by `paygress deploy <slug>` and the
78    /// `templates/<slug>/` directory.
79    pub fn slug(self) -> &'static str {
80        match self {
81            Self::NostrRelay => "nostr-relay",
82            Self::InferenceEndpoint => "inference-endpoint",
83            Self::HeadlessBrowser => "headless-browser",
84            Self::BitcoinNode => "bitcoin-node",
85            Self::AgentSandbox => "agent-sandbox",
86            Self::OpenClaw => "openclaw",
87            Self::NgitRunner => "ngit-runner",
88        }
89    }
90
91    pub fn from_slug(s: &str) -> Option<Self> {
92        match s {
93            "nostr-relay" => Some(Self::NostrRelay),
94            "inference-endpoint" => Some(Self::InferenceEndpoint),
95            "headless-browser" => Some(Self::HeadlessBrowser),
96            "bitcoin-node" => Some(Self::BitcoinNode),
97            "agent-sandbox" => Some(Self::AgentSandbox),
98            "openclaw" => Some(Self::OpenClaw),
99            "ngit-runner" => Some(Self::NgitRunner),
100            _ => None,
101        }
102    }
103
104    pub fn all() -> [Self; 7] {
105        [
106            Self::NostrRelay,
107            Self::InferenceEndpoint,
108            Self::HeadlessBrowser,
109            Self::BitcoinNode,
110            Self::AgentSandbox,
111            Self::OpenClaw,
112            Self::NgitRunner,
113        ]
114    }
115}
116
117/// One port the consumer needs to reach to use the workload.
118#[derive(Debug, Clone)]
119pub struct Port {
120    /// Container-internal port.
121    pub container_port: u16,
122    /// Wire-protocol hint for tooling / docs (`tcp`, `http`, `ws`,
123    /// `https`, `bitcoin-rpc`, etc.).
124    pub protocol: &'static str,
125    /// Human label (`relay-ws`, `inference-http`, `bitcoind-rpc`).
126    pub label: &'static str,
127}
128
129/// Full definition of a template. The consumer-visible defaults
130/// (tier, replication) and the operator-visible facts (image,
131/// ports, env, compose_path).
132#[derive(Debug, Clone)]
133pub struct TemplateDefinition {
134    pub name: TemplateName,
135    pub summary: &'static str,
136
137    // ---- operator-side facts ----
138    /// Real, public Docker image. No `ubuntu:22.04` placeholders.
139    pub image: &'static str,
140    pub ports: Vec<Port>,
141    /// Environment variables the workload expects. Values are
142    /// defaults; consumers can override per-deploy.
143    pub env: HashMap<&'static str, &'static str>,
144    /// Path (relative to the repo root) to a working
145    /// `docker-compose.yml`. Operators run
146    /// `docker compose -f <compose_path> up` to reproduce the
147    /// workload locally with no Paygress involved.
148    pub compose_path: &'static str,
149
150    /// Extra `docker run` flags this template needs (ulimits,
151    /// sysctls, capabilities, etc.). Passed verbatim before the
152    /// image positional. Keep these minimal and well-justified —
153    /// every flag here is a cross-template attack surface.
154    /// Example: `&["--ulimit", "nofile=1048576:1048576"]` for
155    /// strfry, which tries to bump NOFILES to 1M and fails inside
156    /// Docker's default 524288 cap.
157    pub extra_docker_args: &'static [&'static str],
158
159    /// Container-internal path that holds the workload's
160    /// persistent state (LMDB for strfry, models for ollama,
161    /// chain data for bitcoind). DockerBackend mounts a
162    /// vmid-scoped volume here. None means stateless (browser).
163    pub data_path: Option<&'static str>,
164
165    // ---- consumer-side defaults ----
166    pub tier: &'static str,
167    pub replication: ReplicationMode,
168
169    /// Minimum sane resources. Provisioning rejects tiers below this.
170    pub min_cpu_millicores: u64,
171    pub min_memory_mb: u64,
172    pub min_storage_gb: u64,
173}
174
175impl TemplateDefinition {
176    pub fn lookup(name: TemplateName) -> Self {
177        match name {
178            TemplateName::NostrRelay => nostr_relay(),
179            TemplateName::InferenceEndpoint => inference_endpoint(),
180            TemplateName::HeadlessBrowser => headless_browser(),
181            TemplateName::BitcoinNode => bitcoin_node(),
182            TemplateName::AgentSandbox => agent_sandbox(),
183            TemplateName::OpenClaw => openclaw(),
184            TemplateName::NgitRunner => ngit_runner(),
185        }
186    }
187
188    pub fn all() -> Vec<Self> {
189        TemplateName::all()
190            .iter()
191            .map(|n| Self::lookup(*n))
192            .collect()
193    }
194}
195
196/// Default policy for `--encrypt-volume` on the spawn CLI: should
197/// the consumer's data volume be LUKS-encrypted at rest *unless
198/// they explicitly pass `--no-encrypt-volume`*?
199///
200/// Rule: yes for every template that holds persistent state
201/// (`data_path: Some(_)`). Stateless templates have nothing to
202/// encrypt and the default is a no-op for them.
203///
204/// Why this rule and not "Checkpointed only": every persistent-state
205/// template leaks the *same* class of data to a curious operator —
206/// strfry's LMDB has relay subscribers' message graph, ollama's
207/// model dir carries any RAG context, openclaw's config dir holds
208/// chat-app credentials, bitcoind's chaindata carries the wallet
209/// pubkeys. The replication mode is a recovery-model knob, not a
210/// confidentiality knob; encryption is justified in all of them.
211/// Modest LUKS overhead beats a confused consumer-vs-template-
212/// author trust split.
213///
214/// Pure function over the template name — testable without
215/// touching the filesystem or the network.
216pub fn template_default_encrypts_volume(name: TemplateName) -> bool {
217    TemplateDefinition::lookup(name).data_path.is_some()
218}
219
220fn nostr_relay() -> TemplateDefinition {
221    let mut env = HashMap::new();
222    env.insert("STRFRY_DB_PATH", "/app/strfry-db");
223    env.insert("RELAY_NAME", "paygress-relay");
224    TemplateDefinition {
225        name: TemplateName::NostrRelay,
226        summary: "Censorship-resistant Nostr relay (strfry). Freedom-tech anchor; warm-standby across two providers because relay outage = censorship surface for the users who depend on it.",
227        image: "dockurr/strfry:latest",
228        ports: vec![Port {
229            container_port: 7777,
230            protocol: "ws",
231            label: "relay-ws",
232        }],
233        env,
234        compose_path: "templates/nostr-relay/docker-compose.yml",
235        // strfry's startup tries to bump nofile rlimit to 1M; without
236        // this flag the container immediately exits with "Unable to
237        // set NOFILES limit to 1000000, exceeds max of 524288".
238        extra_docker_args: &["--ulimit", "nofile=1048576:1048576"],
239        data_path: Some("/app/strfry-db"),
240        tier: "basic",
241        replication: ReplicationMode::WarmStandby,
242        min_cpu_millicores: 500,
243        min_memory_mb: 512,
244        min_storage_gb: 5,
245    }
246}
247
248fn inference_endpoint() -> TemplateDefinition {
249    let mut env = HashMap::new();
250    env.insert("OLLAMA_HOST", "0.0.0.0:11434");
251    env.insert("OLLAMA_MODELS", "/root/.ollama/models");
252    TemplateDefinition {
253        name: TemplateName::InferenceEndpoint,
254        summary: "OpenAI-compatible inference endpoint (Ollama). Agent-economy anchor; checkpointed (resumable model state) but no warm standby — costs scale linearly with replication and most agents accept retry on a fresh provider.",
255        image: "ollama/ollama:latest",
256        ports: vec![Port {
257            container_port: 11434,
258            protocol: "http",
259            label: "ollama-http",
260        }],
261        env,
262        compose_path: "templates/inference-endpoint/docker-compose.yml",
263        extra_docker_args: &[],
264        data_path: Some("/root/.ollama"),
265        tier: "standard",
266        replication: ReplicationMode::Checkpointed,
267        min_cpu_millicores: 2000,
268        min_memory_mb: 4096,
269        min_storage_gb: 20,
270    }
271}
272
273fn headless_browser() -> TemplateDefinition {
274    let mut env = HashMap::new();
275    env.insert("CONNECTION_TIMEOUT", "300000");
276    env.insert("MAX_CONCURRENT_SESSIONS", "10");
277    TemplateDefinition {
278        name: TemplateName::HeadlessBrowser,
279        summary: "Disposable headless Chrome (browserless). Agent-driven scraping. Stateless by design, so replication is `none` by default — a crash means \"retry from scratch\", which is what callers already do.",
280        image: "ghcr.io/browserless/chromium:latest",
281        ports: vec![
282            Port {
283                container_port: 3000,
284                protocol: "http",
285                label: "browserless-http",
286            },
287            Port {
288                container_port: 9222,
289                protocol: "http",
290                label: "cdp",
291            },
292        ],
293        env,
294        compose_path: "templates/headless-browser/docker-compose.yml",
295        extra_docker_args: &[],
296        data_path: None,
297        tier: "basic",
298        replication: ReplicationMode::None,
299        min_cpu_millicores: 1000,
300        min_memory_mb: 1024,
301        min_storage_gb: 5,
302    }
303}
304
305fn bitcoin_node() -> TemplateDefinition {
306    let mut env = HashMap::new();
307    env.insert("BITCOIN_NETWORK", "regtest");
308    env.insert("BITCOIN_RPC_USER", "paygress");
309    TemplateDefinition {
310        name: TemplateName::BitcoinNode,
311        summary: "Bitcoin full node (bitcoind). Long sync, large state — checkpointed so a provider crash doesn't restart the chain download. Defaults to regtest for fast smoke testing; mainnet via env override.",
312        image: "btcpayserver/bitcoin:28.1",
313        ports: vec![
314            Port {
315                container_port: 8332,
316                protocol: "bitcoin-rpc",
317                label: "rpc",
318            },
319            Port {
320                container_port: 8333,
321                protocol: "tcp",
322                label: "p2p",
323            },
324        ],
325        env,
326        compose_path: "templates/bitcoin-node/docker-compose.yml",
327        extra_docker_args: &[],
328        data_path: Some("/data"),
329        tier: "standard",
330        replication: ReplicationMode::Checkpointed,
331        min_cpu_millicores: 1000,
332        min_memory_mb: 2048,
333        min_storage_gb: 50,
334    }
335}
336
337fn agent_sandbox() -> TemplateDefinition {
338    let mut env = HashMap::new();
339    env.insert("WORKSPACE", "/workspace");
340    env.insert("PYTHONUNBUFFERED", "1");
341    env.insert("NODE_ENV", "production");
342    // EXEC_USER and EXEC_PASS are the auth credentials for the
343    // baked-in HTTP exec server (images/agent-sandbox/server.py).
344    // Provider's spawn handler overrides these with the consumer's
345    // ssh_username / ssh_password at container-start time so the
346    // caller can use ONE set of creds for both SSH (legacy) and the
347    // exec endpoint. Default values here are placeholders — the
348    // server returns 503 until they're set to non-empty values.
349    env.insert("EXEC_USER", "");
350    env.insert("EXEC_PASS", "");
351    TemplateDefinition {
352        name: TemplateName::AgentSandbox,
353        summary: "Generic compute sandbox: Python 3.12 + Node 20 + git in a writable /workspace volume. Bundled HTTP exec server on port 8080 lets agents run shell commands directly via the `paygress-cli exec` / MCP `run_command` path — no SSH needed. Stateless by default — retry-on-fresh-provider is the recovery model. Browser-using agents should compose with the `headless-browser` template.",
354        // Custom paygress image: nikolaik/python-nodejs +
355        // /usr/local/bin/paygress-exec (the baked-in HTTP server).
356        // Built and published by .github/workflows/agent-sandbox-image.yml
357        // on tags `agent-sandbox-v*`. Pinned to 0.1.0 so a registry-
358        // side rebuild can't silently change spawn behavior.
359        image: "ghcr.io/dhananjaypurohit/paygress-agent-sandbox:0.1.0",
360        ports: vec![Port {
361            // The exec server listens here. AccessDetails surfaces
362            // the host_port mapping so the caller can hit
363            // http://<host>:<host_port>/exec with HTTP Basic auth
364            // using the spawn-time ssh_user / ssh_pass.
365            container_port: 8080,
366            protocol: "http",
367            label: "sandbox-exec",
368        }],
369        env,
370        compose_path: "templates/agent-sandbox/docker-compose.yml",
371        extra_docker_args: &[],
372        // /workspace is the agent's writable scratch. Persistent
373        // across restarts on the same provider so a long-running
374        // agent task that gets restarted (e.g. backend restart)
375        // doesn't lose its checkout.
376        data_path: Some("/workspace"),
377        tier: "basic",
378        replication: ReplicationMode::None,
379        // Sized for a typical CI step / agent run, not a heavyweight
380        // ML job. Operators can offer larger tiers separately.
381        min_cpu_millicores: 500,
382        min_memory_mb: 1024,
383        min_storage_gb: 5,
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn slug_round_trip() {
393        for t in TemplateName::all() {
394            assert_eq!(TemplateName::from_slug(t.slug()), Some(t));
395        }
396    }
397
398    #[test]
399    fn unknown_slug_is_none() {
400        assert!(TemplateName::from_slug("not-a-template").is_none());
401    }
402
403    #[test]
404    fn every_template_has_an_image_and_ports() {
405        for def in TemplateDefinition::all() {
406            assert!(
407                !def.image.contains("ubuntu:22.04"),
408                "{:?} still on placeholder image",
409                def.name
410            );
411            assert!(!def.image.is_empty(), "{:?} has empty image", def.name);
412            assert!(
413                !def.ports.is_empty(),
414                "{:?} has no ports — workload would be unreachable",
415                def.name
416            );
417        }
418    }
419
420    #[test]
421    fn min_resources_are_nonzero() {
422        for def in TemplateDefinition::all() {
423            assert!(def.min_cpu_millicores > 0);
424            assert!(def.min_memory_mb > 0);
425            assert!(def.min_storage_gb > 0);
426        }
427    }
428
429    #[test]
430    fn compose_paths_match_slug() {
431        for def in TemplateDefinition::all() {
432            let expected = format!("templates/{}/docker-compose.yml", def.name.slug());
433            assert_eq!(def.compose_path, expected);
434        }
435    }
436
437    #[test]
438    fn replication_defaults_match_workload_semantics() {
439        // Pin the design choices: relay needs availability, browser
440        // is throwaway, others checkpoint. Changing these is a
441        // compatibility-bearing decision.
442        assert_eq!(
443            TemplateDefinition::lookup(TemplateName::NostrRelay).replication,
444            ReplicationMode::WarmStandby
445        );
446        assert_eq!(
447            TemplateDefinition::lookup(TemplateName::HeadlessBrowser).replication,
448            ReplicationMode::None
449        );
450        assert_eq!(
451            TemplateDefinition::lookup(TemplateName::InferenceEndpoint).replication,
452            ReplicationMode::Checkpointed
453        );
454        assert_eq!(
455            TemplateDefinition::lookup(TemplateName::BitcoinNode).replication,
456            ReplicationMode::Checkpointed
457        );
458        // Agent sandbox: same recovery model as headless-browser
459        // (retry-from-scratch on a fresh provider) — most CI / agent
460        // runs are short-lived and naturally idempotent at the harness
461        // level, so paying for warm-standby would be pure waste.
462        assert_eq!(
463            TemplateDefinition::lookup(TemplateName::AgentSandbox).replication,
464            ReplicationMode::None
465        );
466        // ngit-runner: a CI run is naturally idempotent at the
467        // bridge level (re-spawn on a fresh provider is the recovery
468        // model). Warm-standby would burn money for no benefit on a
469        // one-shot workload.
470        assert_eq!(
471            TemplateDefinition::lookup(TemplateName::NgitRunner).replication,
472            ReplicationMode::None
473        );
474    }
475
476    #[test]
477    fn agent_sandbox_has_workspace_data_path() {
478        // The /workspace volume is the contract for callers that
479        // want to leave artifacts for retrieval over SSH (e.g.
480        // map-reduce shards writing partial results). If this
481        // changes, the docker-compose.yml and the user-facing docs
482        // need to follow.
483        let def = TemplateDefinition::lookup(TemplateName::AgentSandbox);
484        assert_eq!(def.data_path, Some("/workspace"));
485        assert_eq!(def.env.get("WORKSPACE"), Some(&"/workspace"));
486    }
487
488    #[test]
489    fn ngit_runner_is_stateless_and_requires_repo_and_commit() {
490        // CI runs are one-shot — no persistence between runs (failed
491        // pipeline retries on a fresh provider). Empty defaults for
492        // NGIT_REPO / NGIT_COMMIT mean "the entrypoint refuses to
493        // start unless the consumer set them" rather than running
494        // against an unintended target.
495        let def = TemplateDefinition::lookup(TemplateName::NgitRunner);
496        assert_eq!(def.data_path, None, "CI runner must be stateless");
497        assert_eq!(def.env.get("NGIT_REPO"), Some(&""));
498        assert_eq!(def.env.get("NGIT_COMMIT"), Some(&""));
499        assert_eq!(def.env.get("NGIT_PIPELINE_PATH"), Some(&".ngit/ci.yml"));
500    }
501}
502
503fn openclaw() -> TemplateDefinition {
504    let mut env = HashMap::new();
505    // Gateway HTTP control plane bind. The companion app + the
506    // installed-skill webhooks need to hit this; consumers reach it
507    // via the host-port mapping surfaced in AccessDetails.
508    env.insert("OPENCLAW_GATEWAY_PORT", "18789");
509    env.insert("OPENCLAW_GATEWAY_HOST", "0.0.0.0");
510    // Persist config + memory + sessions + per-skill credentials
511    // here so a checkpoint round-trip preserves them. Matches the
512    // upstream default at ~/.openclaw.
513    env.insert("OPENCLAW_CONFIG_DIR", "/data/.openclaw");
514    TemplateDefinition {
515        name: TemplateName::OpenClaw,
516        summary: "OpenClaw — open-source personal AI assistant Gateway (openclaw.ai). Connects outbound to chat apps (WhatsApp/Telegram/Discord/Slack/Signal/iMessage), keeps persistent memory + tool credentials in /data/.openclaw, exposes the Gateway control plane on 18789. Checkpointed because the memory + credentials are personal and should survive provider restarts.",
517        // TODO(openclaw-image): swap to a paygress-pinned image
518        // (`ghcr.io/dhananjaypurohit/paygress-openclaw:<ver>`) once
519        // we publish one — same pattern as agent-sandbox. Today's
520        // best public image is the upstream openclaw repo's own
521        // GHCR build; if upstream stops publishing, deploys break
522        // until the paygress-pinned image lands.
523        image: "ghcr.io/openclaw/openclaw:latest",
524        ports: vec![Port {
525            container_port: 18789,
526            protocol: "http",
527            label: "openclaw-gateway",
528        }],
529        env,
530        compose_path: "templates/openclaw/docker-compose.yml",
531        extra_docker_args: &[],
532        // ~/.openclaw inside the container — config, memory store,
533        // sessions, and per-skill OAuth tokens. The agent's whole
534        // identity lives here; lose it and the user reauthenticates
535        // every chat-app integration.
536        data_path: Some("/data/.openclaw"),
537        tier: "standard",
538        // Personal assistant state is irreplaceable from the
539        // consumer's POV — checkpointed gives them a Blossom-stored
540        // restore point on provider crash. Warm-standby is overkill
541        // (the assistant doesn't need sub-minute recovery).
542        replication: ReplicationMode::Checkpointed,
543        // Node 24 + memory store + concurrent chat-app integrations:
544        // 1 vCPU is fine at idle, 2 GB lets the JS heap breathe under
545        // bursty webhook activity (Telegram + Discord + Slack at once).
546        min_cpu_millicores: 1000,
547        min_memory_mb: 2048,
548        min_storage_gb: 5,
549    }
550}
551
552fn ngit_runner() -> TemplateDefinition {
553    let mut env = HashMap::new();
554    // Required per-spawn (consumer overrides via spawn env): the repo
555    // to clone and the commit / ref to check out. Empty defaults
556    // mean "the runner refuses to start and prints a clear error"
557    // rather than running against an unintended target.
558    env.insert("NGIT_REPO", "");
559    env.insert("NGIT_COMMIT", "");
560    // Pipeline file path inside the repo. `.ngit/ci.yml` mirrors the
561    // `.github/workflows/`-style convention so a repo author can
562    // grep for it. Override per-spawn if your repo uses a different
563    // path (e.g. monorepos with multiple pipelines).
564    env.insert("NGIT_PIPELINE_PATH", ".ngit/ci.yml");
565    // Status HTTP server bind. Live log streaming + a final
566    // /status JSON document so the bridge daemon (or a human via
567    // ssh tunnel) can poll while the pipeline runs. The provider
568    // surfaces the host-port mapping via AccessDetails just like
569    // every other HTTP-serving template.
570    env.insert("NGIT_STATUS_PORT", "8080");
571    TemplateDefinition {
572        name: TemplateName::NgitRunner,
573        summary: "ngit CI/CD runner — one-shot pipeline executor for Nostr-based git repos. Clones the repo at the requested commit, parses .ngit/ci.yml, runs each step. Result reporting today is exit code + /status HTTP; the follow-up step ships the kind-38401 Nostr-event publish once the ngit-ci bridge daemon and event schema are agreed upon.",
574        // TODO(ngit-runner-image): publish a paygress-pinned image
575        // (`ghcr.io/dhananjaypurohit/paygress-ngit-runner:<ver>`)
576        // built from `images/ngit-runner/`. Until that image exists,
577        // deploys of this template will fail at docker pull — the
578        // template config is staged ahead of the image so the CLI
579        // surface, schema, and tests can land first.
580        image: "ghcr.io/dhananjaypurohit/paygress-ngit-runner:0.1.0",
581        ports: vec![Port {
582            container_port: 8080,
583            protocol: "http",
584            label: "ngit-runner-status",
585        }],
586        env,
587        compose_path: "templates/ngit-runner/docker-compose.yml",
588        extra_docker_args: &[],
589        // CI runs are one-shot — no persistence between runs. A
590        // failed pipeline retries on a fresh provider with a clean
591        // workspace, which is what the upstream bridge already
592        // assumes (the whole spawn-per-run model is the recovery
593        // story). Stateless ⇒ encryption defaults off ⇒ no LUKS
594        // overhead on the hot path.
595        data_path: None,
596        tier: "basic",
597        // Bridge respawns on a fresh provider per CI run; warm-standby
598        // would burn money for no recovery benefit on a one-shot
599        // workload.
600        replication: ReplicationMode::None,
601        // Sized for a typical compile + test cycle in a small repo
602        // (Node/Python/Go usually fit). Heavyweight builds (large
603        // Rust crates, Docker-in-Docker) should use a higher tier
604        // — operators are free to offer larger SKUs.
605        min_cpu_millicores: 1000,
606        min_memory_mb: 2048,
607        min_storage_gb: 10,
608    }
609}
610
611#[cfg(test)]
612mod default_policy_tests {
613    use super::*;
614
615    #[test]
616    fn templates_with_persistent_state_default_to_encrypted() {
617        // Anything with a `data_path` should get encrypted by default.
618        // Stateful templates today: nostr-relay, inference-endpoint,
619        // bitcoin-node, agent-sandbox, openclaw.
620        for name in TemplateName::all() {
621            let def = TemplateDefinition::lookup(name);
622            let expected = def.data_path.is_some();
623            assert_eq!(
624                template_default_encrypts_volume(name),
625                expected,
626                "template {:?} default-encrypt mismatch (data_path={:?})",
627                name,
628                def.data_path,
629            );
630        }
631    }
632
633    #[test]
634    fn nostr_relay_encrypts_by_default() {
635        // strfry's LMDB carries subscribers' message graph; encrypting
636        // it at rest is justified even though replication is
637        // warm-standby (recovery mode is orthogonal to confidentiality).
638        assert!(template_default_encrypts_volume(TemplateName::NostrRelay));
639    }
640
641    #[test]
642    fn headless_browser_does_not_encrypt_by_default() {
643        // Stateless template — nothing to encrypt. The default is a
644        // no-op for it.
645        assert!(!template_default_encrypts_volume(
646            TemplateName::HeadlessBrowser
647        ));
648    }
649
650    #[test]
651    fn openclaw_encrypts_by_default() {
652        // OpenClaw's /data/.openclaw holds chat-app OAuth tokens —
653        // arguably the load-bearing reason this default exists.
654        assert!(template_default_encrypts_volume(TemplateName::OpenClaw));
655    }
656}