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}