diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0aedc2be..457ab04b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -138,6 +138,9 @@ jobs: run: cargo nextest run --partition count:${{ matrix.shard }}/${{ matrix.shards }} -E 'not binary(bdd)' # The `bdd` test uses a custom harness (harness = false), which nextest # cannot run, so execute it once on shard 1 with the standard test runner. + - name: Prepare config files for BDD tests + if: ${{ matrix.shard == 1 }} + run: cp access_control.conf.dist access_control.conf - name: Run BDD suite if: ${{ matrix.shard == 1 }} run: cargo test --test bdd diff --git a/.gitignore b/.gitignore index ad2a7530..d3392e26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ target .idea/ files -access_control.conf configuration.yaml configuration.yaml.backup configuration.yaml.orig @@ -17,3 +16,5 @@ plan/ .stacker done.txt post-deploy-ran.txt + +bake/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e262d563..9976d870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,102 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.3.2] — 2026-08-26 + +### Added — Container-health monitoring & alerts (`stacker monitor`) + +- New `stacker monitor` command: watches the deployment's live container health + (via the Status Panel agent) and raises an alert when a container is not + running, then again when everything recovers. Edge-triggered — it notifies + once per transition, not on every poll. +- `stacker monitor --once` runs a single check and exits (cron-friendly); + without it, the command loops on an interval. `--interval ` overrides + the configured poll interval. +- New `monitoring.alerts` block in `stacker.yml` configures the alarm: + + ```yaml + monitoring: + alerts: + interval: 60 # poll interval, seconds (default 60) + on_recovery: true # also notify when containers recover (default true) + target: + terminal: true # terminal + desktop notification + # url: "https://ntfy.example.com/alerts" # or: HTTP webhook + # method: POST + # pipe: oncall-notify # or: run a declared pipe (deferred) + ``` + +- Alert state is persisted to `.stacker/monitor.state`, so `--once` invocations + stay edge-triggered across cron runs. +- The alarm logic lives in a new, dependency-free `health-monitor` crate (pure + health evaluation + transition detection), reusable by the CLI and, later, the + agent. + +### Added — Declarative pipes (Infrastructure-as-Code) + +- New `pipes:` block in `stacker.yml` declares pipes as committable config + (name, source/target, endpoints, fields, trigger, retry, handlers) — the + same surface as the `pipe create` flags, now version-controllable. +- `stacker pipe diff` compares the declared `pipes:` against what is deployed + and prints a plan: `create` (declared, not deployed), `update` (differs, with + the exact field changes), `unchanged`, and `orphan` (deployed, not declared). + `--json` for scripting; a clean tree reports "In sync". +- `stacker pipe apply` reconciles the declaration into the deployment: creates + declared-but-missing pipes (template + instance) and, with `--prune`, deletes + deployed pipes not in `stacker.yml`. `--dry-run` shows the plan without acting; + re-running is idempotent. + +### Added — Manual pipe endpoints & non-interactive `pipe create` + +- `stacker pipe create` now accepts explicit endpoints, bypassing endpoint + discovery entirely: `--source-endpoint "METHOD /path"`, + `--target-endpoint "METHOD /path"`, `--source-fields`, `--target-fields`, and + `--name`. This lets any app (or arbitrary HTTP endpoint) be piped — including + apps whose APIs aren't at auto-discoverable paths — and makes pipe creation + fully scriptable. + +### Added — Pipe resilience: retry policy & lifecycle handlers + +- `stacker pipe create` gained `--retry `, `--retry-backoff-ms `, + `--retry-backoff-max-ms `, `--on-failure `, and + `--on-success `. These are persisted in the pipe's typed config + (retry policy + handler references) so the runtime can honor them. + +### Added — Reverse-proxy routing (traefik, caddy, nginx-proxy-manager) + +- `proxy.type` now produces working routing for all supported proxies: + Traefik via container labels, Caddy via a generated `Caddyfile`, and + Nginx Proxy Manager via auto-created proxy hosts — all driven by + `proxy.domains` (`{domain, upstream, ssl}`), which is now forwarded end-to-end + to the Install Service. +- Proxies are **platform-managed**: the synthesized proxy service is stripped + from the compose shipped to the server (deployed by its own role instead), so + a `proxy:` block no longer double-deploys the proxy or collides on ports + 80/443/81. +- For `--target local`, the CLI now renders the proxy config file itself + (`.stacker/Caddyfile`) so the bind mount is a real file locally. + +### Fixed — Proxy config validation & port-conflict detection + +- New `W003` warning: a `proxy:` block plus a service publishing the same + ingress host port (80/443/81) is flagged as a likely conflict. +- Fixed `W001` host-port comparison to correctly handle the + `ip:host:container` binding form (previously produced false positives and + false negatives on loopback bindings). + +### Fixed — SSH backup key saved during provisioning + +- The local emergency SSH backup keypair is now saved the moment the server + first appears during a cloud deploy's watch loop, instead of only after the + watch completes. Interrupting the watch (timeout / Ctrl-C / network) no longer + loses SSH access to a successfully-deployed server. + +### Fixed — Remote compose cleanup + +- Stripping a platform-managed proxy from the remote compose now also prunes the + named volumes only that proxy used (e.g. `caddy_data`/`caddy_config`), instead + of leaving orphaned top-level volume declarations. + ### Added — Deployment command aliases and `--pinned` flag - Added `stacker deployment status` as a visible alias for `stacker deployment state`. diff --git a/Cargo.lock b/Cargo.lock index 5bf2e829..e25620f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3172,6 +3172,14 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "health-monitor" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "heck" version = "0.5.0" @@ -7005,7 +7013,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.3.1" +version = "0.3.2" dependencies = [ "actix", "actix-casbin-auth", @@ -7036,6 +7044,7 @@ dependencies = [ "futures-lite 2.6.1", "futures-util", "glob", + "health-monitor", "hmac 0.12.1", "indexmap 2.14.0", "indicatif", diff --git a/Cargo.toml b/Cargo.toml index f05c6afe..38ff4db2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "stacker" -version = "0.3.1" +version = "0.3.2" edition = "2021" default-run= "server" [workspace] -members = ["crates/pipe-adapter-sdk", "crates/pipe-adapter-mail", "crates/td-audit"] +members = ["crates/pipe-adapter-sdk", "crates/pipe-adapter-mail", "crates/td-audit", "crates/health-monitor"] resolver = "2" [lib] @@ -39,6 +39,7 @@ config = "0.13.4" reqwest = { version = "0.11.23", features = ["json", "blocking", "stream", "native-tls"] } serde = { version = "1.0.195", features = ["derive"] } td-audit = { path = "crates/td-audit" } +health-monitor = { path = "crates/health-monitor" } tokio = { version = "1.28.1", features = ["full"] } tracing = { version = "0.1.40", features = ["log"] } tracing-bunyan-formatter = "0.3.8" diff --git a/README.md b/README.md index 7c15b3d1..adaf9397 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,10 @@ monitoring: healthcheck: endpoint: /health interval: 30s + alerts: # container-down alarm for `stacker monitor` (0.3.2) + interval: 30 + target: + terminal: true # or: { url: "https://ntfy.example.com/alerts" } ``` Full schema reference: [docs/STACKER_YML_REFERENCE.md](docs/STACKER_YML_REFERENCE.md) @@ -239,7 +243,9 @@ The end-user tool. No server required for local deploys. | `stacker pipe scan` | Discover local endpoints/resources from running containers (when target is `local`) | | `stacker pipe scan --containers [filter]` | Discover local endpoints/resources for matching containers | | `stacker pipe scan --app ` | Probe a remote app for API endpoints | -| `stacker pipe create ` | Create a data pipe between two containers (interactive) | +| `stacker pipe create ` | Create a data pipe between two containers (interactive; or non-interactive with `--source-endpoint`/`--target-endpoint`/`--name` — added in 0.3.2). `--retry`/`--on-failure`/`--on-success` attach a retry policy + lifecycle handlers | +| `stacker pipe diff` | Compare the declared `pipes:` block against deployed pipes (added in 0.3.2) | +| `stacker pipe apply` | Reconcile declared pipes into the deployment; `--prune` removes orphans, `--dry-run` previews (added in 0.3.2) | | `stacker pipe list` | List pipe instances for the current deployment | | `stacker pipe activate ` | Activate a pipe (start listening for triggers) | | `stacker pipe deactivate ` | Pause an active pipe | @@ -247,6 +253,7 @@ The end-user tool. No server required for local deploys. | `stacker pipe deploy ` | Promote a local pipe to a remote deployment | | `stacker pipe history ` | View execution history for a pipe | | `stacker pipe replay ` | Re-run a previous pipe execution | +| `stacker monitor` | Watch container health and alert on problems (added in 0.3.2); `--once` for a single check (cron-friendly). Configure via `monitoring.alerts` in `stacker.yml` | | `stacker target [local\|cloud\|server]` | Switch deployment target mode | | `stacker env [local\|dev\|prod]` | Show or persist the active deploy environment/profile used by app-only updates | | `stacker whoami` | Show the active login, subscription plan, and current project deployment context | @@ -376,7 +383,9 @@ curl -sL https://marketplace.try.direct//install.sh | sh - **Agent control** — `stacker agent` subcommand to manage remote Status Panel agents (health, logs, restart, deploy, proxy) with `--json` output - **SSH key management** — generate, view, upload, and repair server SSH keys (Vault-backed), with automatic local backup SSH access after cloud deploy -- **Reverse proxy** — auto-detects Nginx / Nginx Proxy Manager, configures domains + SSL +- **Reverse proxy** — Traefik (labels), Caddy (Caddyfile), and Nginx Proxy Manager, platform-managed and driven by `proxy.domains` end-to-end (0.3.2) +- **Container-health alarm** — `stacker monitor` watches container health and alerts on problems (terminal, webhook, or pipe), configured via `monitoring.alerts` (0.3.2) +- **Declarative pipes (IaC)** — declare pipes in `stacker.yml` and reconcile with `stacker pipe diff` / `pipe apply [--prune]` (0.3.2) - **Cloud deployment** — Hetzner, DigitalOcean, AWS, Linode, with provider firewall operations and paused/failed install IP retention - **MCP Server** — 85+ tools, including deployment, agent control, config, proxy, firewall, and remote service secret management - **Marketplace** — submit stacks for review, auto-publish on approval, check status from CLI @@ -520,6 +529,7 @@ cargo test user_service_client # User Service connector cargo test marketplace_webhook # Marketplace webhook flows cargo test deployment_validator # Deployment validation cargo test --test security_cli # CLI endpoint IDOR security tests +SQLX_OFFLINE=true cargo test --lib -- proxy_domains 2>&1 | tail -10 ``` --- diff --git a/access_control.conf b/access_control.conf new file mode 100644 index 00000000..f164af1a --- /dev/null +++ b/access_control.conf @@ -0,0 +1,14 @@ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && r.act == p.act diff --git a/crates/health-monitor/Cargo.toml b/crates/health-monitor/Cargo.toml new file mode 100644 index 00000000..00b70e41 --- /dev/null +++ b/crates/health-monitor/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "health-monitor" +version = "0.1.0" +edition = "2021" +description = "Pure container-health alarm engine for TryDirect Stacker: evaluate container health snapshots, edge-detect down/recovery transitions, and format alerts. No I/O, network, or scheduler — the CLI/agent drive it." + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[features] +default = [] diff --git a/crates/health-monitor/src/lib.rs b/crates/health-monitor/src/lib.rs new file mode 100644 index 00000000..94541226 --- /dev/null +++ b/crates/health-monitor/src/lib.rs @@ -0,0 +1,239 @@ +//! # health-monitor +//! +//! Pure container-health **alarm engine** for TryDirect Stacker. It answers two +//! questions from a snapshot of container states: +//! +//! 1. Is the deployment healthy right now? (any container not `running` → down) +//! 2. Did we just **transition** down or recover, relative to the last snapshot? +//! (edge-triggering, so a watcher notifies once per change — not every poll) +//! +//! It has **no I/O, no network, no scheduler**: the caller (the `stacker monitor` +//! CLI loop, or a future agent) fetches container health, feeds it in, and acts +//! on the returned [`Transition`]. State is a tiny serializable value the caller +//! persists between polls (e.g. `.stacker/monitor.state`) so edge-triggering +//! survives across one-shot `--once` invocations (cron-friendly). +//! +//! Input parsing is tolerant: [`parse_container_health`] reads the array shape +//! that `stacker agent health --json` emits (`[{name, status, ...}]`). + +use serde::{Deserialize, Serialize}; + +/// A single container's health, reduced to what the alarm needs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContainerHealth { + pub name: String, + /// Docker/agent status string, e.g. "running", "restarting", "exited". + pub status: String, +} + +impl ContainerHealth { + /// A container is "up" only when it reports exactly `running`. Anything else + /// (restarting, exited, paused, dead, created, …) counts as a problem. + pub fn is_up(&self) -> bool { + self.status.eq_ignore_ascii_case("running") + } +} + +/// Overall health of the set of containers at one point in time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Health { + /// Every container is running. + Up, + /// At least one container is not running. + Down, +} + +/// Persisted watcher state between polls. Serialize to `.stacker/monitor.state`. +/// `Unknown` is the correct initial value: the first poll establishes a baseline +/// and only *changes* alert thereafter (a fresh watcher against an already-down +/// stack fires immediately, which is what you want). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum WatchState { + #[default] + Unknown, + Up, + Down, +} + +impl From for WatchState { + fn from(h: Health) -> Self { + match h { + Health::Up => WatchState::Up, + Health::Down => WatchState::Down, + } + } +} + +/// What changed between the previous [`WatchState`] and the current [`Health`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Transition { + /// No actionable change (still up, or still down). + None, + /// Crossed into a problem state — fire the alarm. + WentDown { offenders: Vec }, + /// Recovered to all-running — fire the (optional) recovery notice. + Recovered, +} + +/// Evaluate overall health from a snapshot. +pub fn evaluate(containers: &[ContainerHealth]) -> Health { + if containers.iter().all(ContainerHealth::is_up) { + Health::Up + } else { + Health::Down + } +} + +/// The containers that are currently not running (for the alert message). +pub fn offenders(containers: &[ContainerHealth]) -> Vec { + containers.iter().filter(|c| !c.is_up()).cloned().collect() +} + +/// Edge-detect the transition from `prev` to the current snapshot. This is the +/// heart of the alarm: it returns [`Transition::WentDown`] / [`Transition::Recovered`] +/// only on a *change*, so the caller notifies once per event. +/// +/// - `Unknown → Down` and `Up → Down` → `WentDown` +/// - `Unknown → Up` → `None` (baseline established silently) +/// - `Down → Up` → `Recovered` +/// - same-state → `None` +pub fn detect_transition(prev: WatchState, containers: &[ContainerHealth]) -> Transition { + let now = evaluate(containers); + match (prev, now) { + (WatchState::Down, Health::Up) => Transition::Recovered, + (WatchState::Up | WatchState::Unknown, Health::Down) => Transition::WentDown { + offenders: offenders(containers), + }, + // Unknown→Up (baseline), Up→Up, Down→Down: nothing to report. + _ => Transition::None, + } +} + +/// A ready-to-send alert message for a [`Transition`], or `None` when there's +/// nothing to send (or a recovery when recovery notices are disabled). +pub fn alert_message(transition: &Transition, notify_on_recovery: bool) -> Option { + match transition { + Transition::None => None, + Transition::WentDown { offenders } => { + let names: Vec<&str> = offenders.iter().map(|c| c.name.as_str()).collect(); + Some(format!( + "⚠️ container problem: {} not running ({})", + names.len(), + if names.is_empty() { + "unknown".to_string() + } else { + names.join(", ") + } + )) + } + Transition::Recovered if notify_on_recovery => { + Some("✅ all containers recovered".to_string()) + } + Transition::Recovered => None, + } +} + +/// Parse the JSON that `stacker agent health --json` emits: a top-level array of +/// objects with at least `name` and `status`. Unknown fields are ignored; +/// entries missing `name`/`status` are skipped rather than failing the whole +/// parse (a robust watcher shouldn't die on one odd row). +pub fn parse_container_health(json: &serde_json::Value) -> Vec { + let Some(arr) = json.as_array() else { + return Vec::new(); + }; + arr.iter() + .filter_map(|item| { + let name = item.get("name")?.as_str()?.to_string(); + let status = item.get("status")?.as_str()?.to_string(); + Some(ContainerHealth { name, status }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn c(name: &str, status: &str) -> ContainerHealth { + ContainerHealth { + name: name.into(), + status: status.into(), + } + } + + #[test] + fn evaluate_is_up_only_when_all_running() { + assert_eq!(evaluate(&[c("a", "running"), c("b", "running")]), Health::Up); + assert_eq!(evaluate(&[c("a", "running"), c("b", "restarting")]), Health::Down); + assert_eq!(evaluate(&[]), Health::Up); // vacuously up + // status match is case-insensitive + assert_eq!(evaluate(&[c("a", "RUNNING")]), Health::Up); + } + + #[test] + fn offenders_lists_only_non_running() { + let snap = [c("a", "running"), c("b", "exited"), c("c", "restarting")]; + let names: Vec<_> = offenders(&snap).into_iter().map(|c| c.name).collect(); + assert_eq!(names, vec!["b", "c"]); + } + + #[test] + fn transition_edges_are_correct() { + let up = [c("a", "running")]; + let down = [c("a", "restarting")]; + + // baseline: Unknown→Up is silent; Unknown→Down fires + assert_eq!(detect_transition(WatchState::Unknown, &up), Transition::None); + assert!(matches!( + detect_transition(WatchState::Unknown, &down), + Transition::WentDown { .. } + )); + // Up→Down fires, Down→Up recovers + assert!(matches!( + detect_transition(WatchState::Up, &down), + Transition::WentDown { .. } + )); + assert_eq!(detect_transition(WatchState::Down, &up), Transition::Recovered); + // steady states are silent + assert_eq!(detect_transition(WatchState::Up, &up), Transition::None); + assert_eq!(detect_transition(WatchState::Down, &down), Transition::None); + } + + #[test] + fn alert_message_formats_and_respects_recovery_flag() { + let down = Transition::WentDown { + offenders: vec![c("project-app-1", "restarting")], + }; + let msg = alert_message(&down, true).unwrap(); + assert!(msg.contains("project-app-1") && msg.contains("container problem")); + + assert_eq!(alert_message(&Transition::Recovered, true).as_deref(), Some("✅ all containers recovered")); + assert_eq!(alert_message(&Transition::Recovered, false), None); + assert_eq!(alert_message(&Transition::None, true), None); + } + + #[test] + fn parse_reads_agent_health_shape_and_skips_bad_rows() { + let payload = json!([ + { "name": "project-app-1", "status": "running", "cpu_pct": 0.2 }, + { "name": "project-ntfy-1", "status": "restarting" }, + { "status": "running" }, // no name → skipped + { "name": "x" } // no status → skipped + ]); + let parsed = parse_container_health(&payload); + assert_eq!(parsed.len(), 2); + assert_eq!(evaluate(&parsed), Health::Down); + assert_eq!(offenders(&parsed)[0].name, "project-ntfy-1"); + } + + #[test] + fn watch_state_round_trips_and_defaults_unknown() { + assert_eq!(WatchState::default(), WatchState::Unknown); + let s = serde_json::to_string(&WatchState::Down).unwrap(); + assert_eq!(s, "\"down\""); + assert_eq!(WatchState::from(Health::Up), WatchState::Up); + } +} diff --git a/docs/PIPING.md b/docs/PIPING.md index 3a735b61..aa5447b5 100644 --- a/docs/PIPING.md +++ b/docs/PIPING.md @@ -89,6 +89,64 @@ The wizard: 5. Asks for a pipe name 6. Creates a template + instance +#### Manual endpoints (non-interactive) — *added in 0.3.2* + +Skip discovery entirely by naming both endpoints. This works for any app — +including ones whose APIs aren't at auto-discoverable paths — and makes pipe +creation fully scriptable: + +```bash +stacker pipe create app ntfy \ + --source-endpoint "GET /status" \ + --target-endpoint "POST /pipetest" \ + --source-fields message \ + --target-fields message \ + --name apprise-to-ntfy +``` + +- Endpoints are `"METHOD /path"` (a bare `/path` defaults to `GET`). +- Fields map by name, then by position, then identity; an empty + `--target-fields` passes the whole payload through. +- `--name` skips the interactive name prompt. + +#### Retry policy & lifecycle handlers — *added in 0.3.2* + +Attach a retry policy and success/failure handlers at creation time; they are +persisted in the pipe's config so the runtime can honor them: + +```bash +stacker pipe create app ntfy \ + --source-endpoint "GET /status" --target-endpoint "POST /pipetest" \ + --name apprise-to-ntfy \ + --retry 5 --retry-backoff-ms 500 --retry-backoff-max-ms 30000 \ + --on-failure oncall-notify --on-success audit-log +``` + +### Declarative pipes (Infrastructure-as-Code) — *added in 0.3.2* + +Instead of imperative `pipe create` commands, declare pipes in `stacker.yml` +and reconcile them. See the [`pipes:` reference](./STACKER_YML_REFERENCE.md#pipes). + +```yaml +pipes: + - name: apprise-to-ntfy + source: app + target: ntfy + source_endpoint: "GET /status" + target_endpoint: "POST /pipetest" + source_fields: [message] + target_fields: [message] + retry: 5 + on_failure: oncall-notify +``` + +```bash +stacker pipe diff # preview: create / update / unchanged / orphan +stacker pipe apply # create declared-but-missing pipes (idempotent) +stacker pipe apply --prune # also delete deployed pipes not in stacker.yml +stacker pipe apply --dry-run # show the plan without applying +``` + ### 3. Activate the pipe ```bash diff --git a/docs/STACKER_YML_REFERENCE.md b/docs/STACKER_YML_REFERENCE.md index 8bd17cf6..42bd184f 100644 --- a/docs/STACKER_YML_REFERENCE.md +++ b/docs/STACKER_YML_REFERENCE.md @@ -26,7 +26,8 @@ - [config_contract — Service Config Contracts](#config_contract) - [ai — AI Assistant](#ai) - [monitoring — Health & Metrics](#monitoring) - - [status_panel](#monitoringstatus_panel) · [healthcheck](#monitoringhealthcheck) · [metrics](#monitoringmetrics) + - [status_panel](#monitoringstatus_panel) · [healthcheck](#monitoringhealthcheck) · [metrics](#monitoringmetrics) · [alerts](#monitoringalerts) +- [pipes — Declarative Pipes (IaC)](#pipes) - [hooks — Lifecycle Scripts](#hooks) - [env / env_file — Environment Variables](#env--env_file) - [Environment Variable Interpolation](#environment-variable-interpolation) @@ -980,6 +981,103 @@ monitoring: telegraf: true ``` +### `monitoring.alerts` + +*Optional* · `object` · Default: none · *Added in 0.3.2* + +Container-down alarm for the `stacker monitor` command. When configured, +`stacker monitor` polls the deployment's live container health and fires an +alert on transitions: once when a container stops running, and once when +everything recovers (edge-triggered — no repeat spam while a container stays +down). + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `interval` | `int` | `60` | Poll interval in seconds (overridable with `--interval`) | +| `on_recovery` | `bool` | `true` | Also notify when containers recover to healthy | +| `target` | `object` | — (required) | Where to deliver the alert (see below) | + +**`target`** is one of: + +| Shape | Delivery | +|-------|----------| +| `{ terminal: true }` | Terminal + desktop notification (OS notification, terminal bell, stderr) | +| `{ url: "", method: POST }` | HTTP webhook (ntfy, Slack, …); `method` defaults to `POST` | +| `{ pipe: }` | Run a declared pipe (deferred — see notes) | + +```yaml +monitoring: + status_panel: true + alerts: + interval: 30 + on_recovery: true + target: + terminal: true + # or a webhook: + # url: "https://ntfy.example.com/alerts" + # method: POST + # or a pipe: + # pipe: oncall-notify +``` + +Run it with `stacker monitor` (loops every `interval`) or `stacker monitor --once` +(single check — ideal for cron). Alert state is persisted to +`.stacker/monitor.state`, so one-shot `--once` runs stay edge-triggered. + +> The `pipe:` target is accepted but not yet dispatched (a follow-up); use a +> `terminal` or `url` target today. + +--- + +## `pipes` + +*Optional* · `array` · Default: none · *Added in 0.3.2* + +Declaratively-defined data pipes, reconciled with the deployment by +`stacker pipe diff` (preview) and `stacker pipe apply` (create / `--prune`). +Each entry mirrors the `stacker pipe create` flags, so pipes become +committable, reviewable, and reproducible. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `name` | `string` | — (required) | Unique pipe name (identity for reconcile) | +| `source` | `string` | — (required) | Source app code (container/service) | +| `target` | `string` | — (required) | Target app code | +| `source_endpoint` | `string` | — (required) | `"METHOD /path"` on the source | +| `target_endpoint` | `string` | — (required) | `"METHOD /path"` on the target | +| `source_fields` | `[string]` | `[]` | Source field names for mapping | +| `target_fields` | `[string]` | `[]` | Target field names for mapping | +| `trigger` | `string` | `webhook` | `manual` \| `webhook` \| `poll` | +| `poll_interval` | `int` | — | Poll interval (seconds) when `trigger: poll` | +| `retry` | `int` | — | Max delivery retries | +| `retry_backoff_ms` | `int` | — | Base retry backoff (ms) | +| `retry_backoff_max_ms` | `int` | — | Max retry backoff (ms) | +| `on_failure` | `string` | — | Pipe (by name) to run when delivery fails | +| `on_success` | `string` | — | Pipe (by name) to run after success | + +```yaml +pipes: + - name: apprise-to-ntfy + source: app + target: ntfy + source_endpoint: "GET /status" + target_endpoint: "POST /pipetest" + source_fields: [message] + target_fields: [message] + trigger: manual + retry: 5 + on_failure: oncall-notify +``` + +Workflow: + +```bash +stacker pipe diff # preview: create / update / unchanged / orphan +stacker pipe apply # create declared-but-missing pipes (idempotent) +stacker pipe apply --prune # also delete deployed pipes not in stacker.yml +stacker pipe apply --dry-run # show the plan without applying +``` + --- ## `hooks` @@ -1352,6 +1450,7 @@ Stacker validates your configuration both syntactically (YAML structure) and sem |------|------|-------| | `W001` | Port conflict — multiple services bind the same host port | `services.ports` | | `W002` | Named volume referenced in `volumes` but not mounted by any service | `volumes` | +| `W003` | A `proxy:` block plus a service publishing the same ingress host port (80/443/81) — likely conflict *(added in 0.3.2)* | `proxy` / `services.ports` | ### Example output @@ -1403,6 +1502,10 @@ Configuration issues: | `stacker agent configure-firewall` | Configure guest OS firewall rules via the Status Panel agent | | `stacker agent history` | Show recent agent command execution history | | `stacker agent exec` | Execute a raw agent command with JSON parameters | +| `stacker monitor` | Watch container health and alert on problems *(added in 0.3.2)*; `--once` for a single check, `--interval ` to override the poll interval. Requires `monitoring.alerts` in `stacker.yml`. | +| `stacker pipe create` | Create a pipe. *(0.3.2)* Add `--source-endpoint`/`--target-endpoint`/`--source-fields`/`--target-fields`/`--name` for manual, non-interactive creation, and `--retry`/`--retry-backoff-ms`/`--retry-backoff-max-ms`/`--on-failure`/`--on-success` for a retry policy + lifecycle handlers | +| `stacker pipe diff` | *(0.3.2)* Compare the declared `pipes:` block against deployed pipes (create/update/unchanged/orphan); `--json` | +| `stacker pipe apply` | *(0.3.2)* Reconcile declared pipes into the deployment — creates missing pipes; `--prune` deletes orphans, `--dry-run` previews | | `stacker update` | Check for CLI updates | ### `stacker init` flags diff --git a/migrations/20260816120000_deployment_daily_billing.down.sql b/migrations/20260816120000_deployment_daily_billing.down.sql new file mode 100644 index 00000000..7e656411 --- /dev/null +++ b/migrations/20260816120000_deployment_daily_billing.down.sql @@ -0,0 +1,19 @@ +-- Revert deployment daily billing changes + +DROP INDEX IF EXISTS idx_authorization_daily_sweep; +DROP INDEX IF EXISTS idx_authorization_billing_cycle; + +DROP TABLE IF EXISTS server_type_daily_rate; + +ALTER TABLE marketplace_install_authorization + DROP COLUMN IF EXISTS daily_rate, + DROP COLUMN IF EXISTS monthly_cap, + DROP COLUMN IF EXISTS total_charged_minor, + DROP COLUMN IF EXISTS last_daily_charge_at, + DROP COLUMN IF EXISTS server_deleted_at, + DROP COLUMN IF EXISTS suspended_at, + DROP COLUMN IF EXISTS billing_cycle; + +ALTER TABLE stack_template + DROP COLUMN IF EXISTS daily_rate, + DROP COLUMN IF EXISTS monthly_cap; diff --git a/migrations/20260816120000_deployment_daily_billing.up.sql b/migrations/20260816120000_deployment_daily_billing.up.sql new file mode 100644 index 00000000..c81b8c0c --- /dev/null +++ b/migrations/20260816120000_deployment_daily_billing.up.sql @@ -0,0 +1,46 @@ +-- Deployment daily billing model +-- Adds daily_rate/monthly_cap to stack_template and extends +-- marketplace_install_authorization with daily billing tracking. + +-- stack_template: daily billing fields +ALTER TABLE stack_template + ADD COLUMN IF NOT EXISTS daily_rate DECIMAL(10,2) DEFAULT NULL, + ADD COLUMN IF NOT EXISTS monthly_cap DECIMAL(10,2) DEFAULT NULL; + +-- marketplace_install_authorization: daily billing tracking +ALTER TABLE marketplace_install_authorization + ADD COLUMN IF NOT EXISTS daily_rate DECIMAL(10,2) DEFAULT 0, + ADD COLUMN IF NOT EXISTS monthly_cap DECIMAL(10,2) DEFAULT 0, + ADD COLUMN IF NOT EXISTS total_charged_minor BIGINT DEFAULT 0, + ADD COLUMN IF NOT EXISTS last_daily_charge_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS server_deleted_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS suspended_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS billing_cycle VARCHAR(50) DEFAULT 'per_install'; + +-- Platform default daily rates per server type +CREATE TABLE IF NOT EXISTS server_type_daily_rate ( + server_type VARCHAR(50) PRIMARY KEY, + daily_rate DECIMAL(10,2) NOT NULL, + monthly_cap DECIMAL(10,2) NOT NULL, + hetzner_monthly_eur DECIMAL(10,2), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Seed default pricing (Hetzner EUR × 1.1 exchange × 1.5 margin / 30 days) +INSERT INTO server_type_daily_rate (server_type, daily_rate, monthly_cap, hetzner_monthly_eur) +VALUES + ('cpx11', 0.27, 8.00, 4.85), + ('cpx22', 0.47, 14.00, 8.55), + ('cpx32', 0.86, 26.00, 15.59), + ('cpx42', 1.67, 50.00, 30.39), + ('cpx52', 3.30, 99.00, 59.99) +ON CONFLICT (server_type) DO NOTHING; + +-- Index for sweeper queries +CREATE INDEX IF NOT EXISTS idx_authorization_billing_cycle + ON marketplace_install_authorization (billing_cycle, status); + +CREATE INDEX IF NOT EXISTS idx_authorization_daily_sweep + ON marketplace_install_authorization (billing_cycle, status, last_daily_charge_at) + WHERE billing_cycle = 'deployment_daily' AND status = 'captured'; diff --git a/migrations/20260819135945_fix_daily_rate_float8.down.sql b/migrations/20260819135945_fix_daily_rate_float8.down.sql new file mode 100644 index 00000000..c2f46402 --- /dev/null +++ b/migrations/20260819135945_fix_daily_rate_float8.down.sql @@ -0,0 +1,14 @@ +-- Revert daily_rate/monthly_cap columns back to NUMERIC +-- (should only be run if the code is also reverted to bind Decimal) +ALTER TABLE stack_template + ALTER COLUMN daily_rate TYPE DECIMAL(10,2) USING daily_rate::numeric(10,2), + ALTER COLUMN monthly_cap TYPE DECIMAL(10,2) USING monthly_cap::numeric(10,2); + +ALTER TABLE marketplace_install_authorization + ALTER COLUMN daily_rate TYPE DECIMAL(10,2) USING daily_rate::numeric(10,2), + ALTER COLUMN monthly_cap TYPE DECIMAL(10,2) USING monthly_cap::numeric(10,2); + +ALTER TABLE server_type_daily_rate + ALTER COLUMN daily_rate TYPE DECIMAL(10,2) USING daily_rate::numeric(10,2), + ALTER COLUMN monthly_cap TYPE DECIMAL(10,2) USING monthly_cap::numeric(10,2), + ALTER COLUMN hetzner_monthly_eur TYPE DECIMAL(10,2) USING hetzner_monthly_eur::numeric(10,2); \ No newline at end of file diff --git a/migrations/20260819135945_fix_daily_rate_float8.up.sql b/migrations/20260819135945_fix_daily_rate_float8.up.sql new file mode 100644 index 00000000..4fe28b78 --- /dev/null +++ b/migrations/20260819135945_fix_daily_rate_float8.up.sql @@ -0,0 +1,18 @@ +-- Fix daily_rate/monthly_cap columns to FLOAT8 (DOUBLE PRECISION) +-- Rust code binds/decodes these as f64 (sqlx FLOAT8); the original migration +-- created them as DECIMAL(10,2) (NUMERIC), causing: +-- "mismatched types; Rust type Option (as SQL type FLOAT8) +-- is not compatible with SQL type NUMERIC" +-- Applies to the three tables that carry daily billing values. +ALTER TABLE stack_template + ALTER COLUMN daily_rate TYPE DOUBLE PRECISION USING daily_rate::double precision, + ALTER COLUMN monthly_cap TYPE DOUBLE PRECISION USING monthly_cap::double precision; + +ALTER TABLE marketplace_install_authorization + ALTER COLUMN daily_rate TYPE DOUBLE PRECISION USING daily_rate::double precision, + ALTER COLUMN monthly_cap TYPE DOUBLE PRECISION USING monthly_cap::double precision; + +ALTER TABLE server_type_daily_rate + ALTER COLUMN daily_rate TYPE DOUBLE PRECISION USING daily_rate::double precision, + ALTER COLUMN monthly_cap TYPE DOUBLE PRECISION USING monthly_cap::double precision, + ALTER COLUMN hetzner_monthly_eur TYPE DOUBLE PRECISION USING hetzner_monthly_eur::double precision; \ No newline at end of file diff --git a/src/banner.rs b/src/banner.rs index bbd5c301..bf56260c 100644 --- a/src/banner.rs +++ b/src/banner.rs @@ -3,6 +3,8 @@ pub fn print_banner() { let version = env!("CARGO_PKG_VERSION"); let name = env!("CARGO_PKG_NAME"); + let git_hash = option_env!("STACKER_GIT_SHORT_HASH").unwrap_or("unknown"); + let banner = format!( r#" _ | | @@ -13,15 +15,14 @@ pub fn print_banner() { ────────────────────────────────────────── {} - Version: {} - Build: {} + Version: {} (git: {}) Edition: {} ───────────────────────────────────────── "#, capitalize(name), version, - env!("CARGO_PKG_VERSION"), + git_hash, "2021" ); diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index f1f39c0d..a81f5bf6 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -160,7 +160,7 @@ enum StackerCommands { #[arg(long)] lock: bool, /// Skip server pre-check; force fresh cloud provision even if deploy.server exists - #[arg(long)] + #[arg(long, conflicts_with = "force_rebuild")] force_new: bool, /// Container runtime: "runc" (default) or "kata" for hardware-isolated containers #[arg(long, value_name = "RUNTIME", default_value = "runc")] @@ -305,6 +305,18 @@ enum StackerCommands { #[command(subcommand)] command: DeploymentCommands, }, + /// Watch container health and alert on problems (config: monitoring.alerts) + Monitor { + /// Run a single check and exit (cron-friendly); otherwise loops + #[arg(long)] + once: bool, + /// Override the poll interval in seconds (default from monitoring.alerts) + #[arg(long)] + interval: Option, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, /// Explain path and topology decisions Explain { #[command(subcommand)] @@ -863,6 +875,12 @@ enum ConfigCommands { Validate { #[arg(long, value_name = "FILE")] file: Option, + /// Deploy target to validate against (local, cloud, server). Skips + /// env-var resolution for the inactive deploy.server/deploy.cloud + /// section in dual-target configs. Defaults to deploy.target in + /// the file when omitted. + #[arg(long)] + target: Option, }, /// Show resolved configuration Show { @@ -1274,6 +1292,63 @@ enum PipeCommands { /// Use ML-based field matching (n-gram cosine similarity) #[arg(long, conflicts_with_all = ["ai", "no_ai"])] ml: bool, + /// Manual source endpoint "METHOD /path" (e.g. "GET /items"); bypasses + /// endpoint discovery. Requires --target-endpoint. + #[arg(long, requires = "target_endpoint")] + source_endpoint: Option, + /// Manual target endpoint "METHOD /path" (e.g. "POST /pipetest"); + /// bypasses endpoint discovery. Requires --source-endpoint. + #[arg(long, requires = "source_endpoint")] + target_endpoint: Option, + /// Comma-separated source field names (manual mode) + #[arg(long, value_delimiter = ',')] + source_fields: Vec, + /// Comma-separated target field names (manual mode) + #[arg(long, value_delimiter = ',')] + target_fields: Vec, + /// Pipe name (skips the interactive name prompt) + #[arg(long)] + name: Option, + /// Max delivery retries before the pipe is marked failed (default 3) + #[arg(long)] + retry: Option, + /// Base backoff between retries, milliseconds (default 1000) + #[arg(long)] + retry_backoff_ms: Option, + /// Max backoff cap between retries, milliseconds (default 30000) + #[arg(long)] + retry_backoff_max_ms: Option, + /// Run another pipe (by name) when delivery fails after retries + #[arg(long)] + on_failure: Option, + /// Run another pipe (by name) after a successful delivery + #[arg(long)] + on_success: Option, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Compare declaratively-defined pipes (`pipes:` in stacker.yml) against + /// what's deployed. Read-only; run `pipe apply` to reconcile. + Diff { + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Reconcile the declared `pipes:` into the deployment (creates missing pipes). + Apply { + /// Signal intent to remove deployed pipes not in stacker.yml + #[arg(long)] + prune: bool, + /// Show what would change without creating anything + #[arg(long)] + dry_run: bool, /// Output in JSON format #[arg(long)] json: bool, @@ -1810,7 +1885,54 @@ fn resolved_config_environment( Ok(config.selected_environment(None)) } -fn main() -> Result<(), Box> { +/// `println!`/`print!` panic on write failure, including EPIPE when a +/// downstream reader (e.g. `stacker agent logs | head`, or a command that +/// errors out early like `stacker ai ask`) closes stdout before we're done +/// writing. Unix tools conventionally exit quietly when that happens rather +/// than dumping a panic backtrace, so swallow just that failure mode here. +fn install_broken_pipe_panic_hook() { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let is_broken_pipe = info + .payload() + .downcast_ref::() + .map(|s| s.contains("Broken pipe")) + .or_else(|| { + info.payload() + .downcast_ref::<&str>() + .map(|s| s.contains("Broken pipe")) + }) + .unwrap_or(false); + if is_broken_pipe { + std::process::exit(0); + } + default_hook(info); + })); +} + +/// Thin wrapper around `run()` so a closed downstream pipe (e.g. +/// `stacker | head`, or a piped command that exits early) is treated +/// as a normal, quiet exit rather than an error — matching how `?`-propagated +/// `io::Error`s would otherwise surface as a raw `Error: Os { code: 32, .. }` +/// Debug dump from the default `Result`-returning `main` termination path. +fn main() -> std::process::ExitCode { + install_broken_pipe_panic_hook(); + match run() { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(err) => { + let is_broken_pipe = err + .downcast_ref::() + .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::BrokenPipe); + if is_broken_pipe { + return std::process::ExitCode::SUCCESS; + } + eprintln!("Error: {:?}", err); + std::process::ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), Box> { let cli = match Cli::try_parse() { Ok(cli) => cli, Err(err) => { @@ -2013,6 +2135,13 @@ fn get_command( } => Box::new(stacker::console::commands::cli::status::StatusCommand::new( json, watch, notify, )), + StackerCommands::Monitor { + once, + interval, + deployment, + } => Box::new(stacker::console::commands::cli::monitor::MonitorCommand::new( + once, interval, deployment, + )), StackerCommands::Deployment { command } => match command { DeploymentCommands::State { json, @@ -2059,9 +2188,9 @@ fn get_command( stacker::console::commands::cli::rollback::RollbackCommand::new(version, confirm), ), StackerCommands::Config { command: cfg_cmd } => match cfg_cmd { - ConfigCommands::Validate { file } => { - Box::new(stacker::console::commands::cli::config::ConfigValidateCommand::new(file)) - } + ConfigCommands::Validate { file, target } => Box::new( + stacker::console::commands::cli::config::ConfigValidateCommand::new(file, target), + ), ConfigCommands::Show { file, resolved } => Box::new( stacker::console::commands::cli::config::ConfigShowCommand::new(file, resolved), ), @@ -2504,11 +2633,47 @@ fn get_command( ai, no_ai, ml, + source_endpoint, + target_endpoint, + source_fields, + target_fields, + name, + retry, + retry_backoff_ms, + retry_backoff_max_ms, + on_failure, + on_success, json, deployment, } => Box::new(pipe::PipeCreateCommand::new( - source, target, manual, ai, no_ai, ml, json, deployment, + source, + target, + manual, + ai, + no_ai, + ml, + source_endpoint, + target_endpoint, + source_fields, + target_fields, + name, + retry, + retry_backoff_ms, + retry_backoff_max_ms, + on_failure, + on_success, + json, + deployment, )), + PipeCommands::Diff { json, deployment } => { + Box::new(pipe::PipeDiffCommand::new(json, deployment)) + } + PipeCommands::Apply { + prune, + dry_run, + json, + deployment, + } => Box::new(pipe::PipeApplyCommand::new(prune, dry_run, json, deployment)), PipeCommands::List { json, deployment } => { Box::new(pipe::PipeListCommand::new(json, deployment)) } diff --git a/src/cli/compose_service_sync.rs b/src/cli/compose_service_sync.rs index 4c3be0ad..e9c09874 100644 --- a/src/cli/compose_service_sync.rs +++ b/src/cli/compose_service_sync.rs @@ -114,7 +114,7 @@ fn inject_external_network( changed } -fn upsert_external_network(compose_doc: &mut serde_yaml::Value, network: &str) { +pub(crate) fn upsert_external_network(compose_doc: &mut serde_yaml::Value, network: &str) { let Some(root) = compose_doc.as_mapping_mut() else { return; }; diff --git a/src/cli/config_bundle.rs b/src/cli/config_bundle.rs index d0d96d09..3f0fdf6e 100644 --- a/src/cli/config_bundle.rs +++ b/src/cli/config_bundle.rs @@ -103,6 +103,26 @@ pub fn build_config_bundle( let compose_content = std::fs::read_to_string(&compose_canonical)?; let mut compose_yaml: serde_yaml::Value = serde_yaml::from_str(&compose_content)?; + + // Drop platform-managed services (e.g. the nginx-proxy-manager ingress) + // from the compose that ships to the remote host. Platform-managed + // services are deployed by their own install-service Ansible role into + // their own directory (`/home/trydirect//`), NOT inside the + // project compose. Leaving them here too would deploy the same container + // twice and collide on the ingress host ports (80/443/81) — the + // "duplicate runtime ownership" that the scope convention in + // docs/APP_DEPLOYMENT.md exists to prevent. This runs only when building + // the remote bundle, so the local `.stacker/docker-compose.yml` keeps the + // proxy service (a local deploy has no install-service role to run it). + let stripped_platform_services = strip_platform_managed_services(&mut compose_yaml); + if !stripped_platform_services.is_empty() { + eprintln!( + " Excluding platform-managed service(s) from the remote compose \ + (installed separately by their own role): {}", + stripped_platform_services.join(", ") + ); + } + let mut collected = BTreeMap::::new(); let selected_env_file = if let Some(env_file) = env_file { @@ -642,6 +662,114 @@ fn mapping_mut(value: &mut serde_yaml::Value) -> Option<&mut serde_yaml::Mapping } } +/// Remove `services` entries carrying the `my.stacker.scope: platform` label +/// from a parsed compose document, returning the removed service names. +/// +/// Platform-managed services (the nginx-proxy-manager ingress today; see +/// `PLATFORM_MANAGED_APP_CODES`) are installed by their own Ansible role in +/// their own directory, so they must not also appear in the project compose +/// — otherwise the container is deployed twice and the ingress ports collide. +/// User-declared services are labeled `scope: project` (or unlabeled) and are +/// left untouched, so a user's own reverse-proxy service is never dropped. +fn strip_platform_managed_services(compose: &mut serde_yaml::Value) -> Vec { + let scope_key = serde_yaml::Value::String(crate::helpers::stacker_labels::SCOPE.to_string()); + let Some(services) = mapping_mut(compose) + .and_then(|root| root.get_mut(serde_yaml::Value::String("services".to_string()))) + .and_then(mapping_mut) + else { + return Vec::new(); + }; + + let to_remove: Vec = services + .iter() + .filter(|(_, definition)| { + definition + .get("labels") + .and_then(|labels| labels.as_mapping()) + .and_then(|labels| labels.get(&scope_key)) + .and_then(|scope| scope.as_str()) + .map(|scope| scope == crate::helpers::stacker_labels::SCOPE_PLATFORM) + .unwrap_or(false) + }) + .map(|(name, _)| name.clone()) + .collect(); + + // Collect the named volumes the doomed services referenced, so we can prune + // any that become orphaned once those services are gone (e.g. Caddy's + // caddy_data/caddy_config, which would otherwise linger as unused top-level + // volume declarations in the remote compose). + let mut candidate_volumes: Vec = Vec::new(); + for name in &to_remove { + if let Some(def) = services.get(name) { + candidate_volumes.extend(named_volume_sources(def)); + } + } + + let mut removed = Vec::with_capacity(to_remove.len()); + for name in to_remove { + services.remove(&name); + if let serde_yaml::Value::String(name) = name { + removed.push(name); + } + } + + // A candidate volume is orphaned only if no *remaining* service still mounts + // it. Re-borrow services immutably to check, then prune the top-level map. + if !candidate_volumes.is_empty() { + let still_referenced: std::collections::HashSet = mapping_mut(compose) + .and_then(|root| root.get_mut(serde_yaml::Value::String("services".to_string()))) + .and_then(mapping_mut) + .map(|services| { + services + .values() + .flat_map(named_volume_sources) + .collect() + }) + .unwrap_or_default(); + + if let Some(volumes) = mapping_mut(compose) + .and_then(|root| root.get_mut(serde_yaml::Value::String("volumes".to_string()))) + .and_then(mapping_mut) + { + for vol in candidate_volumes { + if !still_referenced.contains(&vol) { + volumes.remove(&serde_yaml::Value::String(vol)); + } + } + } + } + + removed +} + +/// Extract the *named* volume sources a service mounts (e.g. `caddy_data` from +/// `caddy_data:/data`, or `source: caddy_data` in long syntax). Bind mounts +/// (sources containing `/` or starting with `.`) are host paths, not named +/// volumes, and are ignored. +fn named_volume_sources(service_def: &serde_yaml::Value) -> Vec { + let Some(serde_yaml::Value::Sequence(volumes)) = service_def.get("volumes") else { + return Vec::new(); + }; + let is_named = |src: &str| !src.is_empty() && !src.contains('/') && !src.starts_with('.'); + volumes + .iter() + .filter_map(|vol| match vol { + // Short syntax: "name:/container/path[:opts]" + serde_yaml::Value::String(s) => { + let src = s.split(':').next().unwrap_or(""); + is_named(src).then(|| src.to_string()) + } + // Long syntax: { type: volume, source: name, target: ... } + serde_yaml::Value::Mapping(_) => vol + .get("source") + .and_then(|s| s.as_str()) + .filter(|src| is_named(src)) + .map(|src| src.to_string()), + _ => None, + }) + .collect() +} + fn validation_error(message: impl Into) -> CliError { CliError::ConfigValidation(message.into()) } @@ -994,4 +1122,150 @@ services: assert_eq!(metadata["config_files"][1]["content_hidden"], false); assert!(metadata["config_files"][0].get("content").is_none()); } + + #[test] + fn build_config_bundle_strips_platform_managed_services_from_remote_compose() { + // A `proxy: type: nginx-proxy-manager` deploy synthesizes a + // platform-scoped `proxy-manager` service into the compose. The + // install-service deploys NPM separately via its own role, so the + // remote bundle must NOT also carry it (double-deploy / port collision). + // A user's own reverse-proxy declared as a normal service is + // project-scoped and must be preserved. + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("docker-compose.yml"), + r#" +services: + app: + image: myapp:latest + labels: + my.stacker.scope: "project" + proxy-manager: + image: jc21/nginx-proxy-manager:latest + ports: + - "80:80" + - "443:443" + - "81:81" + labels: + my.stacker.scope: "platform" + my.stacker.service: "nginx_proxy_manager" + my-own-traefik: + image: traefik:v2.10 + labels: + my.stacker.scope: "project" +"#, + ) + .unwrap(); + + let artifacts = build_config_bundle( + dir.path(), + "default", + &dir.path().join("docker-compose.yml"), + None, + dir.path(), + false, + ) + .expect("bundle should be built"); + + let compose_content = artifacts + .config_files + .iter() + .find(|file| file["name"] == "docker-compose.yml") + .and_then(|file| file["content"].as_str()) + .expect("remote bundle contains the compose file"); + + // Assert on the scope *label* (the contract), not the generated + // service name: no `scope: platform` service may survive in the + // remote compose, while project-scoped services are preserved. + let parsed: serde_yaml::Value = + serde_yaml::from_str(compose_content).expect("remote compose is valid yaml"); + let services = parsed + .get("services") + .and_then(|services| services.as_mapping()) + .expect("remote compose has a services map"); + + let scope_key = + serde_yaml::Value::String(crate::helpers::stacker_labels::SCOPE.to_string()); + let service_scope = |name: &str| -> Option { + services + .get(name)? + .get("labels")? + .as_mapping()? + .get(&scope_key)? + .as_str() + .map(str::to_string) + }; + + // No platform-scoped service remains anywhere in the shipped compose … + let has_platform_scoped = services.values().any(|definition| { + definition + .get("labels") + .and_then(|labels| labels.as_mapping()) + .and_then(|labels| labels.get(&scope_key)) + .and_then(|scope| scope.as_str()) + == Some(crate::helpers::stacker_labels::SCOPE_PLATFORM) + }); + assert!( + !has_platform_scoped, + "no `scope: platform` service should survive in the remote compose:\n{compose_content}" + ); + + // … while the app and the user's own project-scoped proxy are kept. + assert_eq!( + service_scope("app").as_deref(), + Some(crate::helpers::stacker_labels::SCOPE_PROJECT) + ); + assert_eq!( + service_scope("my-own-traefik").as_deref(), + Some(crate::helpers::stacker_labels::SCOPE_PROJECT), + "a user's own project-scoped proxy must not be stripped:\n{compose_content}" + ); + } + + #[test] + fn strip_platform_managed_services_prunes_orphaned_named_volumes() { + // Stripping the platform proxy must also drop the named volumes only it + // used (Caddy's caddy_data/caddy_config), while keeping volumes still + // referenced by a surviving service and untouched bind mounts. + let mut compose: serde_yaml::Value = serde_yaml::from_str( + r#" +services: + app: + image: myapp:latest + volumes: + - app_data:/data + labels: + my.stacker.scope: "project" + caddy: + image: caddy:2-alpine + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + - app_data:/shared + labels: + my.stacker.scope: "platform" +volumes: + app_data: {} + caddy_data: {} + caddy_config: {} +"#, + ) + .unwrap(); + + let removed = strip_platform_managed_services(&mut compose); + assert_eq!(removed, vec!["caddy".to_string()]); + + let volumes = compose + .get("volumes") + .and_then(|v| v.as_mapping()) + .expect("top-level volumes map"); + let has = |name: &str| volumes.contains_key(serde_yaml::Value::String(name.to_string())); + + // Orphaned (only caddy used them) → pruned. + assert!(!has("caddy_data"), "caddy_data should be pruned"); + assert!(!has("caddy_config"), "caddy_config should be pruned"); + // Still used by the surviving `app` service → kept. + assert!(has("app_data"), "app_data is still referenced and must stay"); + } } diff --git a/src/cli/config_parser.rs b/src/cli/config_parser.rs index 86b66b19..44cac429 100644 --- a/src/cli/config_parser.rs +++ b/src/cli/config_parser.rs @@ -81,6 +81,7 @@ pub enum ProxyType { Nginx, NginxProxyManager, Traefik, + Caddy, None, } @@ -90,6 +91,7 @@ impl fmt::Display for ProxyType { Self::Nginx => write!(f, "nginx"), Self::NginxProxyManager => write!(f, "nginx-proxy-manager"), Self::Traefik => write!(f, "traefik"), + Self::Caddy => write!(f, "caddy"), Self::None => write!(f, "none"), } } @@ -381,6 +383,80 @@ pub struct DomainConfig { pub upstream: String, } +/// A declaratively-defined pipe (`pipes:` block in stacker.yml). This is the +/// committable source of truth reconciled by `stacker pipe apply` / `pipe diff` +/// against the deployed templates+instances. Fields mirror the `pipe create` +/// flags so the imperative and declarative surfaces stay 1:1. +/// +/// See `config/docs/PIPE_IAC_AND_RESILIENCE_PLAN.md` §5. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PipeSpec { + /// Unique pipe name (identity for reconcile). + pub name: String, + /// Source app code (container/service selector). + pub source: String, + /// Target app code. + pub target: String, + /// Source endpoint "METHOD /path". + pub source_endpoint: String, + /// Target endpoint "METHOD /path". + pub target_endpoint: String, + #[serde(default)] + pub source_fields: Vec, + #[serde(default)] + pub target_fields: Vec, + /// Trigger mode: manual | webhook | poll (default webhook, matching the CLI). + #[serde(default = "default_pipe_trigger")] + pub trigger: String, + /// Poll interval (seconds) when trigger = poll. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub poll_interval: Option, + /// Max delivery retries (→ pipe config). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_backoff_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_backoff_max_ms: Option, + /// Run another pipe (by name) on failure / success. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_failure: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_success: Option, +} + +fn default_pipe_trigger() -> String { + "webhook".to_string() +} + +impl PipeSpec { + /// Build the typed resilience/lifecycle config for this pipe (empty when no + /// retry/handler was declared, so it round-trips cleanly). + pub fn to_pipe_config(&self) -> crate::models::pipe_config::PipeConfig { + use crate::models::agent_protocol::RetryPolicy; + use crate::models::pipe_config::{HandlerRef, PipeConfig}; + + let d = RetryPolicy::default(); + let retry = if self.retry.is_some() + || self.retry_backoff_ms.is_some() + || self.retry_backoff_max_ms.is_some() + { + Some(RetryPolicy { + max_retries: self.retry.unwrap_or(d.max_retries), + backoff_base_ms: self.retry_backoff_ms.unwrap_or(d.backoff_base_ms), + backoff_max_ms: self.retry_backoff_max_ms.unwrap_or(d.backoff_max_ms), + }) + } else { + None + }; + PipeConfig { + retry, + on_failure: self.on_failure.clone().map(HandlerRef::Pipe), + on_success: self.on_success.clone().map(HandlerRef::Pipe), + } + } +} + /// Docker registry credentials for pulling private images during deployment. /// /// TODO: Currently these credentials are passed through on every deploy (env vars or stacker.yml). @@ -684,6 +760,65 @@ pub struct MonitoringConfig { #[serde(default)] pub metrics: Option, + + /// Container-down alerting for `stacker monitor`. Absent → no alarm. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alerts: Option, +} + +/// Config for the `stacker monitor` container-health alarm (§10 of the PIPE +/// IaC/resilience plan). Reuses `HandlerRef` as the notification target, so an +/// alert can hit a webhook (ntfy/Slack) or, later, run a pipe. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AlertConfig { + /// Poll interval in seconds for the watch loop (default 60). + #[serde(default = "default_alert_interval")] + pub interval: u64, + /// Where to deliver the alert (required). + pub target: AlertTarget, + /// Also notify when containers recover to healthy (default true). + #[serde(default = "default_true")] + pub on_recovery: bool, +} + +/// Alert delivery target — a YAML-friendly, untagged view (distinguished by the +/// `url` vs `pipe` key) that maps onto the shared `HandlerRef` for dispatch: +/// +/// ```yaml +/// target: { terminal: true } # terminal + desktop +/// target: { url: "https://ntfy.example.com/alerts", method: POST } # webhook +/// target: { pipe: oncall-notify } # run a pipe +/// ``` +/// +/// (A dedicated type, rather than reusing `HandlerRef` directly, because +/// `serde_yaml` renders externally-tagged enums as `!tag` — awkward in a config +/// file — whereas this untagged form reads naturally.) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AlertTarget { + /// Terminal + desktop notification (OS notification, terminal bell, stderr). + Terminal { terminal: bool }, + /// HTTP webhook (ntfy/Slack/…). `method` defaults to POST. + Webhook { + url: String, + #[serde(default = "default_notify_method_alert")] + method: String, + }, + /// Run a declared pipe by name. + Pipe { pipe: String }, +} + +fn default_notify_method_alert() -> String { + "POST".to_string() +} + + +fn default_alert_interval() -> u64 { + 60 +} + +fn default_true() -> bool { + true } /// Healthcheck settings. @@ -847,6 +982,11 @@ pub struct StackerConfig { #[serde(default)] pub config_contract: ConfigContract, + /// Declaratively-defined pipes reconciled by `stacker pipe apply` / `diff`. + /// Absent → empty, so existing configs are unaffected. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pipes: Vec, + /// Provenance of this config. Not serialized — computed at load time. /// /// Defaults to `UserAuthored`. `from_file`/`from_str` flip to @@ -883,6 +1023,29 @@ impl StackerConfig { /// config and write it back to disk, use [`from_file_raw`] instead so /// that `${VAR}` placeholders are preserved. pub fn from_file(path: &Path) -> Result { + Self::from_file_for_target(path, None) + } + + /// Load config from a file path like [`from_file`], but skip `${VAR}` + /// resolution inside whichever of `deploy.server` / `deploy.cloud` is + /// **not** the active target — determined by `target_override` (e.g. + /// the CLI's `--target` flag) or, absent that, the literal + /// `deploy.target` value in the file. + /// + /// A project commonly defines both `deploy.server` and `deploy.cloud` + /// (see the dual-target pattern) so it can be pointed at either without + /// editing `stacker.yml`. Without this, `stacker deploy --target cloud` + /// would fail on a missing `${EXISTING_SERVER_HOST}` even though the + /// server section is never used for a cloud deploy — see GH #239. + /// + /// When the effective target can't be determined (no override, no + /// literal `deploy.target` in the file, or a multi-target `deploy.targets` + /// config), both sections are resolved as before — this only skips a + /// section when we're confident it's inactive. + pub fn from_file_for_target( + path: &Path, + target_override: Option<&str>, + ) -> Result { if !path.exists() { return Err(CliError::ConfigNotFound { path: path.to_path_buf(), @@ -893,7 +1056,14 @@ impl StackerConfig { let origin = detect_origin_from_raw(&raw_content); let mut parsed: serde_yaml::Value = serde_yaml::from_str(&raw_content)?; let env_file_vars = load_env_file_vars_from_yaml(path, &raw_content); - resolve_env_placeholders_in_value(&mut parsed, &env_file_vars)?; + + let (skip_server, skip_cloud) = inactive_deploy_sections(&parsed, target_override); + resolve_env_placeholders_in_value_skipping_deploy( + &mut parsed, + &env_file_vars, + skip_server, + skip_cloud, + )?; let app_present = parsed.get("app").is_some(); let mut config = deserialize_config_value(parsed)?; config.origin = origin; @@ -924,6 +1094,21 @@ impl StackerConfig { Ok(config) } + /// Load config from a YAML string **without** resolving `${VAR}` placeholders. + /// + /// Use this for validation/preview where referenced variables (e.g. + /// config_contract install inputs) are not yet known. `${VAR}` references + /// are kept as-is so a missing variable does not fail the parse. + pub fn from_str_raw(yaml: &str) -> Result { + let origin = detect_origin_from_raw(yaml); + let parsed: serde_yaml::Value = serde_yaml::from_str(yaml)?; + let app_present = parsed.get("app").is_some(); + let mut config = deserialize_config_value(parsed)?; + config.origin = origin; + config.app_present = app_present; + Ok(config) + } + /// Load config from a YAML string (useful for tests). pub fn from_str(yaml: &str) -> Result { let origin = detect_origin_from_raw(yaml); @@ -1079,15 +1264,19 @@ impl StackerConfig { }); } - // Port conflict detection across services + // Port conflict detection across services, keyed by the *published + // host port*. Only services that publish a fixed host port can collide; + // the container-only form (no host port) is skipped. `host_port_binding` + // correctly handles the `ip:host:container` form — extracting the host + // port, not the leading IP — so two loopback services on different + // ports (e.g. `127.0.0.1:5432:5432` and `127.0.0.1:6379:6379`) are no + // longer misreported as sharing a port. let mut port_map: HashMap> = HashMap::new(); for svc in &self.services { for port_str in &svc.ports { - let host_port = extract_host_port(port_str); - port_map - .entry(host_port.clone()) - .or_default() - .push(svc.name.clone()); + if let Some(host_port) = host_port_binding(port_str) { + port_map.entry(host_port).or_default().push(svc.name.clone()); + } } } for (port, services) in &port_map { @@ -1105,6 +1294,57 @@ impl StackerConfig { } } + // W003 — a `proxy:` block deploys a *platform-managed* reverse proxy + // that owns the ingress host ports on the target. Any app/service that + // also publishes one of those host ports collides with it: the managed + // proxy takes precedence and the user's binding is shadowed. Detect by + // host-port overlap (not by image name) so it also catches a plain app + // accidentally bound to :80, not only a rival proxy. + if self.proxy.proxy_type != ProxyType::None { + let ingress_ports: &[&str] = match self.proxy.proxy_type { + ProxyType::NginxProxyManager => &["80", "443", "81"], + ProxyType::Nginx | ProxyType::Traefik | ProxyType::Caddy => &["80", "443"], + ProxyType::None => &[], + }; + + let mut published: Vec<(String, String)> = Vec::new(); + for port in &self.app.ports { + if let Some(host_port) = host_port_binding(port) { + published.push(("app".to_string(), host_port)); + } + } + for svc in &self.services { + for port in &svc.ports { + if let Some(host_port) = host_port_binding(port) { + published.push((svc.name.clone(), host_port)); + } + } + } + + let mut conflicts: BTreeMap> = BTreeMap::new(); + for (service, host_port) in published { + if ingress_ports.contains(&host_port.as_str()) { + conflicts.entry(service).or_default().push(host_port); + } + } + for (service, ports) in conflicts { + issues.push(ValidationIssue { + severity: Severity::Warning, + code: "W003".to_string(), + message: format!( + "Service '{service}' publishes host port(s) {} that the platform-managed \ + '{}' proxy (configured via the proxy: block) also claims on the deploy \ + target. The managed proxy takes precedence, so '{service}' is ignored on \ + those ports. Remove the proxy: block to keep your own service there, or \ + change its host port.", + ports.join(", "), + self.proxy.proxy_type + ), + field: Some("proxy.type".to_string()), + }); + } + } + issues } } @@ -1314,8 +1554,20 @@ fn load_env_file_vars_from_yaml(path: &Path, raw_content: &str) -> HashMap String { - port_str.split(':').next().unwrap_or(port_str).to_string() +/// Extract the *published host* port from a compose port spec, or `None` when +/// the spec publishes no host port (container-only form). Handles the protocol +/// suffix and all three binding forms: +/// `"80"` -> None (ephemeral), `"80:80"` -> `"80"`, +/// `"127.0.0.1:80:80"` -> `"80"`, `"80:80/tcp"` -> `"80"`. +fn host_port_binding(port_str: &str) -> Option { + let spec = port_str.split('/').next().unwrap_or(port_str); + let parts: Vec<&str> = spec.split(':').collect(); + match parts.as_slice() { + [_container] => None, + [host, _container] => Some((*host).to_string()), + [_ip, host, _container] => Some((*host).to_string()), + _ => None, + } } /// Resolve `${VAR_NAME}` references in a string using process environment. @@ -1324,6 +1576,96 @@ fn resolve_env_vars(content: &str) -> Result { resolve_env_vars_with_fallback(content, &HashMap::new()) } +/// Decide which of `deploy.server` / `deploy.cloud` (legacy single-block +/// form) is inactive for the effective target, so env-var resolution can +/// skip it entirely. Returns `(skip_server, skip_cloud)`. +/// +/// Only acts when the effective target is confidently known — from +/// `target_override` (e.g. `--target`) or a literal (non-templated) +/// `deploy.target` in the file. Multi-target `deploy.targets.` +/// configs are left untouched: each named profile already carries only +/// its own `server` or `cloud`, so there's no ambiguity to resolve. +fn inactive_deploy_sections( + parsed: &serde_yaml::Value, + target_override: Option<&str>, +) -> (bool, bool) { + let Some(root) = parsed.as_mapping() else { + return (false, false); + }; + let Some(deploy) = root + .get(serde_yaml::Value::String("deploy".to_string())) + .and_then(serde_yaml::Value::as_mapping) + else { + return (false, false); + }; + + let has_named_targets = deploy + .get(serde_yaml::Value::String("targets".to_string())) + .and_then(serde_yaml::Value::as_mapping) + .map(|m| !m.is_empty()) + .unwrap_or(false); + if has_named_targets { + return (false, false); + } + + let effective_target = target_override + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_lowercase) + .or_else(|| { + deploy + .get(serde_yaml::Value::String("target".to_string())) + .and_then(serde_yaml::Value::as_str) + .filter(|s| !s.contains("${")) // don't trust an unresolved literal + .map(|s| s.trim().to_lowercase()) + }); + + match effective_target.as_deref() { + Some("cloud") => (true, false), + Some("server") => (false, true), + Some("local") => (true, true), + _ => (false, false), + } +} + +/// Like [`resolve_env_placeholders_in_value`], but leaves `deploy.server` +/// and/or `deploy.cloud` completely untouched (placeholders and all) when +/// `skip_server`/`skip_cloud` say that section is inactive for this deploy. +fn resolve_env_placeholders_in_value_skipping_deploy( + value: &mut serde_yaml::Value, + fallback_vars: &HashMap, + skip_server: bool, + skip_cloud: bool, +) -> Result<(), CliError> { + if !skip_server && !skip_cloud { + return resolve_env_placeholders_in_value(value, fallback_vars); + } + + let Some(root) = value.as_mapping_mut() else { + return resolve_env_placeholders_in_value(value, fallback_vars); + }; + + for (key, map_value) in root.iter_mut() { + if key.as_str() != Some("deploy") { + resolve_env_placeholders_in_value(map_value, fallback_vars)?; + continue; + } + let Some(deploy_map) = map_value.as_mapping_mut() else { + continue; + }; + for (deploy_key, deploy_value) in deploy_map.iter_mut() { + let skip = (deploy_key.as_str() == Some("server") && skip_server) + || (deploy_key.as_str() == Some("cloud") && skip_cloud); + if skip { + continue; + } + resolve_env_placeholders_in_value(deploy_value, fallback_vars)?; + } + } + + Ok(()) +} + fn resolve_env_placeholders_in_value( value: &mut serde_yaml::Value, fallback_vars: &HashMap, @@ -1590,6 +1932,7 @@ impl ConfigBuilder { env_file: self.env_file, env: self.env, config_contract: ConfigContract::default(), + pipes: Vec::new(), origin: ConfigOrigin::UserAuthored, app_present, }) @@ -1926,6 +2269,125 @@ deploy: assert_eq!(config.app.image.as_deref(), Some("node:14-alpine")); } + // Regression tests for GH #239: `stacker deploy --target cloud` failed + // on a missing `${EXISTING_SERVER_HOST}` even though `deploy.server` is + // never used for a cloud deploy — env-var resolution walked the whole + // file unconditionally, including the inactive dual-target section. + fn dual_target_yaml() -> &'static str { + r#" +name: dual-target-app +app: + type: custom + path: . + image: myorg/myapp:latest +deploy: + target: server + server: + host: ${EXISTING_SERVER_HOST} + user: ${EXISTING_SERVER_USER} + ssh_key: /tmp/id_ed25519 + cloud: + provider: hetzner + region: fsn1 + size: cpx22 + public_ports: ["3579"] +"# + } + + #[test] + fn test_from_file_for_target_cloud_skips_missing_server_vars() { + let dir = TempDir::new().unwrap(); + let config_path = dir.path().join("stacker.yml"); + fs::write(&config_path, dual_target_yaml()).unwrap(); + + // No EXISTING_SERVER_HOST/USER anywhere (env or .env) — must not + // fail, since --target cloud never touches deploy.server. + let config = StackerConfig::from_file_for_target(&config_path, Some("cloud")).unwrap(); + let resolved = config.with_resolved_deploy_target(Some("cloud")).unwrap(); + assert_eq!(resolved.deploy.target, DeployTarget::Cloud); + assert!(resolved.deploy.cloud.is_some()); + } + + #[test] + fn test_from_file_for_target_server_skips_cloud_section() { + let dir = TempDir::new().unwrap(); + let config_path = dir.path().join("stacker.yml"); + // Cloud section left var-free here on purpose — this test only + // asserts the server branch resolves independent of cloud content. + fs::write( + &config_path, + r#" +name: dual-target-app +app: + type: custom + path: . + image: myorg/myapp:latest +deploy: + target: server + server: + host: 203.0.113.5 + user: deployer + ssh_key: /tmp/id_ed25519 + cloud: + provider: hetzner + region: ${UNSET_REGION_VAR} +"#, + ) + .unwrap(); + + let config = StackerConfig::from_file_for_target(&config_path, Some("server")).unwrap(); + assert_eq!( + config.deploy.server.as_ref().map(|s| s.host.as_str()), + Some("203.0.113.5") + ); + } + + #[test] + fn test_from_file_for_target_falls_back_to_literal_deploy_target_in_file() { + let dir = TempDir::new().unwrap(); + let config_path = dir.path().join("stacker.yml"); + fs::write(&config_path, dual_target_yaml()).unwrap(); + + // No override passed — `deploy.target: server` in the file is a + // plain literal, so it should still be trusted as the effective + // target and fail exactly like before (this isn't a behavior + // change for callers that already relied on the file's own target). + let result = StackerConfig::from_file_for_target(&config_path, None); + assert!( + result.is_err(), + "server section is active, so its missing vars must still error" + ); + } + + #[test] + fn test_from_file_for_target_unresolvable_target_resolves_both_sections_as_before() { + let dir = TempDir::new().unwrap(); + let config_path = dir.path().join("stacker.yml"); + fs::write(&config_path, dual_target_yaml()).unwrap(); + + // Override doesn't match a known target keyword — falls back to + // resolving everything, matching the pre-fix strict behavior. + let result = StackerConfig::from_file_for_target(&config_path, Some("bogus")); + assert!(result.is_err()); + } + + #[test] + fn test_config_validate_respects_target_override() { + let dir = TempDir::new().unwrap(); + let config_path = dir.path().join("stacker.yml"); + fs::write(&config_path, dual_target_yaml()).unwrap(); + + let path_str = config_path.to_string_lossy().to_string(); + assert!( + crate::console::commands::cli::config::run_validate(&path_str, Some("cloud")).is_ok(), + "config validate --target cloud must not fail on unset server vars" + ); + assert!( + crate::console::commands::cli::config::run_validate(&path_str, None).is_err(), + "without an override, the file's own deploy.target: server is still active" + ); + } + #[test] fn test_parse_invalid_app_type_returns_error() { let yaml = r#" @@ -2035,6 +2497,65 @@ proxy: assert_eq!(config.proxy.domains[1].ssl, SslMode::Off); } + #[test] + fn test_parse_pipes_block() { + let yaml = r#" +name: pipes-test +pipes: + - name: apprise-to-ntfy + source: app + target: ntfy + source_endpoint: "GET /status" + target_endpoint: "POST /pipetest" + source_fields: [message] + target_fields: [message] + trigger: manual + retry: 5 + retry_backoff_ms: 500 + on_failure: oncall-notify +"#; + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.pipes.len(), 1); + let p = &config.pipes[0]; + assert_eq!(p.name, "apprise-to-ntfy"); + assert_eq!(p.source_endpoint, "GET /status"); + assert_eq!(p.trigger, "manual"); + // The declared retry/handler flow into the typed PipeConfig … + let cfg = p.to_pipe_config(); + let retry = cfg.retry.as_ref().expect("retry declared"); + assert_eq!(retry.max_retries, 5); + assert_eq!(retry.backoff_base_ms, 500); + assert_eq!(retry.backoff_max_ms, 30_000); // default filled in + assert_eq!( + cfg.on_failure, + Some(crate::models::pipe_config::HandlerRef::Pipe("oncall-notify".into())) + ); + } + + #[test] + fn test_pipes_absent_defaults_empty_and_trigger_default() { + // No pipes: block → empty (back-compat). Minimal pipe → webhook trigger, + // empty PipeConfig (no retry/handlers declared). + let config = StackerConfig::from_str("name: no-pipes\n").unwrap(); + assert!(config.pipes.is_empty()); + + let yaml = r#" +name: t +pipes: + - name: p + source: a + target: b + source_endpoint: "GET /x" + target_endpoint: "POST /y" +"#; + let c = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(c.pipes[0].trigger, "webhook"); + assert_eq!( + c.pipes[0].to_pipe_config(), + crate::models::pipe_config::PipeConfig::default() + ); + } + #[test] fn test_parse_ai_section_with_ollama() { let yaml = r#" @@ -2221,6 +2742,138 @@ services: assert!(errors.is_empty(), "Expected no errors, got: {errors:?}"); } + #[test] + fn w001_does_not_false_positive_on_distinct_loopback_ports() { + // Regression: `ip:host:container` bindings must key on the host *port*, + // not the IP. Two loopback services on different ports do NOT conflict. + let config = StackerConfig::from_str( + r#" +name: demo +app: + type: custom + image: myapp:latest +services: + - name: postgres + image: postgres:16-alpine + ports: + - "127.0.0.1:5432:5432" + - name: redis + image: redis:7-alpine + ports: + - "127.0.0.1:6379:6379" +"#, + ) + .unwrap(); + + assert!( + !config + .validate_semantics() + .iter() + .any(|issue| issue.code == "W001"), + "distinct loopback host ports must not be reported as a conflict" + ); + } + + #[test] + fn w001_detects_real_host_port_conflict_across_binding_forms() { + // A 2-part `80:80` and a 3-part `127.0.0.1:80:80` both publish host + // port 80 → real conflict, previously missed by the IP-first extractor. + let config = StackerConfig::from_str( + r#" +name: demo +app: + type: custom + image: myapp:latest +services: + - name: web + image: nginx:alpine + ports: + - "80:80" + - name: legacy + image: httpd:alpine + ports: + - "127.0.0.1:80:80" +"#, + ) + .unwrap(); + + let w001: Vec<_> = config + .validate_semantics() + .into_iter() + .filter(|issue| issue.code == "W001") + .collect(); + assert_eq!(w001.len(), 1, "expected one W001 for the port-80 clash: {w001:?}"); + assert!(w001[0].message.contains("80")); + assert!(w001[0].message.contains("web") && w001[0].message.contains("legacy")); + } + + #[test] + fn proxy_block_warns_when_user_service_publishes_an_ingress_port() { + // A `proxy:` block deploys a platform-managed proxy owning 80/443/81. + // A user's own reverse-proxy service on 80/443 collides → one W003. + let config = StackerConfig::from_str( + r#" +name: demo +app: + type: custom + image: myapp:latest +services: + - name: my-own-traefik + image: traefik:v2.10 + ports: + - "80:80" + - "443:443" +proxy: + type: nginx-proxy-manager +"#, + ) + .unwrap(); + + let w003: Vec<_> = config + .validate_semantics() + .into_iter() + .filter(|issue| issue.code == "W003") + .collect(); + assert_eq!(w003.len(), 1, "expected exactly one W003, got: {w003:?}"); + assert_eq!(w003[0].severity, Severity::Warning); + assert!(w003[0].message.contains("my-own-traefik")); + // Both conflicting ports are reported in the single per-service warning. + assert!(w003[0].message.contains("80")); + assert!(w003[0].message.contains("443")); + } + + #[test] + fn proxy_block_does_not_warn_for_nonconflicting_ports() { + // The app on :8080 (with `127.0.0.1:` host-scoped DB elsewhere) does not + // overlap the proxy's ingress ports → no W003. + let config = StackerConfig::from_str( + r#" +name: demo +app: + type: custom + image: myapp:latest + ports: + - "8080:8080" +services: + - name: db + image: postgres:16-alpine + ports: + - "127.0.0.1:5432:5432" +proxy: + type: nginx-proxy-manager +"#, + ) + .unwrap(); + + assert!( + !config + .validate_semantics() + .iter() + .any(|issue| issue.code == "W003"), + "no ingress overlap should produce no W003" + ); + } + #[test] fn test_validate_semantics_multi_target_requires_default_for_multiple_profiles() { let config = StackerConfig::from_str( diff --git a/src/cli/credentials.rs b/src/cli/credentials.rs index 7838d0c1..2901d1fa 100644 --- a/src/cli/credentials.rs +++ b/src/cli/credentials.rs @@ -217,17 +217,45 @@ impl CredentialsManager { /// Load credentials and ensure they are present and not expired. /// Returns `CliError::LoginRequired` when absent, - /// `CliError::TokenExpired` when expired. + /// `CliError::TokenExpired` when expired and no refresh was possible. + /// + /// When the access token has expired but a `refresh_token` is on file, + /// transparently exchanges it for a new access token (RFC 6749 §6 + /// `grant_type=refresh_token`) and persists the result before + /// returning it — the caller never sees `TokenExpired` for a session + /// that could be silently renewed. See GH issue #214. pub fn require_valid_token(&self, feature: &str) -> Result { + self.require_valid_token_with_oauth(feature, &HttpOAuthClient) + } + + /// Same as [`require_valid_token`](Self::require_valid_token), but with + /// the OAuth client injectable for testing. + pub fn require_valid_token_with_oauth( + &self, + feature: &str, + oauth: &O, + ) -> Result { let creds = self.store.load()?.ok_or_else(|| CliError::LoginRequired { feature: feature.to_string(), })?; - if creds.is_expired() { - return Err(CliError::TokenExpired); + if !creds.is_expired() { + return Ok(creds); } - Ok(creds) + // No auth URL to refresh against (never logged in via this + // machine's env/UserConfig) is just another "refresh not + // possible" case, same as a missing refresh_token below. + if let Ok(auth_url) = resolve_auth_url_from(None) { + if let Some(refreshed) = try_refresh_token(&creds, oauth, &auth_url) { + // Best-effort: if persisting the refreshed token fails, the + // caller still gets a valid in-memory token for this run. + let _ = self.store.save(&refreshed); + return Ok(refreshed); + } + } + + Err(CliError::TokenExpired) } /// Returns the bearer token header value if credentials are valid. @@ -237,6 +265,35 @@ impl CredentialsManager { } } +/// Attempt to renew `creds` via its `refresh_token`. Returns `None` (never +/// an error) for every case where refresh isn't possible or fails — the +/// caller's job is to fall back to the existing `TokenExpired` prompt, not +/// to surface a refresh attempt's internal failure as a new/different +/// error the user has no context for. +fn try_refresh_token( + creds: &StoredCredentials, + oauth: &O, + auth_url: &str, +) -> Option { + let refresh_token = creds.refresh_token.as_deref()?; + let token_resp = oauth.refresh_token(auth_url, refresh_token).ok()?; + + let mut refreshed: StoredCredentials = token_resp.into(); + // The refresh response only carries token fields; carry over identity + // fields From can't know about. + refreshed.email = creds.email.clone(); + refreshed.server_url = creds.server_url.clone(); + refreshed.org = creds.org.clone(); + refreshed.domain = creds.domain.clone(); + // Some OAuth servers omit refresh_token on a refresh response, meaning + // "reuse the one you already have" rather than "you no longer have one". + if refreshed.refresh_token.is_none() { + refreshed.refresh_token = creds.refresh_token.clone(); + } + + Some(refreshed) +} + impl CredentialsManager { /// Convenience: create a manager backed by the default file path. pub fn with_default_store() -> Self { @@ -258,10 +315,29 @@ fn is_direct_login_endpoint(auth_url: &str) -> bool { || url.ends_with("/login") } -fn resolve_auth_url(request: &LoginRequest) -> Result { - request - .auth_url - .clone() +/// Compute the refresh endpoint URL for a login `auth_url`. The User +/// Service's `.../auth/refresh` is registered right alongside +/// `.../auth/login` (see `app/auth/views.py`), so this mirrors +/// `request_token`'s own two supported `auth_url` shapes: +/// - a full login URL (e.g. an explicit `--auth-url` ending in +/// `/auth/login` or `/login`) -> swap the last segment for `/refresh`. +/// - a base URL (e.g. `https://try.direct/server/user`, the shape +/// persisted in `UserConfig` — `request_token` appends `TOKEN_ENDPOINT` +/// to this at request time) -> append `/auth/refresh`. +fn refresh_url_for(auth_url: &str) -> Option { + let trimmed = auth_url.trim_end_matches('/'); + if is_direct_login_endpoint(trimmed) { + trimmed + .strip_suffix("/login") + .map(|base| format!("{base}/refresh")) + } else { + Some(format!("{trimmed}/auth/refresh")) + } +} + +fn resolve_auth_url_from(explicit: Option<&str>) -> Result { + explicit + .map(str::to_string) .or_else(|| std::env::var("STACKER_AUTH_URL").ok()) .or_else(|| std::env::var("STACKER_API_URL").ok()) .or_else(|| crate::cli::user_config::UserConfig::load().auth_url) @@ -272,6 +348,10 @@ fn resolve_auth_url(request: &LoginRequest) -> Result { }) } +fn resolve_auth_url(request: &LoginRequest) -> Result { + resolve_auth_url_from(request.auth_url.as_deref()) +} + fn resolve_server_url(request: &LoginRequest) -> Result { request .server_url @@ -306,6 +386,15 @@ pub trait OAuthClient: Send + Sync { email: &str, password: &str, ) -> Result; + + /// Exchange a stored `refresh_token` for a new access token via the + /// standard OAuth2 `grant_type=refresh_token` grant (RFC 6749 §6), + /// posted to the same token endpoint used for login. Returns an error + /// when the endpoint doesn't support this grant (e.g. a legacy direct + /// email/password login endpoint with no OAuth refresh concept) — the + /// caller treats that as "refresh not possible" and falls back to + /// prompting `stacker login`, never surfacing it as a harder failure. + fn refresh_token(&self, auth_url: &str, refresh_token: &str) -> Result; } /// Production OAuth client using `reqwest::blocking`. @@ -375,6 +464,47 @@ impl OAuthClient for HttpOAuthClient { Ok(token_resp) } + + fn refresh_token(&self, auth_url: &str, refresh_token: &str) -> Result { + // The login endpoint (`.../auth/login`) mints tokens directly, + // bypassing OAuth client authentication entirely — clients here + // (CLI, web) never have a client_id/secret, so a generic + // `grant_type=refresh_token` POST to a standard OAuth token + // endpoint would fail with invalid_client. The User Service + // exposes a sibling `.../auth/refresh` endpoint with the same + // no-client-auth treatment: POST `refresh_token`, get a fresh + // `{access_token, refresh_token, ...}` back, same shape as login. + // Only known for the literal "/login"-suffixed auth_url shape — + // any other shape has no verified refresh endpoint to target. + let Some(url) = refresh_url_for(auth_url) else { + return Err(CliError::AuthFailed( + "Auth endpoint shape not recognized for token refresh".to_string(), + )); + }; + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| CliError::AuthFailed(format!("HTTP client error: {e}")))?; + + let resp = client + .post(&url) + .form(&[("refresh_token", refresh_token)]) + .send() + .map_err(|e| CliError::AuthFailed(format!("Network error: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + let body_preview: String = body.chars().take(240).collect(); + return Err(CliError::AuthFailed(format!( + "Token refresh failed (HTTP {status}): {body_preview}" + ))); + } + + resp.json() + .map_err(|e| CliError::AuthFailed(format!("Invalid token response: {e}"))) + } } /// High-level login function used by `LoginCommand`. @@ -681,6 +811,14 @@ mod tests { use super::*; use std::sync::{Arc, Mutex}; + /// Serializes tests that mutate STACKER_AUTH_URL/STACKER_API_URL, so + /// they don't race with each other (or with resolve_auth_url_from's + /// other callers) when the suite runs in parallel. + fn credentials_env_lock() -> &'static Mutex<()> { + static LOCK: Mutex<()> = Mutex::new(()); + &LOCK + } + #[test] fn test_is_direct_login_endpoint_detection() { assert!(is_direct_login_endpoint( @@ -692,6 +830,37 @@ mod tests { assert!(!is_direct_login_endpoint("https://api.try.direct")); } + #[test] + fn test_refresh_url_for_swaps_login_for_refresh() { + assert_eq!( + refresh_url_for("https://dev.try.direct/server/user/auth/login"), + Some("https://dev.try.direct/server/user/auth/refresh".to_string()) + ); + // Trailing slash tolerated the same way is_direct_login_endpoint is. + assert_eq!( + refresh_url_for("https://dev.try.direct/server/user/auth/login/"), + Some("https://dev.try.direct/server/user/auth/refresh".to_string()) + ); + assert_eq!( + refresh_url_for("https://api.try.direct/auth/login"), + Some("https://api.try.direct/auth/refresh".to_string()) + ); + } + + #[test] + fn test_refresh_url_for_appends_auth_refresh_for_base_url() { + // The shape UserConfig actually persists (request_token appends + // TOKEN_ENDPOINT to this at login time, so refresh mirrors that). + assert_eq!( + refresh_url_for("https://try.direct/server/user"), + Some("https://try.direct/server/user/auth/refresh".to_string()) + ); + assert_eq!( + refresh_url_for("https://api.try.direct"), + Some("https://api.try.direct/auth/refresh".to_string()) + ); + } + // ── In-memory mock store ──────────────────────── #[derive(Clone, Default)] @@ -895,13 +1064,116 @@ mod tests { #[test] fn test_require_valid_token_expired() { + // Uses require_valid_token_with_oauth + a mock that fails the + // refresh attempt, so this stays deterministic and network-free + // regardless of ambient STACKER_AUTH_URL/UserConfig state — the + // production `require_valid_token()` (real HttpOAuthClient) would + // otherwise attempt a real refresh call here now that expired_creds() + // carries a refresh_token, see test_require_valid_token_expired_* + // below for coverage of the actual refresh path. let (manager, _) = make_manager(); manager.save(&expired_creds()).unwrap(); - let err = manager.require_valid_token("cloud deploy").unwrap_err(); + let oauth = MockOAuthClient::failure("refresh not supported"); + let err = manager + .require_valid_token_with_oauth("cloud deploy", &oauth) + .unwrap_err(); let msg = format!("{}", err); assert!(msg.contains("expired")); } + // try_refresh_token takes an explicit auth_url (not resolved from + // env/UserConfig internally) specifically so this stays a pure, + // deterministic unit test — no ambient STACKER_AUTH_URL/UserConfig + // state to race with other tests in this file. + #[test] + fn test_try_refresh_token_success_carries_identity_fields() { + let oauth = MockOAuthClient::success(); + let refreshed = try_refresh_token(&expired_creds(), &oauth, "https://auth.example.test") + .expect("mock refresh should succeed"); + + assert_eq!(refreshed.access_token, "mock-access-token"); + assert!(!refreshed.is_expired()); + // Identity fields a token response doesn't carry must survive the + // refresh, taken from the credentials being refreshed. + assert_eq!(refreshed.email.as_deref(), Some("old@example.com")); + } + + #[test] + fn test_try_refresh_token_reuses_old_refresh_token_when_response_omits_one() { + let oauth = MockOAuthClient { + response: Some(TokenResponse { + access_token: "new-access".into(), + refresh_token: None, // server didn't rotate the refresh token + token_type: Some("Bearer".into()), + scope: None, + expires_in: Some(3600), + }), + error_msg: None, + }; + + let refreshed = try_refresh_token(&expired_creds(), &oauth, "https://auth.example.test") + .expect("mock refresh should succeed"); + + assert_eq!(refreshed.refresh_token.as_deref(), Some("expired-refresh")); + } + + #[test] + fn test_require_valid_token_expired_persists_refreshed_credentials() { + // Full wiring, including auth_url resolution — scoped to this test + // via STACKER_AUTH_URL like the file's other env-var-driven tests + // (see e.g. the XDG_CONFIG_HOME-based FileCredentialStore tests + // below), since resolve_auth_url_from() falls back to real env vars. + let _env_guard = credentials_env_lock().lock().unwrap(); + std::env::set_var("STACKER_AUTH_URL", "https://auth.example.test"); + + let (manager, store) = make_manager(); + manager.save(&expired_creds()).unwrap(); + let oauth = MockOAuthClient::success(); + + let creds = manager + .require_valid_token_with_oauth("cloud deploy", &oauth) + .expect("should transparently refresh instead of erroring"); + assert_eq!(creds.access_token, "mock-access-token"); + + // The refreshed token must be persisted, not just returned in-memory, + // so the next command doesn't have to refresh again. + let persisted = store.load().unwrap().unwrap(); + assert_eq!(persisted.access_token, "mock-access-token"); + + std::env::remove_var("STACKER_AUTH_URL"); + } + + #[test] + fn test_require_valid_token_expired_falls_back_when_refresh_fails() { + let (manager, _) = make_manager(); + manager.save(&expired_creds()).unwrap(); + let oauth = MockOAuthClient::failure("refresh token invalid or expired"); + + let err = manager + .require_valid_token_with_oauth("cloud deploy", &oauth) + .unwrap_err(); + + assert!(matches!(err, CliError::TokenExpired)); + } + + #[test] + fn test_require_valid_token_expired_without_refresh_token_skips_refresh_attempt() { + let (manager, _) = make_manager(); + manager.save(&StoredCredentials { + refresh_token: None, + ..expired_creds() + }).unwrap(); + // A mock that would succeed if called — proves it's never called + // when there's no refresh_token to use. + let oauth = MockOAuthClient::success(); + + let err = manager + .require_valid_token_with_oauth("cloud deploy", &oauth) + .unwrap_err(); + + assert!(matches!(err, CliError::TokenExpired)); + } + #[test] fn test_bearer_header_format() { let (manager, _) = make_manager(); @@ -1038,6 +1310,19 @@ mod tests { )), } } + + fn refresh_token( + &self, + _auth_url: &str, + _refresh_token: &str, + ) -> Result { + match &self.response { + Some(resp) => Ok(resp.clone()), + None => Err(CliError::AuthFailed( + self.error_msg.clone().unwrap_or_default(), + )), + } + } } #[test] diff --git a/src/cli/generator/compose.rs b/src/cli/generator/compose.rs index 44fe2a20..8fde6364 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -4,7 +4,7 @@ use std::fmt; use std::path::Path; use crate::cli::config_parser::{ - AppType, ComposeHealthcheck, DomainConfig, ProxyType, ServiceDefinition, StackerConfig, + AppType, ComposeHealthcheck, DomainConfig, ProxyType, ServiceDefinition, SslMode, StackerConfig, }; use crate::cli::error::CliError; @@ -151,16 +151,32 @@ impl TryFrom<&StackerConfig> for ComposeDefinition { // --- Proxy service --- if let Some(proxy_svc) = build_proxy_service(config) { + // Collect named volumes (e.g. Caddy's `caddy_data`/`caddy_config` + // persistence volumes) — without this they'd be referenced by + // the service but never declared under top-level `volumes:`, + // the same class of bug fixed for the server-side renderer in + // GH #236. + for vol in &proxy_svc.volumes { + if let Some(named) = extract_named_volume(vol) { + if !named_volumes.contains(&named) { + named_volumes.push(named); + } + } + } compose.services.push(proxy_svc); } // --- Set top-level volumes --- compose.volumes = named_volumes; - // --- Auto-inject default_network for NginxProxyManager-proxied services --- - if config.proxy.proxy_type == ProxyType::NginxProxyManager - && !config.proxy.domains.is_empty() - { + // --- Auto-inject the shared external `default_network` onto proxied + // services --- + // Every proxy type is platform-managed: on remote deploys the proxy + // container is stripped from this compose (see build_config_bundle) + // and installed by its own backend role, which joins the external + // `default_network`. So the proxied app service(s) must also sit on + // `default_network` for the managed proxy to reach them by name/label. + if config.proxy.proxy_type != ProxyType::None && !config.proxy.domains.is_empty() { let proxied: Vec = config .proxy .domains @@ -184,6 +200,62 @@ impl TryFrom<&StackerConfig> for ComposeDefinition { } } + // --- Traefik: derive router/service labels from config.proxy.domains --- + // Traefik routes via labels on the *target* container (unlike Nginx/Caddy, + // which route via a separate config file), so `build_proxy_service` + // alone can't wire up routing — it only starts the Traefik container. + if config.proxy.proxy_type == ProxyType::Traefik { + let has_acme = admin_email_from_config(config).is_some(); + for domain in &config.proxy.domains { + let Some(target_name) = upstream_service_name_from_domain(domain) else { + continue; + }; + let Some(port) = upstream_port_from_domain(domain) else { + continue; + }; + let Some(svc) = compose.services.iter_mut().find(|s| s.name == target_name) else { + continue; + }; + + let router = traefik_router_name(&domain.domain); + svc.labels + .insert("traefik.enable".to_string(), "true".to_string()); + svc.labels.insert( + format!("traefik.http.routers.{router}.rule"), + format!("Host(`{}`)", domain.domain), + ); + svc.labels.insert( + format!("traefik.http.services.{router}.loadbalancer.server.port"), + port.to_string(), + ); + + match domain.ssl { + SslMode::Off => { + svc.labels.insert( + format!("traefik.http.routers.{router}.entrypoints"), + "web".to_string(), + ); + } + SslMode::Auto | SslMode::Manual => { + svc.labels.insert( + format!("traefik.http.routers.{router}.entrypoints"), + "websecure".to_string(), + ); + svc.labels.insert( + format!("traefik.http.routers.{router}.tls"), + "true".to_string(), + ); + if domain.ssl == SslMode::Auto && has_acme { + svc.labels.insert( + format!("traefik.http.routers.{router}.tls.certresolver"), + "letsencrypt".to_string(), + ); + } + } + } + } + } + Ok(compose) } } @@ -307,6 +379,14 @@ fn build_proxy_service(config: &StackerConfig) -> Option { }; svc.volumes .push("./nginx/conf.d:/etc/nginx/conf.d:ro".to_string()); + crate::helpers::stacker_labels::insert_runtime_labels( + &mut svc.labels, + None::, + None, + crate::helpers::stacker_labels::SCOPE_PLATFORM, + "nginx", + "nginx", + ); Some(svc) } ProxyType::NginxProxyManager => { @@ -341,6 +421,69 @@ fn build_proxy_service(config: &StackerConfig) -> Option { }; svc.volumes .push("/var/run/docker.sock:/var/run/docker.sock:ro".to_string()); + svc.volumes.push("traefik_certs:/letsencrypt".to_string()); + + // Static config passed via CLI flags (Traefik supports this in + // place of a traefik.yml file). `exposedbydefault=false` means + // only services carrying `traefik.enable=true` labels — the + // ones generated below from `config.proxy.domains` — get a + // router; nothing else on the Docker network is exposed. + let mut args = vec![ + "--providers.docker=true".to_string(), + "--providers.docker.exposedbydefault=false".to_string(), + "--entrypoints.web.address=:80".to_string(), + "--entrypoints.websecure.address=:443".to_string(), + ]; + if let Some(email) = admin_email_from_config(config) { + args.push( + "--certificatesresolvers.letsencrypt.acme.httpchallenge=true".to_string(), + ); + args.push( + "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" + .to_string(), + ); + args.push(format!( + "--certificatesresolvers.letsencrypt.acme.email={}", + email + )); + args.push( + "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + .to_string(), + ); + } + svc.command = Some(args.join(" ")); + crate::helpers::stacker_labels::insert_runtime_labels( + &mut svc.labels, + None::, + None, + crate::helpers::stacker_labels::SCOPE_PLATFORM, + "traefik", + "traefik", + ); + Some(svc) + } + ProxyType::Caddy => { + let mut svc = ComposeService { + name: "caddy".to_string(), + image: Some("caddy:2-alpine".to_string()), + ports: vec!["80:80".to_string(), "443:443".to_string()], + depends_on: vec!["app".to_string()], + ..Default::default() + }; + svc.volumes + .push("./Caddyfile:/etc/caddy/Caddyfile:ro".to_string()); + // Named volumes so Caddy's ACME state/certificates survive + // container recreation instead of re-issuing on every restart. + svc.volumes.push("caddy_data:/data".to_string()); + svc.volumes.push("caddy_config:/config".to_string()); + crate::helpers::stacker_labels::insert_runtime_labels( + &mut svc.labels, + None::, + None, + crate::helpers::stacker_labels::SCOPE_PLATFORM, + "caddy", + "caddy", + ); Some(svc) } ProxyType::None => None, @@ -362,6 +505,49 @@ fn upstream_service_name_from_domain(domain: &DomainConfig) -> Option { } } +/// Extract the port from a DomainConfig upstream like `svc:3000` or `http://svc:3000`. +fn upstream_port_from_domain(domain: &DomainConfig) -> Option { + let s = domain + .upstream + .trim_start_matches("https://") + .trim_start_matches("http://"); + let host = s.split('/').next()?; + host.split(':').nth(1)?.parse().ok() +} + +/// Admin email for ACME/Let's Encrypt registration, sourced from +/// `install.inputs.admin_email` (a free-form marketplace-style install +/// input, not a dedicated config field). Absent this, Traefik still +/// terminates TLS (its own self-signed default cert) but without an ACME +/// certresolver, since Traefik's ACME setup hard-requires a non-empty email. +fn admin_email_from_config(config: &StackerConfig) -> Option { + config + .install + .inputs + .get("admin_email") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .map(str::to_string) +} + +/// Sanitize a domain into a Traefik router/service name: alphanumerics only, +/// everything else collapsed to `-` (router names must not contain `.` +/// reliably across all label-parsing edge cases, so we normalize). +fn traefik_router_name(domain: &str) -> String { + let mut out = String::new(); + let mut prev_dash = false; + for ch in domain.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + prev_dash = false; + } else if !prev_dash { + out.push('-'); + prev_dash = true; + } + } + out.trim_matches('-').to_string() +} + /// Extract a named volume from a volume string like "my-data:/var/lib/data". /// Returns `None` for bind mounts (starting with `.` or `/`). fn extract_named_volume(vol_str: &str) -> Option { @@ -798,6 +984,230 @@ services: assert_eq!(traefik.unwrap().image.as_deref(), Some("traefik:v2.10")); } + #[test] + fn test_compose_caddy_proxy() { + let config = ConfigBuilder::new() + .name("caddy-app") + .app_type(AppType::Python) + .proxy(ProxyConfig { + proxy_type: ProxyType::Caddy, + auto_detect: true, + domains: Vec::new(), + config: None, + }) + .build() + .unwrap(); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let caddy = compose + .services + .iter() + .find(|s| s.name == "caddy") + .expect("caddy service should be present"); + assert_eq!(caddy.image.as_deref(), Some("caddy:2-alpine")); + assert!(caddy.ports.contains(&"80:80".to_string())); + assert!(caddy.ports.contains(&"443:443".to_string())); + assert!(caddy.depends_on.contains(&"app".to_string())); + assert!(caddy + .volumes + .contains(&"./Caddyfile:/etc/caddy/Caddyfile:ro".to_string())); + } + + // Regression test: the proxy service's named volumes (Caddy's + // `caddy_data`/`caddy_config`) must be declared under top-level + // `volumes:`, the same class of bug fixed for the server-side renderer + // in GH #236 — a service referencing a named volume that's never + // declared produces an invalid compose file. + #[test] + fn test_compose_caddy_named_volumes_declared_at_top_level() { + let config = ConfigBuilder::new() + .name("caddy-app") + .app_type(AppType::Python) + .proxy(ProxyConfig { + proxy_type: ProxyType::Caddy, + auto_detect: true, + domains: Vec::new(), + config: None, + }) + .build() + .unwrap(); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + assert!(compose.volumes.contains(&"caddy_data".to_string())); + assert!(compose.volumes.contains(&"caddy_config".to_string())); + + let yaml = compose.render(); + let doc: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); + let top_level_volumes = doc + .get("volumes") + .and_then(|v| v.as_mapping()) + .cloned() + .unwrap_or_default(); + assert!(top_level_volumes.contains_key(serde_yaml::Value::String("caddy_data".to_string()))); + assert!( + top_level_volumes.contains_key(serde_yaml::Value::String("caddy_config".to_string())) + ); + } + + // Regression tests: Traefik was previously a container-only stub — no + // labels were ever generated from `config.proxy.domains`, so it never + // actually routed anything (see GH discussion following #237). These + // cover the label-injection logic added to close that gap. + + fn traefik_config_with_domain(ssl: SslMode) -> StackerConfig { + ConfigBuilder::new() + .name("traefik-app") + .app_type(AppType::Node) + .proxy(ProxyConfig { + proxy_type: ProxyType::Traefik, + auto_detect: true, + domains: vec![DomainConfig { + domain: "app.example.com".to_string(), + ssl, + upstream: "app:3000".to_string(), + }], + config: None, + }) + .build() + .unwrap() + } + + #[test] + fn test_compose_traefik_labels_route_to_upstream_service() { + let config = traefik_config_with_domain(SslMode::Off); + let compose = ComposeDefinition::try_from(&config).unwrap(); + let app = compose.services.iter().find(|s| s.name == "app").unwrap(); + + assert_eq!( + app.labels.get("traefik.enable").map(String::as_str), + Some("true") + ); + assert_eq!( + app.labels + .get("traefik.http.routers.app-example-com.rule") + .map(String::as_str), + Some("Host(`app.example.com`)") + ); + assert_eq!( + app.labels + .get("traefik.http.services.app-example-com.loadbalancer.server.port") + .map(String::as_str), + Some("3000") + ); + assert_eq!( + app.labels + .get("traefik.http.routers.app-example-com.entrypoints") + .map(String::as_str), + Some("web"), + "SslMode::Off should route through the plain web entrypoint, no TLS label" + ); + assert!(!app + .labels + .contains_key("traefik.http.routers.app-example-com.tls")); + } + + #[test] + fn test_compose_traefik_labels_enable_tls_without_certresolver_when_no_admin_email() { + let config = traefik_config_with_domain(SslMode::Auto); + let compose = ComposeDefinition::try_from(&config).unwrap(); + let app = compose.services.iter().find(|s| s.name == "app").unwrap(); + + assert_eq!( + app.labels + .get("traefik.http.routers.app-example-com.entrypoints") + .map(String::as_str), + Some("websecure") + ); + assert_eq!( + app.labels + .get("traefik.http.routers.app-example-com.tls") + .map(String::as_str), + Some("true") + ); + assert!( + !app.labels + .contains_key("traefik.http.routers.app-example-com.tls.certresolver"), + "no admin_email configured, so no ACME certresolver should be wired up" + ); + } + + #[test] + fn test_compose_traefik_labels_use_acme_certresolver_when_admin_email_present() { + let mut config = traefik_config_with_domain(SslMode::Auto); + config.install.inputs.insert( + "admin_email".to_string(), + serde_json::json!("ops@example.com"), + ); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let app = compose.services.iter().find(|s| s.name == "app").unwrap(); + assert_eq!( + app.labels + .get("traefik.http.routers.app-example-com.tls.certresolver") + .map(String::as_str), + Some("letsencrypt") + ); + + let traefik = compose + .services + .iter() + .find(|s| s.name == "traefik") + .unwrap(); + let command = traefik.command.as_deref().unwrap_or_default(); + assert!(command.contains("--certificatesresolvers.letsencrypt.acme.email=ops@example.com")); + assert!(command.contains("--certificatesresolvers.letsencrypt.acme.httpchallenge=true")); + } + + #[test] + fn test_compose_traefik_static_config_and_certs_volume() { + let config = traefik_config_with_domain(SslMode::Off); + let compose = ComposeDefinition::try_from(&config).unwrap(); + let traefik = compose + .services + .iter() + .find(|s| s.name == "traefik") + .unwrap(); + + let command = traefik.command.as_deref().unwrap_or_default(); + assert!(command.contains("--providers.docker=true")); + assert!(command.contains("--providers.docker.exposedbydefault=false")); + assert!(command.contains("--entrypoints.web.address=:80")); + assert!(command.contains("--entrypoints.websecure.address=:443")); + + assert!(compose.volumes.contains(&"traefik_certs".to_string())); + let yaml = compose.render(); + let doc: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); + assert!(doc["volumes"] + .as_mapping() + .unwrap() + .contains_key(serde_yaml::Value::String("traefik_certs".to_string()))); + } + + #[test] + fn test_compose_traefik_skips_domain_with_unresolvable_upstream_service() { + let config = ConfigBuilder::new() + .name("traefik-app") + .app_type(AppType::Node) + .proxy(ProxyConfig { + proxy_type: ProxyType::Traefik, + auto_detect: true, + domains: vec![DomainConfig { + domain: "ghost.example.com".to_string(), + ssl: SslMode::Off, + upstream: "nonexistent-service:9999".to_string(), + }], + config: None, + }) + .build() + .unwrap(); + + // Must not panic — an upstream naming a service that doesn't exist + // in the compose is silently skipped rather than erroring the build. + let compose = ComposeDefinition::try_from(&config).unwrap(); + let app = compose.services.iter().find(|s| s.name == "app").unwrap(); + assert!(!app.labels.contains_key("traefik.enable")); + } + #[test] fn test_compose_render_omits_obsolete_version() { let config = minimal_config(AppType::Static); @@ -1196,7 +1606,52 @@ services: } #[test] - fn non_npm_proxy_does_not_inject_default_network() { + fn every_proxy_type_labels_its_service_platform_scoped() { + // The synthesized proxy for every proxy type must carry + // `my.stacker.scope: platform` so the remote-bundle strip removes it + // and the backend role becomes its sole owner (no double-deploy). + for (proxy_type, service_name) in [ + (ProxyType::Nginx, "nginx"), + (ProxyType::NginxProxyManager, "proxy-manager"), + (ProxyType::Traefik, "traefik"), + (ProxyType::Caddy, "caddy"), + ] { + let config = ConfigBuilder::new() + .name("proxy-scope-app") + .app_type(AppType::Node) + .proxy(ProxyConfig { + proxy_type, + auto_detect: false, + domains: vec![], + config: None, + }) + .build() + .unwrap(); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let proxy = compose + .services + .iter() + .find(|svc| svc.name == service_name) + .unwrap_or_else(|| panic!("{service_name} proxy service should be present")); + assert_eq!( + proxy + .labels + .get(crate::helpers::stacker_labels::SCOPE) + .map(String::as_str), + Some(crate::helpers::stacker_labels::SCOPE_PLATFORM), + "{service_name} proxy must be platform-scoped" + ); + } + } + + #[test] + fn non_npm_proxy_injects_default_network_onto_proxied_service() { + // Every proxy type is platform-managed: on remote deploys the proxy + // container is stripped from this compose and installed by its own + // backend role, which joins the external `default_network`. So the + // proxied service must also join `default_network` for the managed + // proxy to reach it. (Previously only NginxProxyManager did this.) let svc = ServiceDefinition { name: "web".into(), image: "nginx:latest".into(), @@ -1226,8 +1681,19 @@ services: let compose = ComposeDefinition::try_from(&config).unwrap(); assert!( - compose.external_networks.is_empty(), - "Traefik proxy should not inject default_network" + compose + .external_networks + .contains(&"default_network".to_string()), + "Traefik proxy should inject the external default_network" + ); + let web = compose + .services + .iter() + .find(|svc| svc.name == "web") + .expect("proxied 'web' service present"); + assert!( + web.networks.contains(&"default_network".to_string()), + "the proxied service should join default_network" ); } diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs index e67d6501..4a991a58 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -469,10 +469,11 @@ fn extract_port_from_docker_ps_entry(spec: &str) -> Option { /// Ask Docker for the host ports currently bound by THIS compose project's containers. /// -/// Uses `docker compose -f ps --format "{{.Ports}}"`. +/// Uses `docker compose -p -f ps --format "{{.Ports}}"`. /// Returns an empty set if Docker is unavailable or the project has no running containers. fn get_own_compose_running_ports( compose_path: &Path, + project_name: &str, executor: &dyn CommandExecutor, ) -> std::collections::HashSet { let compose_str = compose_path.to_string_lossy(); @@ -480,6 +481,8 @@ fn get_own_compose_running_ports( "docker", &[ "compose", + "-p", + project_name, "-f", &compose_str, "ps", @@ -638,6 +641,7 @@ fn format_preflight_port_conflicts(target: &str, conflicts: &[String]) -> String /// Docker access still work. fn check_local_host_port_conflicts( compose_path: &Path, + project_name: &str, executor: &dyn CommandExecutor, ) -> Vec { use std::net::TcpListener; @@ -662,7 +666,7 @@ fn check_local_host_port_conflicts( // Exclude ports that belong to OUR own currently-running project containers — // docker compose up will stop-and-restart them without a conflict. - let own_ports = get_own_compose_running_ports(compose_path, executor); + let own_ports = get_own_compose_running_ports(compose_path, project_name, executor); occupied .into_iter() @@ -691,6 +695,23 @@ fn resolve_compose_cmd(executor: &dyn CommandExecutor) -> (&'static str, Vec<&'s ("docker-compose", vec![]) } +/// Compose project name for local deploys, derived from the project's own +/// identity rather than left to Compose's default (the containing +/// directory's basename). Every project's generated compose file lives +/// under `.stacker/`, so without an explicit name every project defaulted +/// to the same Compose project ("stacker") — deploying one project locally +/// would recreate/destroy another project's containers, and `down` would +/// report unrelated projects' containers as orphans of the "stacker" +/// project. See GH issue #235. +pub fn local_compose_project_name(config: &StackerConfig) -> String { + let identity = config + .project + .identity + .clone() + .unwrap_or_else(|| config.name.clone()); + sanitize_stack_code(&identity) +} + pub struct LocalDeploy; impl DeployStrategy for LocalDeploy { @@ -721,10 +742,12 @@ impl DeployStrategy for LocalDeploy { } let compose_path = context.compose_path.to_string_lossy().to_string(); + let project_name = local_compose_project_name(config); // Pre-flight: catch host port conflicts before docker compose up so the // error is actionable rather than buried in Docker daemon output. - let port_conflicts = check_local_host_port_conflicts(&context.compose_path, executor); + let port_conflicts = + check_local_host_port_conflicts(&context.compose_path, &project_name, executor); if !port_conflicts.is_empty() { return Err(CliError::DeployFailed { target: DeployTarget::Local, @@ -738,6 +761,9 @@ impl DeployStrategy for LocalDeploy { let (cmd, base_args) = resolve_compose_cmd(executor); let mut args: Vec = base_args.iter().map(|s| s.to_string()).collect(); + args.push("-p".into()); + args.push(project_name); + if let Some(ref env_file) = config.env_file { let env_file_path = if env_file.is_absolute() { env_file.clone() @@ -791,6 +817,9 @@ impl DeployStrategy for LocalDeploy { let (cmd, base_args) = resolve_compose_cmd(executor); let mut args: Vec = base_args.iter().map(|s| s.to_string()).collect(); + args.push("-p".into()); + args.push(local_compose_project_name(config)); + if let Some(ref env_file) = config.env_file { let env_file_path = if env_file.is_absolute() { env_file.clone() @@ -1051,6 +1080,7 @@ impl DeployStrategy for CloudDeploy { config, &context.compose_path, )?; + stacker_client::require_app_image_for_remote_deploy(&project_config)?; let mut project_body = stacker_client::build_project_body(&project_config); if let Some(bundle) = &context.config_bundle { stacker_client::attach_config_bundle_to_project_body( @@ -1337,6 +1367,46 @@ impl DeployStrategy for CloudDeploy { } } + // Pre-flight: reject a server type Hetzner can't create in the chosen + // region BEFORE the Terraform container runs, so we fail fast with a + // clear message instead of a swallowed "unsupported location" Terraform + // error that surfaces only as a paused deploy. Mirrors the server-side + // guard in the deploy route — both call the shared connector. + if let Some(cloud_cfg) = &config.deploy.cloud { + if matches!( + cloud_cfg.provider, + crate::cli::config_parser::CloudProvider::Hetzner + ) { + if let Some(server_type) = + cloud_cfg.size.as_deref().filter(|s| !s.trim().is_empty()) + { + if let Some(token) = first_non_empty_env(cloud_env::token_env_vars("htz")) { + let base_url = crate::connectors::hetzner::api_base_url(); + let region = cloud_cfg.region.clone(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!("Failed to initialize async runtime: {}", e), + })?; + rt.block_on( + crate::connectors::hetzner::validate_server_type_availability( + &base_url, + &token, + server_type, + region.as_deref(), + ), + ) + .map_err(|reason| CliError::DeployFailed { + target: DeployTarget::Cloud, + reason, + })?; + } + } + } + } + let action = if context.dry_run { InstallAction::Plan } else { @@ -2222,6 +2292,46 @@ fn deploy_to_intranet_server( }); } + // 1b. Pre-flight: verify Docker is installed on the remote server. + // Fail fast with an actionable message instead of letting docker compose + // fail later with a cryptic error. + { + let docker_check = std::process::Command::new("ssh") + .args(&ssh_args) + .arg(&user_at_host) + .arg("docker --version 2>/dev/null && docker compose version 2>/dev/null") + .output() + .map_err(|e| CliError::DeployFailed { + target: DeployTarget::Server, + reason: format!("Failed to run ssh: {}", e), + })?; + + let docker_ok = docker_check.status.success() + && !String::from_utf8_lossy(&docker_check.stdout) + .trim() + .is_empty(); + + if !docker_ok { + return Err(CliError::DeployFailed { + target: DeployTarget::Server, + reason: format!( + "Docker is not installed on {}.\n\ + \n\ + Install Docker and Docker Compose on the server, then retry:\n\ + \n\ + ssh -i {} -p {} {} 'curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker {}'\n\ + \n\ + After installing, log out and back in (or run `newgrp docker`), then retry the deploy.", + server_cfg.host, + ssh_key_path.display(), + server_cfg.port, + user_at_host, + server_cfg.user, + ), + }); + } + } + // 2. Sync project files to remote (rsync preferred, tar+ssh fallback) let project_src = format!("{}/", context.project_dir.display()); let remote_dest = format!("{}:{}/", user_at_host, remote_dir_abs); @@ -2478,6 +2588,7 @@ impl DeployStrategy for ServerDeploy { config, &context.compose_path, )?; + stacker_client::require_app_image_for_remote_deploy(&project_config)?; let mut project_body = stacker_client::build_project_body(&project_config); if let Some(bundle) = &context.config_bundle { stacker_client::attach_config_bundle_to_project_body(&mut project_body, bundle); @@ -2720,7 +2831,7 @@ fn resolve_ssh_key_path_with_home(path: &Path, home_dir: Option<&Path>) -> PathB path.to_path_buf() } -fn resolve_ssh_key_path(path: &Path) -> PathBuf { +pub(crate) fn resolve_ssh_key_path(path: &Path) -> PathBuf { let home_dir = std::env::var_os("HOME").map(PathBuf::from); resolve_ssh_key_path_with_home(path, home_dir.as_deref()) } @@ -3345,6 +3456,72 @@ mod tests { assert!(args.contains(&"--build".to_string())); } + // Regression test for GH issue #235: without an explicit `-p`, Compose + // derives the project name from the compose file's containing directory + // — since every project's compose lives under `.stacker/`, every + // project defaulted to the same Compose project ("stacker"), so + // deploying project B would recreate/destroy project A's containers. + #[test] + fn test_local_deploy_namespaces_compose_project_by_identity() { + let config = ConfigBuilder::new().name("Miniflux Prod").build().unwrap(); + let context = sample_context(false); + let executor = MockExecutor::success(); + let strategy = LocalDeploy; + + strategy.deploy(&config, &context, &executor).unwrap(); + + let args = executor.last_args(); + let p_index = args + .iter() + .position(|a| a == "-p") + .expect("docker compose up should pass -p "); + assert_eq!( + args.get(p_index + 1).map(String::as_str), + Some("miniflux-prod"), + "project name should be derived from stacker.yml's name/identity, not the compose \ + file's directory, got args: {:?}", + args + ); + } + + #[test] + fn test_local_deploy_uses_project_identity_over_name_for_project_name() { + let config = ConfigBuilder::new() + .name("stacker") // matches the old universal default — must not collide + .project_identity("miniflux-blue") + .build() + .unwrap(); + let context = sample_context(false); + let executor = MockExecutor::success(); + let strategy = LocalDeploy; + + strategy.deploy(&config, &context, &executor).unwrap(); + + let args = executor.last_args(); + let p_index = args.iter().position(|a| a == "-p").unwrap(); + assert_eq!( + args.get(p_index + 1).map(String::as_str), + Some("miniflux-blue") + ); + } + + #[test] + fn test_local_destroy_uses_same_project_name_as_deploy() { + let config = ConfigBuilder::new().name("ntfy").build().unwrap(); + let context = sample_context(false); + let executor = MockExecutor::success(); + let strategy = LocalDeploy; + + strategy.destroy(&config, &context, &executor).unwrap(); + + let args = executor.last_args(); + let p_index = args + .iter() + .position(|a| a == "-p") + .expect("docker compose down should pass -p "); + assert_eq!(args.get(p_index + 1).map(String::as_str), Some("ntfy")); + } + #[test] fn test_local_deploy_failure() { let config = ConfigBuilder::new().name("local-app").build().unwrap(); @@ -3778,7 +3955,7 @@ services: .unwrap(); let executor = MockExecutor::success(); - let conflicts = check_local_host_port_conflicts(tmp.path(), &executor); + let conflicts = check_local_host_port_conflicts(tmp.path(), "myproject", &executor); assert!( conflicts.is_empty(), "expected no conflicts for free port {}: {:?}", @@ -3808,7 +3985,7 @@ services: let ps_output = format!("0.0.0.0:{}->80/tcp", port); let executor = MockExecutor::success_with_stdout(&ps_output); - let conflicts = check_local_host_port_conflicts(tmp.path(), &executor); + let conflicts = check_local_host_port_conflicts(tmp.path(), "myproject", &executor); drop(listener); assert!( conflicts.is_empty(), @@ -3837,7 +4014,7 @@ services: // Simulate `docker compose ps` returning empty (no own containers on this port) let executor = MockExecutor::success_with_stdout(""); - let conflicts = check_local_host_port_conflicts(tmp.path(), &executor); + let conflicts = check_local_host_port_conflicts(tmp.path(), "myproject", &executor); drop(listener); assert!( !conflicts.is_empty(), diff --git a/src/cli/local_compose.rs b/src/cli/local_compose.rs index 5c6f0de7..afa7afb7 100644 --- a/src/cli/local_compose.rs +++ b/src/cli/local_compose.rs @@ -7,6 +7,29 @@ use crate::cli::error::CliError; const OUTPUT_DIR: &str = ".stacker"; const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; +/// Resolve the Compose project name for a local project, the same way +/// `LocalDeploy` does: from `stacker.yml`'s `project.identity`/`name`, +/// sanitized (see `install_runner::local_compose_project_name`). Every +/// caller that runs `docker compose` against a project's `.stacker/` +/// compose file (deploy, destroy, status, ...) must pass this via `-p` — +/// without it, Compose falls back to the compose file's containing +/// directory basename, which is `.stacker` for every project, so every +/// project defaults to the same shared scope ("stacker"). Operating on +/// that shared scope from one project's directory can recreate, remove, or +/// misreport another, unrelated project's containers. See GH issue #235. +/// +/// Falls back to the literal "stacker" only when `stacker.yml` is +/// missing/unparseable (e.g. deleted after a deploy) — there's no project +/// identity left to recover in that case, so this is a best-effort +/// default, not a guarantee of isolation. +pub fn resolve_local_compose_project_name(project_dir: &Path) -> String { + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + StackerConfig::from_file(&config_path) + .ok() + .map(|config| crate::cli::install_runner::local_compose_project_name(&config)) + .unwrap_or_else(|| "stacker".to_string()) +} + pub fn resolve_local_compose_path(project_dir: &Path) -> Result { let generated = project_dir.join(OUTPUT_DIR).join("docker-compose.yml"); let config_path = project_dir.join(DEFAULT_CONFIG_FILE); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 369cfd1d..a0eab010 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -28,6 +28,7 @@ pub mod local_compose; pub mod local_pipe_store; pub mod ml_field_matcher; pub mod notify; +pub mod pipe_apply; pub mod progress; pub mod proxy_manager; pub mod runtime; diff --git a/src/cli/notify.rs b/src/cli/notify.rs index 43ea38ca..eb24a52a 100644 --- a/src/cli/notify.rs +++ b/src/cli/notify.rs @@ -32,6 +32,15 @@ pub fn deploy_notify(success: bool, project_name: &str) { eprint!("\x07"); } +/// Fire an arbitrary terminal + desktop notification (used by `stacker monitor` +/// with a `target: { terminal: true }` alert). Prints the message to stderr, +/// rings the terminal bell, and shows an OS notification where available. +pub fn notify_message(title: &str, body: &str) { + eprintln!("🔔 {title}: {body}"); + os_notify(title, body, "Submarine"); + eprint!("\x07"); +} + // ── Platform-specific helpers ────────────────────────── #[cfg(target_os = "macos")] diff --git a/src/cli/pipe_apply.rs b/src/cli/pipe_apply.rs new file mode 100644 index 00000000..8e27e79c --- /dev/null +++ b/src/cli/pipe_apply.rs @@ -0,0 +1,268 @@ +//! Declarative pipe reconciliation — the pure core behind `stacker pipe diff` +//! and `stacker pipe apply` (§5 / #1 of the PIPE IaC plan). +//! +//! Compares the pipes declared in `stacker.yml` (`config.pipes`) against what is +//! deployed, keyed by pipe **name**. This module is deliberately I/O-free so the +//! reconcile logic is unit-testable; the command layer supplies the deployed +//! view (built from the API's pipe templates) and performs create/update. + +use crate::cli::config_parser::PipeSpec; + +/// A minimal, comparable view of a deployed pipe (built by the command layer +/// from `PipeTemplateInfo`). Endpoints are normalized to "METHOD /path". +#[derive(Debug, Clone, PartialEq)] +pub struct DeployedPipe { + pub name: String, + pub source_app: String, + pub target_app: String, + pub source_endpoint: String, + pub target_endpoint: String, +} + +/// What a reconcile would do to one pipe. +#[derive(Debug, Clone, PartialEq)] +pub enum PipeAction { + /// Declared but not deployed → would be created. + Create, + /// Declared and deployed but one or more compared fields differ. + Update { changes: Vec }, + /// Declared and deployed, identical. + Unchanged, + /// Deployed but not declared → orphan (candidate for `--prune`). + Orphan, +} + +/// One entry in the reconcile plan. +#[derive(Debug, Clone, PartialEq)] +pub struct PipeDiffEntry { + pub name: String, + pub action: PipeAction, +} + +/// Normalize a "METHOD /path" (or bare "/path" → GET) endpoint for comparison: +/// uppercased method + trimmed path. +pub fn normalize_endpoint(spec: &str) -> String { + let trimmed = spec.trim(); + let mut parts = trimmed.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or("").trim(); + match parts.next().map(str::trim) { + Some(path) if !path.is_empty() => format!("{} {}", first.to_ascii_uppercase(), path), + _ => format!("GET {}", first), + } +} + +/// Compute the reconcile plan: for each declared pipe, whether it would be +/// created / updated / left unchanged; plus any deployed pipe not declared +/// (orphan). Deterministic ordering: declared pipes first (in declaration +/// order), then orphans (sorted by name). +pub fn diff_pipes(specs: &[PipeSpec], deployed: &[DeployedPipe]) -> Vec { + let mut plan = Vec::new(); + + for spec in specs { + let entry = match deployed.iter().find(|d| d.name == spec.name) { + None => PipeDiffEntry { + name: spec.name.clone(), + action: PipeAction::Create, + }, + Some(dep) => { + let mut changes = Vec::new(); + if dep.source_app != spec.source { + changes.push(format!("source: {} → {}", dep.source_app, spec.source)); + } + if dep.target_app != spec.target { + changes.push(format!("target: {} → {}", dep.target_app, spec.target)); + } + let want_src = normalize_endpoint(&spec.source_endpoint); + if normalize_endpoint(&dep.source_endpoint) != want_src { + changes.push(format!( + "source_endpoint: {} → {}", + dep.source_endpoint, want_src + )); + } + let want_tgt = normalize_endpoint(&spec.target_endpoint); + if normalize_endpoint(&dep.target_endpoint) != want_tgt { + changes.push(format!( + "target_endpoint: {} → {}", + dep.target_endpoint, want_tgt + )); + } + PipeDiffEntry { + name: spec.name.clone(), + action: if changes.is_empty() { + PipeAction::Unchanged + } else { + PipeAction::Update { changes } + }, + } + } + }; + plan.push(entry); + } + + // Orphans: deployed but not declared. + let declared: std::collections::HashSet<&str> = + specs.iter().map(|s| s.name.as_str()).collect(); + let mut orphans: Vec<&DeployedPipe> = deployed + .iter() + .filter(|d| !declared.contains(d.name.as_str())) + .collect(); + orphans.sort_by(|a, b| a.name.cmp(&b.name)); + for dep in orphans { + plan.push(PipeDiffEntry { + name: dep.name.clone(), + action: PipeAction::Orphan, + }); + } + + plan +} + +/// True when the plan has no create/update/orphan work (everything matches). +pub fn plan_is_clean(plan: &[PipeDiffEntry]) -> bool { + plan.iter() + .all(|e| matches!(e.action, PipeAction::Unchanged)) +} + +/// Parse a "METHOD /path" (or bare "/path" → GET) endpoint into the template +/// JSON shape the API expects (`{"method","path"}`). +pub fn endpoint_to_json(spec: &str) -> serde_json::Value { + let norm = normalize_endpoint(spec); + let mut parts = norm.splitn(2, ' '); + let method = parts.next().unwrap_or("GET"); + let path = parts.next().unwrap_or("/"); + serde_json::json!({ "method": method, "path": path }) +} + +/// Deterministic field mapping for a declared pipe: each target field draws from +/// a same-named source field, else the positionally-aligned source field, else +/// itself. Empty target → empty (pass-through) mapping. (Same rule as the +/// imperative `pipe create --manual` path.) +pub fn field_mapping_for(src: &[String], tgt: &[String]) -> serde_json::Value { + let mut mapping = serde_json::Map::new(); + for (idx, target_field) in tgt.iter().enumerate() { + let source_ref = if src.iter().any(|s| s == target_field) { + target_field.clone() + } else if let Some(s) = src.get(idx) { + s.clone() + } else { + target_field.clone() + }; + mapping.insert( + target_field.clone(), + serde_json::Value::String(format!("$.{source_ref}")), + ); + } + serde_json::Value::Object(mapping) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec(name: &str, src: &str, tgt: &str, se: &str, te: &str) -> PipeSpec { + PipeSpec { + name: name.into(), + source: src.into(), + target: tgt.into(), + source_endpoint: se.into(), + target_endpoint: te.into(), + source_fields: vec![], + target_fields: vec![], + trigger: "manual".into(), + poll_interval: None, + retry: None, + retry_backoff_ms: None, + retry_backoff_max_ms: None, + on_failure: None, + on_success: None, + } + } + + fn dep(name: &str, src: &str, tgt: &str, se: &str, te: &str) -> DeployedPipe { + DeployedPipe { + name: name.into(), + source_app: src.into(), + target_app: tgt.into(), + source_endpoint: se.into(), + target_endpoint: te.into(), + } + } + + #[test] + fn normalize_endpoint_uppercases_and_defaults_get() { + assert_eq!(normalize_endpoint("post /x"), "POST /x"); + assert_eq!(normalize_endpoint("/x"), "GET /x"); + assert_eq!(normalize_endpoint("GET /x"), "GET /x"); + } + + #[test] + fn create_update_unchanged_orphan_are_all_detected() { + let specs = vec![ + spec("new", "a", "b", "GET /s", "POST /t"), // not deployed → create + spec("same", "a", "b", "GET /s", "POST /t"), // identical → unchanged + spec("changed", "a", "b2", "GET /s", "POST /t2"), // differs → update + ]; + let deployed = vec![ + dep("same", "a", "b", "GET /s", "POST /t"), + dep("changed", "a", "b", "GET /s", "POST /t"), // target + endpoint differ + dep("gone", "a", "b", "GET /s", "POST /t"), // not declared → orphan + ]; + + let plan = diff_pipes(&specs, &deployed); + assert_eq!(plan[0], PipeDiffEntry { name: "new".into(), action: PipeAction::Create }); + assert_eq!(plan[1].action, PipeAction::Unchanged); + match &plan[2].action { + PipeAction::Update { changes } => { + assert!(changes.iter().any(|c| c.contains("target:"))); + assert!(changes.iter().any(|c| c.contains("target_endpoint:"))); + } + other => panic!("expected update, got {other:?}"), + } + assert_eq!(plan[3], PipeDiffEntry { name: "gone".into(), action: PipeAction::Orphan }); + assert!(!plan_is_clean(&plan)); + } + + #[test] + fn clean_plan_when_all_match() { + let specs = vec![spec("p", "a", "b", "GET /s", "POST /t")]; + let deployed = vec![dep("p", "a", "b", "GET /s", "POST /t")]; + let plan = diff_pipes(&specs, &deployed); + assert!(plan_is_clean(&plan)); + } + + #[test] + fn endpoint_diff_is_method_case_insensitive() { + // spec "post /t" vs deployed "POST /t" → no change. + let specs = vec![spec("p", "a", "b", "get /s", "post /t")]; + let deployed = vec![dep("p", "a", "b", "GET /s", "POST /t")]; + assert!(plan_is_clean(&diff_pipes(&specs, &deployed))); + } + + #[test] + fn endpoint_to_json_produces_method_path() { + assert_eq!( + endpoint_to_json("post /pipetest"), + serde_json::json!({ "method": "POST", "path": "/pipetest" }) + ); + assert_eq!( + endpoint_to_json("/status"), + serde_json::json!({ "method": "GET", "path": "/status" }) + ); + } + + #[test] + fn field_mapping_for_matches_name_then_position() { + assert_eq!( + field_mapping_for(&["message".into()], &["message".into()]), + serde_json::json!({ "message": "$.message" }) + ); + assert_eq!( + field_mapping_for(&["body".into()], &["message".into()]), + serde_json::json!({ "message": "$.body" }) + ); + assert_eq!( + field_mapping_for(&["x".into()], &[]), + serde_json::json!({}) + ); + } +} diff --git a/src/cli/proxy_manager.rs b/src/cli/proxy_manager.rs index 4ab4d61d..948e54cf 100644 --- a/src/cli/proxy_manager.rs +++ b/src/cli/proxy_manager.rs @@ -150,6 +150,7 @@ const PROXY_SIGNATURES: &[(&str, ProxyType)] = &[ ("jc21/nginx-proxy-manager", ProxyType::NginxProxyManager), ("nginx-proxy-manager", ProxyType::NginxProxyManager), ("traefik", ProxyType::Traefik), + ("caddy", ProxyType::Caddy), ("nginx", ProxyType::Nginx), ]; @@ -373,6 +374,36 @@ pub fn generate_nginx_configs( Ok(configs) } +/// Generate a Caddyfile site block for a single domain configuration. +/// +/// Unlike nginx, Caddy issues/renews TLS certificates itself (automatic +/// HTTPS) — no separate 80→443 redirect block or certbot paths to manage. +/// `SslMode::Manual` points at certs mounted into the Caddy container; +/// `SslMode::Off` prefixes the site address with `http://` to disable +/// automatic HTTPS for that domain. +pub fn generate_caddy_server_block(domain: &DomainConfig) -> Result { + validate_domain(&domain.domain)?; + validate_upstream(&domain.upstream)?; + + let site_address = match domain.ssl { + SslMode::Off => format!("http://{}", domain.domain), + SslMode::Auto | SslMode::Manual => domain.domain.clone(), + }; + + let mut block = String::new(); + block.push_str(&format!("{} {{\n", site_address)); + if domain.ssl == SslMode::Manual { + block.push_str(&format!( + " tls /etc/caddy/certs/{d}/cert.pem /etc/caddy/certs/{d}/key.pem\n", + d = domain.domain + )); + } + block.push_str(&format!(" reverse_proxy {}\n", domain.upstream)); + block.push_str("}\n"); + + Ok(block) +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Tests // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -444,6 +475,16 @@ mod tests { } } + fn caddy_container() -> ContainerInfo { + ContainerInfo { + id: "jkl012".to_string(), + name: "caddy".to_string(), + image: "caddy:2-alpine".to_string(), + ports: vec![80, 443], + status: "Up 1 hour".to_string(), + } + } + fn app_container() -> ContainerInfo { ContainerInfo { id: "xyz999".to_string(), @@ -483,6 +524,14 @@ mod tests { assert_eq!(detection.container_name.as_deref(), Some("traefik")); } + #[test] + fn test_detect_proxy_caddy_from_containers() { + let runtime = MockContainerRuntime::available_with(vec![caddy_container()]); + let detection = detect_proxy(&runtime).unwrap(); + assert_eq!(detection.proxy_type, ProxyType::Caddy); + assert_eq!(detection.container_name.as_deref(), Some("caddy")); + } + #[test] fn test_detect_no_proxy() { let runtime = MockContainerRuntime::available_with(vec![app_container()]); @@ -565,6 +614,48 @@ mod tests { assert!(!block.contains("proxy_pass http://http://app:8080;")); } + // ── caddy config generation tests ─────────────── + + #[test] + fn test_generate_caddy_server_block_ssl_auto() { + let domain = DomainConfig { + domain: "app.example.com".to_string(), + ssl: SslMode::Auto, + upstream: "app:3000".to_string(), + }; + let block = generate_caddy_server_block(&domain).unwrap(); + // Automatic HTTPS: bare domain as the site address, no scheme, no + // manual cert/redirect plumbing like the nginx block needs. + assert!(block.starts_with("app.example.com {")); + assert!(block.contains("reverse_proxy app:3000")); + assert!(!block.contains("tls ")); + } + + #[test] + fn test_generate_caddy_server_block_ssl_manual() { + let domain = DomainConfig { + domain: "app.example.com".to_string(), + ssl: SslMode::Manual, + upstream: "app:3000".to_string(), + }; + let block = generate_caddy_server_block(&domain).unwrap(); + assert!(block.contains("tls /etc/caddy/certs/app.example.com/cert.pem /etc/caddy/certs/app.example.com/key.pem")); + assert!(block.contains("reverse_proxy app:3000")); + } + + #[test] + fn test_generate_caddy_server_block_no_ssl() { + let domain = DomainConfig { + domain: "app.local".to_string(), + ssl: SslMode::Off, + upstream: "app:8080".to_string(), + }; + let block = generate_caddy_server_block(&domain).unwrap(); + assert!(block.starts_with("http://app.local {")); + assert!(block.contains("reverse_proxy app:8080")); + assert!(!block.contains("tls ")); + } + #[test] fn test_generate_nginx_configs_multiple_domains() { let domains = vec![ diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 5347759e..3baed1fb 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -3098,6 +3098,61 @@ impl StackerClient { .ok_or_else(|| CliError::ConfigValidation("Empty status response".to_string())) } + /// Delete a pipe template. `DELETE /api/v1/pipes/templates/{id}`. + /// Idempotent from the caller's view: a 404 (already gone) is treated as + /// success so `pipe apply --prune` can be re-run safely. + pub async fn delete_pipe_template(&self, template_id: &str) -> Result<(), CliError> { + let url = format!("{}/api/v1/pipes/templates/{}", self.base_url, template_id); + let resp = self + .http + .delete(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to delete pipe template: {}", e)) + })?; + if resp.status().is_success() || resp.status().as_u16() == 404 { + return Ok(()); + } + let status_code = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + Err(CliError::ConfigValidation(stacker_api_failure_with_message( + "Delete pipe template failed", + &format!("DELETE /api/v1/pipes/templates/{template_id}"), + status_code, + &body, + cli_debug_enabled(), + ))) + } + + /// Delete a pipe instance. `DELETE /api/v1/pipes/instances/{id}`. + /// 404 → treated as success (idempotent). + pub async fn delete_pipe_instance(&self, instance_id: &str) -> Result<(), CliError> { + let url = format!("{}/api/v1/pipes/instances/{}", self.base_url, instance_id); + let resp = self + .http + .delete(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to delete pipe instance: {}", e)) + })?; + if resp.status().is_success() || resp.status().as_u16() == 404 { + return Ok(()); + } + let status_code = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + Err(CliError::ConfigValidation(stacker_api_failure_with_message( + "Delete pipe instance failed", + &format!("DELETE /api/v1/pipes/instances/{instance_id}"), + status_code, + &body, + cli_debug_enabled(), + ))) + } + /// List pipe templates visible to the current user. /// /// `GET /api/v1/pipes/templates` @@ -3739,6 +3794,29 @@ fn is_platform_managed_service(svc: &ServiceDefinition) -> bool { crate::project_app::is_platform_managed_app_identity(&svc.name, Some(&svc.image)) } +/// Cloud/server deploys go through the Stacker server API, which pulls a +/// pre-built image on the remote host — it never receives the local build +/// context. `app_source_to_app_json` silently drops the app from the deploy +/// payload when `app.image` is unset, which previously produced a "deployed" +/// project missing its main container. Call this before `build_project_body` +/// so a build-only `app:` section (declared via `dockerfile:`/`build:`, no +/// `image:`) fails fast with an actionable message instead of deploying +/// everything except the app. +pub fn require_app_image_for_remote_deploy(config: &StackerConfig) -> Result<(), CliError> { + let app = &config.app; + let app_declared = config.app_present || app.dockerfile.is_some() || app.build.is_some(); + if app_declared && app.image.is_none() { + return Err(CliError::ConfigValidation( + "app.image is required for cloud/server deploys: the Stacker server pulls a \ + pre-built image on the remote host and cannot build from app.dockerfile/app.build \ + locally. Push your image to a registry and set `app.image`, or use \ + `deploy.target: local` to build and run it on this machine." + .to_string(), + )); + } + Ok(()) +} + /// Convert the `app` section of stacker.yml into the Stacker server's app JSON /// format. Returns `None` if the app has no image (build-only local apps). fn app_source_to_app_json( @@ -4129,9 +4207,9 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { } } - // When proxy type is Nginx or NginxProxyManager, inject "nginx_proxy_manager" - // into extended_features so the install service's Ansible playbook runs the - // nginx_proxy_manager role (collect_roles checks selected_features). + // Inject the proxy role into extended_features so the install service's + // Ansible playbook runs the corresponding role (collect_roles checks + // selected_features). match config.proxy.proxy_type { crate::cli::config_parser::ProxyType::Nginx | crate::cli::config_parser::ProxyType::NginxProxyManager => { @@ -4147,9 +4225,66 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { } } } + crate::cli::config_parser::ProxyType::Traefik => { + if let Some(stack_obj) = form.get_mut("stack").and_then(|v| v.as_object_mut()) { + let features = stack_obj + .entry("extended_features") + .or_insert_with(|| serde_json::json!([])); + if let Some(arr) = features.as_array_mut() { + let role = serde_json::Value::String("traefik".to_string()); + if !arr.contains(&role) { + arr.push(role); + } + } + } + } + crate::cli::config_parser::ProxyType::Caddy => { + if let Some(stack_obj) = form.get_mut("stack").and_then(|v| v.as_object_mut()) { + let features = stack_obj + .entry("extended_features") + .or_insert_with(|| serde_json::json!([])); + if let Some(arr) = features.as_array_mut() { + let role = serde_json::Value::String("caddy".to_string()); + if !arr.contains(&role) { + arr.push(role); + } + } + } + } _ => {} } + // Proxy routing domains (proxy.domains) for platform-managed proxies that + // route via a config file — caddy (Caddyfile) and nginx (conf.d). Forwarded + // to the Install Service, which passes them to the proxy role as the + // `stacker_proxy_domains` extra var. (Traefik routes via container labels + // generated into the compose, so it does not need this.) + if !config.proxy.domains.is_empty() { + let domains: Vec = config + .proxy + .domains + .iter() + .map(|d| { + let ssl = match d.ssl { + crate::cli::config_parser::SslMode::Auto => "auto", + crate::cli::config_parser::SslMode::Manual => "manual", + crate::cli::config_parser::SslMode::Off => "off", + }; + serde_json::json!({ + "domain": d.domain, + "upstream": d.upstream, + "ssl": ssl, + }) + }) + .collect(); + if let Some(obj) = form.as_object_mut() { + obj.insert( + "proxy_domains".to_string(), + serde_json::Value::Array(domains), + ); + } + } + // When monitoring.status_panel is enabled, inject the "statuspanel" role into // integrated_features, set connection_mode so the installer recognizes the // status panel flow, and pass vault_url in stack.vars so the Ansible role @@ -4194,6 +4329,44 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { } } + // If the user specified deploy.cloud.ssh_key, read the corresponding + // public key (.pub file) so the Install Service can install it alongside + // the Vault-managed key on the cloud VM. + if let Some(cloud_cfg) = config.deploy.cloud.as_ref() { + if let Some(ssh_key_path) = cloud_cfg.ssh_key.as_ref() { + let resolved = crate::cli::install_runner::resolve_ssh_key_path(ssh_key_path); + let pub_path = std::path::PathBuf::from(format!("{}.pub", resolved.display())); + match std::fs::read_to_string(&pub_path) { + Ok(pub_key) => { + let pub_key = pub_key.trim().to_string(); + if !pub_key.is_empty() { + if let Some(server_obj) = + form.get_mut("server").and_then(|v| v.as_object_mut()) + { + server_obj.insert( + "additional_public_keys".to_string(), + serde_json::json!([pub_key]), + ); + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!( + " note: SSH public key not found at {} — skipping user key installation", + pub_path.display() + ); + } + Err(e) => { + eprintln!( + " warning: could not read SSH public key {}: {}", + pub_path.display(), + e + ); + } + } + } + } + if let Some(cloud_cfg) = config.deploy.cloud.as_ref() { if !cloud_cfg.public_ports.is_empty() { // Normalize each entry to canonical "port/protocol" form so the @@ -4648,6 +4821,7 @@ mod tests { status_panel: true, healthcheck: None, metrics: None, + alerts: None, }) .build() .unwrap(); @@ -4711,6 +4885,7 @@ mod tests { status_panel: true, healthcheck: None, metrics: None, + alerts: None, }) .build() .unwrap(); @@ -4789,6 +4964,56 @@ mod tests { ); } + #[test] + fn test_build_deploy_form_serializes_proxy_domains() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .deploy_target(crate::cli::config_parser::DeployTarget::Cloud) + .proxy(crate::cli::config_parser::ProxyConfig { + proxy_type: crate::cli::config_parser::ProxyType::Caddy, + auto_detect: true, + domains: vec![ + crate::cli::config_parser::DomainConfig { + domain: "app.example.com".to_string(), + ssl: crate::cli::config_parser::SslMode::Auto, + upstream: "app:8080".to_string(), + }, + crate::cli::config_parser::DomainConfig { + domain: "api.example.com".to_string(), + ssl: crate::cli::config_parser::SslMode::Off, + upstream: "app:9000".to_string(), + }, + ], + config: None, + }) + .build() + .unwrap(); + + let form = build_deploy_form(&config); + let domains = form["proxy_domains"] + .as_array() + .expect("proxy_domains should be present in the deploy form"); + assert_eq!(domains.len(), 2); + assert_eq!(domains[0]["domain"], "app.example.com"); + assert_eq!(domains[0]["upstream"], "app:8080"); + assert_eq!(domains[0]["ssl"], "auto"); + assert_eq!(domains[1]["domain"], "api.example.com"); + assert_eq!(domains[1]["ssl"], "off"); + } + + #[test] + fn test_build_deploy_form_omits_proxy_domains_when_none() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .build() + .unwrap(); + let form = build_deploy_form(&config); + assert!( + form.get("proxy_domains").is_none(), + "proxy_domains must be absent when no proxy domains are declared" + ); + } + #[test] fn test_build_project_body_with_nginx_proxy_does_not_add_npm_project_feature() { let config = crate::cli::config_parser::ConfigBuilder::new() @@ -5061,6 +5286,7 @@ mod tests { status_panel: true, healthcheck: None, metrics: None, + alerts: None, }) .build() .unwrap(); @@ -5089,6 +5315,74 @@ mod tests { ); } + // Regression test for GH issue #218: `app` service silently missing from + // the remote docker-compose.yml for cloud/server deploys. Root cause: a + // build-only `app:` section (declared via `dockerfile:`/`build:`, no + // `image:`) makes `app_source_to_app_json` return `None`, so + // `build_project_body` produces an empty `web` array and the server + // deploys everything except the app — silently. This must now be caught + // up front instead. + #[test] + fn test_build_project_body_drops_app_without_image() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("goaccess") + .app_dockerfile("Dockerfile") + .deploy_target(crate::cli::config_parser::DeployTarget::Cloud) + .build() + .unwrap(); + + let body = build_project_body(&config); + assert!( + body["custom"]["web"].as_array().unwrap().is_empty(), + "documents the bug: app section has no image, so `web` stays empty \ + even though app.dockerfile declares a real app" + ); + } + + #[test] + fn test_require_app_image_for_remote_deploy_rejects_dockerfile_only_app() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("goaccess") + .app_dockerfile("Dockerfile") + .deploy_target(crate::cli::config_parser::DeployTarget::Cloud) + .build() + .unwrap(); + + let err = require_app_image_for_remote_deploy(&config) + .expect_err("build-only app (no image) must be rejected before remote deploy"); + match err { + CliError::ConfigValidation(msg) => { + assert!(msg.contains("app.image"), "got: {msg}"); + } + other => panic!("expected ConfigValidation, got {other:?}"), + } + } + + #[test] + fn test_require_app_image_for_remote_deploy_accepts_image_app() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("goaccess") + .app_image("nginx:alpine") + .deploy_target(crate::cli::config_parser::DeployTarget::Cloud) + .build() + .unwrap(); + + require_app_image_for_remote_deploy(&config) + .expect("app with an explicit image should be allowed to deploy remotely"); + } + + #[test] + fn test_require_app_image_for_remote_deploy_accepts_services_only_stack() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("services-only") + .deploy_target(crate::cli::config_parser::DeployTarget::Cloud) + .build() + .unwrap(); + + require_app_image_for_remote_deploy(&config) + .expect("a stack with no declared app: section should not be rejected"); + } + #[test] fn test_generate_server_name_basic() { let name = generate_server_name("website"); diff --git a/src/configuration.rs b/src/configuration.rs index a02205c6..80a6dd5f 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -678,6 +678,10 @@ pub fn get_configuration() -> Result { config.deployment.config_base_path = base_path; } + if let Ok(enabled) = std::env::var("STACKER_PER_INSTALL_BILLING_ENABLED") { + config.per_install_billing_enabled = parse_bool_env(&enabled); + } + Ok(config) } diff --git a/src/connectors/hetzner.rs b/src/connectors/hetzner.rs index 1244869f..0fa70619 100644 --- a/src/connectors/hetzner.rs +++ b/src/connectors/hetzner.rs @@ -49,6 +49,12 @@ pub struct HetznerProvisionedServer { pub public_ipv4: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HetznerSshKey { + pub id: i64, + pub name: String, +} + #[async_trait] pub trait HetznerCloudConnector: Send + Sync { async fn create_server_snapshot( @@ -65,11 +71,19 @@ pub trait HetznerCloudConnector: Send + Sync { request: HetznerCreateServerRequest, ) -> Result; - async fn list_server_types( + /// List all server-type names. Note: Hetzner's `/server_types` endpoint is + /// global and does not support a location filter, so this cannot answer + /// per-region availability — use `validate_server_type_availability` for that. + async fn list_server_types(&self, token: &str) -> Result, ConnectorError>; + + /// Register a public key on the Hetzner account (`POST /ssh_keys`). + /// Returns the key id needed for `HetznerCreateServerRequest::ssh_key_ids`. + async fn add_ssh_key( &self, token: &str, - location: Option<&str>, - ) -> Result, ConnectorError>; + name: &str, + public_key: &str, + ) -> Result; } #[derive(Clone)] @@ -229,15 +243,8 @@ impl HetznerCloudConnector for HetznerCloudClient { }) } - async fn list_server_types( - &self, - token: &str, - location: Option<&str>, - ) -> Result, ConnectorError> { - let url = match location { - Some(loc) => format!("{}/server_types?location={}", self.base_url, loc), - None => format!("{}/server_types", self.base_url), - }; + async fn list_server_types(&self, token: &str) -> Result, ConnectorError> { + let url = format!("{}/server_types", self.base_url); let response = self .http_client @@ -262,6 +269,340 @@ impl HetznerCloudConnector for HetznerCloudClient { Ok(body.server_types.into_iter().map(|t| t.name).collect()) } + + async fn add_ssh_key( + &self, + token: &str, + name: &str, + public_key: &str, + ) -> Result { + let url = format!("{}/ssh_keys", self.base_url); + let response = self + .http_client + .post(&url) + .bearer_auth(token) + .json(&serde_json::json!({ + "name": name, + "public_key": public_key, + })) + .send() + .await + .map_err(ConnectorError::from)?; + + let status = response.status(); + if !status.is_success() { + return Err(status_to_error( + status, + "Hetzner SSH key registration failed", + )); + } + + #[derive(Deserialize)] + struct SshKeyResponse { + ssh_key: HetznerSshKey, + } + let body: SshKeyResponse = response + .json() + .await + .map_err(|err| ConnectorError::InvalidResponse(err.to_string()))?; + + Ok(body.ssh_key) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Server-type × region availability validation +// +// Shared by the deploy route handler AND the CLI local-orchestrator path so the +// pre-flight guard is identical in both. `/server_types` is global and does NOT +// honor a `?location=` filter — per-region availability comes from `/datacenters` +// (`server_types.available` lists the type ids creatable in each datacenter). +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Resolve the Hetzner API base URL, honoring `STACKER_HETZNER_API_URL` (used by +/// tests to point at a mock) then falling back to the public API. +pub fn api_base_url() -> String { + std::env::var("STACKER_HETZNER_API_URL") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "https://api.hetzner.cloud/v1".to_string()) + .trim_end_matches('/') + .to_string() +} + +/// Look up the public IPv4 of a Hetzner server by its name. +/// +/// Used to reconcile `server.srv_ip` when a deploy provisioned a server but the +/// install service never reported the IP back (deployment ends `paused`/`failed` +/// with `srv_ip` null). Hetzner assigns a public IPv4 at creation, so the IP is +/// available on the provider side even when the later Ansible step failed. +/// +/// Returns `Ok(None)` when no server matches the name or the match has no IPv4 +/// yet; `Err` only on transport/HTTP/parse failure so callers can decide whether +/// to retry. +pub async fn fetch_server_ipv4_by_name( + base_url: &str, + token: &str, + name: &str, +) -> Result, ConnectorError> { + let name = name.trim(); + if name.is_empty() { + return Ok(None); + } + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(ConnectorError::from)?; + + let response = client + .get(format!("{}/servers", base_url.trim_end_matches('/'))) + .bearer_auth(token) + .send() + .await + .map_err(ConnectorError::from)?; + + let status = response.status(); + if !status.is_success() { + return Err(status_to_error(status, "Hetzner server lookup failed")); + } + + let body: HetznerServersResponse = response + .json() + .await + .map_err(|err| ConnectorError::InvalidResponse(err.to_string()))?; + + Ok(body + .servers + .iter() + .find(|server| server.name == name) + .and_then(hetzner_server_ip) + .map(str::to_string)) +} + +/// Validate that `server_type` can be created in `region` on Hetzner. +/// +/// Fails open (returns `Ok`) on any transport/HTTP/parse error so a Hetzner +/// outage never blocks deploys. Returns `Err(msg)` only when we positively know +/// the type is unknown, deprecated, or not offered in a region we can see. +pub async fn validate_server_type_availability( + base_url: &str, + token: &str, + server_type: &str, + region: Option<&str>, +) -> Result<(), String> { + let server_type = server_type.trim(); + if server_type.is_empty() || token.trim().is_empty() { + return Ok(()); + } + + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + { + Ok(c) => c, + Err(err) => { + tracing::warn!( + "Could not initialize Hetzner API client: {}; proceeding", + err + ); + return Ok(()); + } + }; + + let server_types = match fetch_hetzner_json::( + &client, + &format!("{}/server_types", base_url), + token, + "server types", + ) + .await + { + Some(body) => body.server_types, + None => return Ok(()), + }; + + // Datacenters are best-effort: without them we skip the region check but + // still enforce existence + deprecation. + let datacenters = fetch_hetzner_json::( + &client, + &format!("{}/datacenters", base_url), + token, + "datacenters", + ) + .await + .map(|body| body.datacenters) + .unwrap_or_default(); + + evaluate_server_type_availability(&server_types, &datacenters, server_type, region) +} + +/// GET a Hetzner JSON endpoint, returning `None` (and logging a warning) on any +/// transport, HTTP, or deserialization error so callers can fail open. +async fn fetch_hetzner_json( + client: &reqwest::Client, + url: &str, + token: &str, + what: &str, +) -> Option { + let response = match client.get(url).bearer_auth(token).send().await { + Ok(r) => r, + Err(err) => { + tracing::warn!( + "Could not reach Hetzner API for {}: {}; proceeding", + what, + err + ); + return None; + } + }; + + if !response.status().is_success() { + tracing::warn!( + "Hetzner {} API returned HTTP {}; skipping server type validation", + what, + response.status().as_u16() + ); + return None; + } + + match response.json::().await { + Ok(body) => Some(body), + Err(err) => { + tracing::warn!( + "Invalid Hetzner {} response: {}; skipping validation", + what, + err + ); + None + } + } +} + +/// Pure availability check, split out from I/O so it is unit-testable. +/// +/// - Unknown type name → error (not offered by Hetzner at all). +/// - Deprecated type → error (cannot create new servers). +/// - `region` given and known to `/datacenters` but not offering the type → +/// error naming the region. This is the case region-blind validation missed: +/// `/server_types?location=…` is ignored by Hetzner, so a globally-existing +/// type like `cpx21` falsely passed even when unavailable in e.g. `nbg1`. +/// - `region` unknown to `/datacenters` (or datacenters unavailable) → fail open. +fn evaluate_server_type_availability( + server_types: &[HetznerServerTypeEntry], + datacenters: &[HetznerDatacenterEntry], + server_type: &str, + region: Option<&str>, +) -> Result<(), String> { + use std::collections::HashSet; + + let active_type_names = |ids: Option<&HashSet>| -> String { + let names: Vec<&str> = server_types + .iter() + .filter(|t| t.deprecated.is_none()) + .filter(|t| ids.map_or(true, |set| set.contains(&t.id))) + .map(|t| t.name.as_str()) + .collect(); + if names.is_empty() { + "none found".to_string() + } else { + names.join(", ") + } + }; + + let entry = match server_types + .iter() + .find(|t| t.name.eq_ignore_ascii_case(server_type)) + { + Some(entry) => entry, + None => { + return Err(format!( + "Server type '{}' is not available in Hetzner. Available types: {}", + server_type, + active_type_names(None) + )); + } + }; + + if entry.deprecated.is_some() { + return Err(format!( + "Server type '{}' is deprecated in Hetzner and can no longer be used to create new servers. \ + Set `deploy.cloud.size` in stacker.yml to an active type: {}", + server_type, + active_type_names(None) + )); + } + + // Per-region availability check — only meaningful when we know the region + // AND `/datacenters` actually lists it. Otherwise fail open. + if let Some(region) = region.map(str::trim).filter(|r| !r.is_empty()) { + let region_dcs: Vec<&HetznerDatacenterEntry> = datacenters + .iter() + .filter(|dc| dc.location.name.eq_ignore_ascii_case(region)) + .collect(); + + if !region_dcs.is_empty() { + let available_ids: HashSet = region_dcs + .iter() + .flat_map(|dc| dc.server_types.available.iter().copied()) + .collect(); + + if !available_ids.contains(&entry.id) { + return Err(format!( + "Server type '{}' is not available in Hetzner location '{}'. \ + Set `deploy.cloud.region` or `deploy.cloud.size` in stacker.yml. \ + Types available in '{}': {}", + server_type, + region, + region, + active_type_names(Some(&available_ids)) + )); + } + } + } + + Ok(()) +} + +#[derive(Debug, Deserialize)] +struct HetznerServerTypesResponse { + #[serde(default)] + server_types: Vec, +} + +#[derive(Debug, Deserialize)] +struct HetznerServerTypeEntry { + /// Numeric id — what `/datacenters` lists under `server_types.available`. + id: i64, + name: String, + /// Non-null when Hetzner has deprecated this type (ISO-8601 timestamp). + #[serde(default)] + deprecated: Option, +} + +#[derive(Debug, Deserialize)] +struct HetznerDatacentersResponse { + #[serde(default)] + datacenters: Vec, +} + +#[derive(Debug, Deserialize)] +struct HetznerDatacenterEntry { + location: HetznerDatacenterLocation, + #[serde(default)] + server_types: HetznerDatacenterServerTypes, +} + +#[derive(Debug, Deserialize)] +struct HetznerDatacenterLocation { + name: String, +} + +#[derive(Debug, Default, Deserialize)] +struct HetznerDatacenterServerTypes { + /// Server-type ids creatable in this datacenter. + #[serde(default)] + available: Vec, } fn status_to_error(status: reqwest::StatusCode, message: &str) -> ConnectorError { @@ -369,23 +710,204 @@ struct HetznerActionResource { resource_type: String, } -#[derive(Debug, Deserialize)] -struct HetznerServerTypesResponse { - #[serde(default)] - server_types: Vec, -} - -#[derive(Debug, Deserialize)] -struct HetznerServerType { - name: String, -} - #[cfg(test)] mod tests { use super::*; use wiremock::matchers::{body_partial_json, header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; + fn stype(id: i64, name: &str, deprecated: Option<&str>) -> HetznerServerTypeEntry { + HetznerServerTypeEntry { + id, + name: name.to_string(), + deprecated: deprecated.map(ToOwned::to_owned), + } + } + + fn datacenter(location: &str, available: &[i64]) -> HetznerDatacenterEntry { + HetznerDatacenterEntry { + location: HetznerDatacenterLocation { + name: location.to_string(), + }, + server_types: HetznerDatacenterServerTypes { + available: available.to_vec(), + }, + } + } + + // The regression from the bug report: `cpx21` exists globally, so the old + // `/server_types?location=…` check falsely passed, but Hetzner does not + // offer it in `nbg1`, so Terraform later died with "unsupported location". + #[test] + fn server_type_unavailable_in_region_is_rejected() { + let types = vec![stype(22, "cpx11", None), stype(23, "cpx21", None)]; + let dcs = vec![ + datacenter("fsn1", &[22, 23]), + datacenter("nbg1", &[22]), // cpx21 (id 23) NOT available here + ]; + + let err = evaluate_server_type_availability(&types, &dcs, "cpx21", Some("nbg1")) + .expect_err("cpx21 must be rejected in nbg1"); + assert!(err.contains("cpx21"), "error should name the type: {err}"); + assert!(err.contains("nbg1"), "error should name the region: {err}"); + assert!( + err.contains("cpx11"), + "error should list available types: {err}" + ); + } + + #[test] + fn server_type_available_in_region_passes() { + let types = vec![stype(22, "cpx11", None), stype(23, "cpx21", None)]; + let dcs = vec![datacenter("fsn1", &[22, 23])]; + assert!(evaluate_server_type_availability(&types, &dcs, "cpx21", Some("fsn1")).is_ok()); + } + + #[test] + fn region_matching_is_case_insensitive() { + let types = vec![stype(23, "cpx21", None)]; + let dcs = vec![datacenter("fsn1", &[23])]; + assert!(evaluate_server_type_availability(&types, &dcs, "CPX21", Some("FSN1")).is_ok()); + } + + #[test] + fn deprecated_server_type_is_rejected() { + let types = vec![ + stype(1, "cx11", Some("2024-01-01T00:00:00+00:00")), + stype(22, "cpx11", None), + ]; + let dcs = vec![datacenter("fsn1", &[1, 22])]; + let err = evaluate_server_type_availability(&types, &dcs, "cx11", Some("fsn1")) + .expect_err("deprecated type must be rejected"); + assert!(err.contains("deprecated"), "err: {err}"); + assert!(err.contains("cpx11"), "should suggest active type: {err}"); + } + + #[test] + fn unknown_server_type_is_rejected() { + let types = vec![stype(22, "cpx11", None)]; + let dcs = vec![datacenter("fsn1", &[22])]; + let err = evaluate_server_type_availability(&types, &dcs, "does-not-exist", Some("fsn1")) + .expect_err("unknown type must be rejected"); + assert!(err.contains("does-not-exist"), "err: {err}"); + } + + // Fail-open guarantees: no region, unknown region, and missing datacenter + // data must never block a deploy on the region dimension. + #[test] + fn no_region_skips_region_check() { + let types = vec![stype(23, "cpx21", None)]; + assert!(evaluate_server_type_availability(&types, &[], "cpx21", None).is_ok()); + } + + #[test] + fn unknown_region_fails_open() { + let types = vec![stype(23, "cpx21", None)]; + let dcs = vec![datacenter("fsn1", &[23])]; + // Region not present in /datacenters — we cannot prove unavailability. + assert!(evaluate_server_type_availability(&types, &dcs, "cpx21", Some("ash")).is_ok()); + } + + #[test] + fn empty_datacenters_fails_open_on_region() { + let types = vec![stype(23, "cpx21", None)]; + // Simulates /datacenters fetch failure: existence still checked, region skipped. + assert!(evaluate_server_type_availability(&types, &[], "cpx21", Some("nbg1")).is_ok()); + } + + // End-to-end through the HTTP layer against a mock Hetzner, exercising the + // /server_types + /datacenters fetch and fail-open on the datacenters call. + #[tokio::test] + async fn validate_rejects_type_missing_in_region_via_http() { + let api = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/server_types")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "server_types": [ + {"id": 22, "name": "cpx11"}, + {"id": 23, "name": "cpx21"} + ] + }))) + .mount(&api) + .await; + Mock::given(method("GET")) + .and(path("/datacenters")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "datacenters": [ + {"location": {"name": "nbg1"}, "server_types": {"available": [22]}} + ] + }))) + .mount(&api) + .await; + + let err = validate_server_type_availability(&api.uri(), "tok", "cpx21", Some("nbg1")) + .await + .expect_err("cpx21 not in nbg1"); + assert!(err.contains("nbg1"), "err: {err}"); + } + + #[tokio::test] + async fn fetch_server_ipv4_by_name_returns_ip_for_match() { + let api = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/servers")) + .and(header("authorization", "Bearer tok")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "servers": [ + {"id": 1, "name": "other", "public_net": {"ipv4": {"ip": "1.1.1.1"}}}, + {"id": 2, "name": "nocodb-419", "public_net": {"ipv4": {"ip": "203.0.113.7"}}} + ] + }))) + .mount(&api) + .await; + + let ip = fetch_server_ipv4_by_name(&api.uri(), "tok", "nocodb-419") + .await + .unwrap(); + assert_eq!(ip.as_deref(), Some("203.0.113.7")); + } + + #[tokio::test] + async fn fetch_server_ipv4_by_name_returns_none_when_absent() { + let api = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/servers")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "servers": [{"id": 1, "name": "other", "public_net": {"ipv4": {"ip": "1.1.1.1"}}}] + }))) + .mount(&api) + .await; + + let ip = fetch_server_ipv4_by_name(&api.uri(), "tok", "missing") + .await + .unwrap(); + assert!(ip.is_none()); + } + + #[tokio::test] + async fn validate_fails_open_when_datacenters_unavailable() { + let api = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/server_types")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "server_types": [{"id": 23, "name": "cpx21"}] + }))) + .mount(&api) + .await; + Mock::given(method("GET")) + .and(path("/datacenters")) + .respond_with(ResponseTemplate::new(500)) + .mount(&api) + .await; + + // Existence passes, region check skipped → Ok. + assert!( + validate_server_type_availability(&api.uri(), "tok", "cpx21", Some("nbg1")) + .await + .is_ok() + ); + } + #[tokio::test] async fn create_snapshot_resolves_server_by_public_ip_without_live_api() { let api = MockServer::start().await; @@ -481,7 +1003,7 @@ mod tests { .await; let client = HetznerCloudClient::new(api.uri()).unwrap(); - let types = client.list_server_types("test-token", None).await.unwrap(); + let types = client.list_server_types("test-token").await.unwrap(); assert_eq!(types, vec!["cx22", "cx32", "cx42"]); } @@ -496,7 +1018,7 @@ mod tests { .await; let client = HetznerCloudClient::new(api.uri()).unwrap(); - let result = client.list_server_types("bad-token", None).await; + let result = client.list_server_types("bad-token").await; assert!(matches!(result, Err(ConnectorError::Unauthorized(_)))); } diff --git a/src/connectors/install_service/client.rs b/src/connectors/install_service/client.rs index 10365d17..5d759fc4 100644 --- a/src/connectors/install_service/client.rs +++ b/src/connectors/install_service/client.rs @@ -1,4 +1,4 @@ -use super::InstallServiceConnector; +use super::{InstallServiceConnector, PostDeployClonePayload}; use crate::forms::cloud_firewall; use crate::forms::project::{RegistryForm, Stack, Var}; use crate::forms::{CloudFirewallOperationMessage, ConfigureCloudFirewallResponse}; @@ -106,6 +106,7 @@ impl InstallServiceConnector for InstallServiceClient { mq_manager: &MqManager, server_public_key: Option, server_private_key: Option, + proxy_domains: Option, ) -> Result { // Build payload for the install service let mut payload = crate::forms::project::Payload::try_from(project) @@ -142,6 +143,25 @@ impl InstallServiceConnector for InstallServiceClient { payload.user_email = Some(user_email); payload.docker_compose = Some(compress(fc.as_str())); payload.registry = registry; + // Reverse-proxy routing domains → install_data["proxy_domains"], read by + // AppVarsMapper as the `stacker_proxy_domains` extra-var. Set on the MQ + // payload here (not just the stored deployment record) so it actually + // reaches the Install Service. + payload.proxy_domains = proxy_domains; + + // Set stack_code for per-project directory namespacing on the remote server. + // The Ansible `custom` role uses this as `stack_source` to compute the + // deploy directory: /home/trydirect/{stack_code}/. + // Includes project.id to guarantee uniqueness — project.name has no DB + // constraint, so "My App" and "my-app" would otherwise collide. + if payload.stack_code.is_none() { + let stack_code = format!( + "{}-{}", + crate::models::project::sanitize_project_name(&project.name), + project.id + ); + payload.stack_code = Some(stack_code); + } tracing::debug!( "Send project data (deployment_hash = {:?}): {:?}", @@ -209,4 +229,23 @@ impl InstallServiceConnector for InstallServiceClient { firewall: None, }) } + + async fn post_deploy_clone( + &self, + payload: PostDeployClonePayload, + mq_manager: &MqManager, + ) -> Result<(), String> { + let routing_key = "install.post_deploy.clone.all.all".to_string(); + tracing::info!( + deployment_hash = %payload.deployment_hash, + server_id = payload.server_id, + "publishing post-deploy-clone job to install service" + ); + mq_manager + .publish("install".to_string(), routing_key, &payload) + .await + .map_err(|err| format!("Failed to publish post-deploy-clone to MQ: {}", err))?; + + Ok(()) + } } diff --git a/src/connectors/install_service/mock.rs b/src/connectors/install_service/mock.rs index 1edce00b..12fd793d 100644 --- a/src/connectors/install_service/mock.rs +++ b/src/connectors/install_service/mock.rs @@ -1,4 +1,4 @@ -use super::InstallServiceConnector; +use super::{InstallServiceConnector, PostDeployClonePayload}; use crate::forms::cloud_firewall; use crate::forms::project::{RegistryForm, Stack}; use crate::forms::{CloudFirewallOperationMessage, ConfigureCloudFirewallResponse}; @@ -26,6 +26,7 @@ impl InstallServiceConnector for MockInstallServiceConnector { _mq_manager: &MqManager, _server_public_key: Option, _server_private_key: Option, + _proxy_domains: Option, ) -> Result { Ok(project_id) } @@ -54,4 +55,12 @@ impl InstallServiceConnector for MockInstallServiceConnector { firewall: None, }) } + + async fn post_deploy_clone( + &self, + _payload: PostDeployClonePayload, + _mq_manager: &MqManager, + ) -> Result<(), String> { + Ok(()) + } } diff --git a/src/connectors/install_service/mod.rs b/src/connectors/install_service/mod.rs index 3979f11e..a9a70d9b 100644 --- a/src/connectors/install_service/mod.rs +++ b/src/connectors/install_service/mod.rs @@ -7,6 +7,7 @@ use crate::forms::{CloudFirewallOperationMessage, ConfigureCloudFirewallResponse use crate::helpers::MqManager; use crate::models; use async_trait::async_trait; +use serde::Serialize; pub mod client; pub mod init; @@ -16,6 +17,22 @@ pub use client::InstallServiceClient; pub use init::init; pub use mock::MockInstallServiceConnector; +/// Payload for the post-clone-deploy Ansible setup job. +#[derive(Debug, Serialize)] +pub struct PostDeployClonePayload { + pub deployment_hash: String, + pub server_id: i64, + pub public_ipv4: String, + pub domain: String, + pub stack: String, + pub provider: String, + pub user_token: String, + pub user_email: String, + pub installation_id: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_private_key: Option, +} + #[async_trait] pub trait InstallServiceConnector: Send + Sync { /// Deploy a project using compose file and credentials via the install service @@ -35,6 +52,7 @@ pub trait InstallServiceConnector: Send + Sync { mq_manager: &MqManager, server_public_key: Option, server_private_key: Option, + proxy_domains: Option, ) -> Result; async fn configure_cloud_firewall( @@ -42,4 +60,12 @@ pub trait InstallServiceConnector: Send + Sync { message: CloudFirewallOperationMessage, mq_manager: &MqManager, ) -> Result; + + /// Trigger post-clone Ansible setup (firewall, monitoring, etc.) on a + /// server that was just cloned from a baked snapshot. + async fn post_deploy_clone( + &self, + payload: PostDeployClonePayload, + mq_manager: &MqManager, + ) -> Result<(), String>; } diff --git a/src/connectors/user_service/client.rs b/src/connectors/user_service/client.rs index f394fe27..fe70ea8a 100644 --- a/src/connectors/user_service/client.rs +++ b/src/connectors/user_service/client.rs @@ -749,6 +749,38 @@ impl UserServiceConnector for UserServiceClient { let text = resp.text().await.unwrap_or_default(); Err(map_billing_error_status(status.as_u16(), &text)) } + + async fn daily_capture_install_charge( + &self, + auth_token: &str, + authorization_id: &str, + amount_minor: i64, + deployment_hash: &str, + ) -> Result { + let url = format!( + "{}/api/1.0/marketplace/billing/daily-capture", + self.base_url + ); + let payload = serde_json::json!({ + "payment_intent_id": authorization_id, + "amount_minor": amount_minor, + "deployment_hash": deployment_hash, + }); + let resp = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", auth_token)) + .json(&payload) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await.map_err(ConnectorError::from)?; + if !status.is_success() { + return Err(map_billing_error_status(status.as_u16(), &text)); + } + serde_json::from_str::(&text) + .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) + } } /// Map a non-2xx billing response to the appropriate ConnectorError. diff --git a/src/connectors/user_service/connector.rs b/src/connectors/user_service/connector.rs index f66a1629..64ee8a38 100644 --- a/src/connectors/user_service/connector.rs +++ b/src/connectors/user_service/connector.rs @@ -139,4 +139,15 @@ pub trait UserServiceConnector: Send + Sync { authorization_id: &str, reason: &str, ) -> Result<(), ConnectorError>; + + /// Partial capture for deployment_daily billing. + /// Captures `amount_minor` from the existing hold, leaving the rest + /// available for future daily charges. + async fn daily_capture_install_charge( + &self, + auth_token: &str, + authorization_id: &str, + amount_minor: i64, + deployment_hash: &str, + ) -> Result; } diff --git a/src/connectors/user_service/marketplace_webhook.rs b/src/connectors/user_service/marketplace_webhook.rs index 5d9ed5a7..e3d105e5 100644 --- a/src/connectors/user_service/marketplace_webhook.rs +++ b/src/connectors/user_service/marketplace_webhook.rs @@ -43,6 +43,14 @@ pub struct MarketplaceWebhookPayload { #[serde(skip_serializing_if = "Option::is_none")] pub billing_cycle: Option, + /// Daily rate for deployment_daily billing (USD) + #[serde(skip_serializing_if = "Option::is_none")] + pub daily_rate: Option, + + /// Monthly cap for deployment_daily billing (USD) + #[serde(skip_serializing_if = "Option::is_none")] + pub monthly_cap: Option, + /// Currency code (USD, EUR, etc.) #[serde(skip_serializing_if = "Option::is_none")] pub currency: Option, @@ -244,6 +252,8 @@ impl MarketplaceWebhookSender { .or_else(|| template.long_description.clone()), price: template.price, billing_cycle: template.billing_cycle.clone(), + daily_rate: template.daily_rate, + monthly_cap: template.monthly_cap, currency: template.currency.clone(), vendor_user_id: Some(vendor_id.to_string()), vendor_name: template.creator_name.clone(), @@ -310,6 +320,8 @@ impl MarketplaceWebhookSender { .or_else(|| template.long_description.clone()), price: template.price, billing_cycle: template.billing_cycle.clone(), + daily_rate: template.daily_rate, + monthly_cap: template.monthly_cap, currency: template.currency.clone(), vendor_user_id: Some(vendor_id.to_string()), vendor_name: template.creator_name.clone(), @@ -374,6 +386,8 @@ impl MarketplaceWebhookSender { .or_else(|| template.long_description.clone()), price: template.price, billing_cycle: template.billing_cycle.clone(), + daily_rate: template.daily_rate, + monthly_cap: template.monthly_cap, currency: template.currency.clone(), vendor_user_id: Some(vendor_id.to_string()), vendor_name: template.creator_name.clone(), @@ -439,6 +453,8 @@ impl MarketplaceWebhookSender { .or_else(|| template.long_description.clone()), price: template.price, billing_cycle: template.billing_cycle.clone(), + daily_rate: template.daily_rate, + monthly_cap: template.monthly_cap, currency: template.currency.clone(), vendor_user_id: Some(vendor_id.to_string()), vendor_name: template.creator_name.clone(), @@ -504,6 +520,8 @@ impl MarketplaceWebhookSender { .or_else(|| template.long_description.clone()), price: template.price, billing_cycle: template.billing_cycle.clone(), + daily_rate: template.daily_rate, + monthly_cap: template.monthly_cap, currency: template.currency.clone(), vendor_user_id: Some(vendor_id.to_string()), vendor_name: template.creator_name.clone(), @@ -569,6 +587,8 @@ impl MarketplaceWebhookSender { .or_else(|| template.long_description.clone()), price: template.price, billing_cycle: template.billing_cycle.clone(), + daily_rate: template.daily_rate, + monthly_cap: template.monthly_cap, currency: template.currency.clone(), vendor_user_id: Some(vendor_id.to_string()), vendor_name: template.creator_name.clone(), @@ -632,6 +652,8 @@ impl MarketplaceWebhookSender { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, vendor_user_id: None, vendor_name: None, category: None, @@ -681,6 +703,8 @@ impl MarketplaceWebhookSender { .or_else(|| template.long_description.clone()), price: template.price, billing_cycle: template.billing_cycle.clone(), + daily_rate: template.daily_rate, + monthly_cap: template.monthly_cap, currency: template.currency.clone(), vendor_user_id: Some(vendor_id.to_string()), vendor_name: template.creator_name.clone(), @@ -865,6 +889,8 @@ mod tests { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, vendor_user_id: None, vendor_name: None, category: None, @@ -973,6 +999,8 @@ mod tests { price: None, // Free template billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, vendor_user_id: None, vendor_name: None, category: Some("CMS".to_string()), @@ -1111,6 +1139,8 @@ mod tests { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, vendor_user_id: None, vendor_name: None, category: None, diff --git a/src/connectors/user_service/mock.rs b/src/connectors/user_service/mock.rs index 09a07853..268df061 100644 --- a/src/connectors/user_service/mock.rs +++ b/src/connectors/user_service/mock.rs @@ -298,4 +298,20 @@ impl UserServiceConnector for MockUserServiceConnector { ) -> Result<(), ConnectorError> { Ok(()) } + + async fn daily_capture_install_charge( + &self, + _auth_token: &str, + _authorization_id: &str, + _amount_minor: i64, + _deployment_hash: &str, + ) -> Result { + Ok(AuthorizationHandle { + authorization_id: "mock-auth-id".to_string(), + amount_minor: 0, + currency: "USD".to_string(), + expires_at: None, + status: "captured".to_string(), + }) + } } diff --git a/src/console/commands/cli/agent.rs b/src/console/commands/cli/agent.rs index 45e69987..9f3f75a3 100644 --- a/src/console/commands/cli/agent.rs +++ b/src/console/commands/cli/agent.rs @@ -9,6 +9,7 @@ //! The CLI never connects to the agent directly. All communication is mediated //! by the Stacker server. +use crate::cli::compose_service_sync::upsert_external_network; use crate::cli::config_bundle::{build_config_bundle, ConfigBundleArtifacts}; use crate::cli::config_parser::StackerConfig; use crate::cli::debug::cli_debug_enabled; @@ -1196,7 +1197,8 @@ fn merge_compose_service( "app-local compose does not define service '{app_code}'" )) })?; - let should_merge_networks = !project_service_networks(&project_doc).is_empty(); + let project_networks = project_service_networks(&project_doc); + let should_merge_networks = !project_networks.is_empty(); align_service_networks_with_project(&mut app_service, &project_doc); let project_services = project_doc @@ -1210,6 +1212,14 @@ fn merge_compose_service( if should_merge_networks { merge_compose_top_level_mapping(&mut project_doc, &app_doc, "networks"); + // `app_doc` may not itself know about a network that the project's + // *existing* services already reference (e.g. a per-app override + // compose that predates `default_network` being added elsewhere). + // Declare any such name as `external: true` so the merged compose + // never ends up with a service referencing an undeclared network. + for network in &project_networks { + upsert_external_network(&mut project_doc, network); + } } merge_compose_top_level_mapping(&mut project_doc, &app_doc, "volumes"); @@ -2250,7 +2260,7 @@ fn run_logs_command( ) } -fn fetch_live_containers( +pub(crate) fn fetch_live_containers( ctx: &CliRuntime, deployment_hash: &str, ) -> Result>, CliError> { @@ -2703,6 +2713,53 @@ fn add_agent_install_scope_contract(deploy_form: &mut serde_json::Value) { })); } +/// Pick the server record `stacker agent install`'s cloud-install path +/// should target, out of every server the Stacker backend has on file for +/// this project. +/// +/// When `configured_server` is `Some` (the user has `deploy.server` set +/// locally, i.e. they have a specific existing server in mind), the match +/// must be by IP, not just by project id — a project can accumulate more +/// than one server record over time (e.g. an earlier `--target cloud` +/// attempt before switching to an existing server), and blindly taking the +/// first project-matching server silently installed against/created a +/// deployment for an unrelated server instead of the one in stacker.yml. +/// See GH issue #223. +fn select_server_for_agent_install( + servers: Vec, + project_id: i32, + configured_server: Option<&crate::cli::config_parser::ServerConfig>, + project_name: &str, + target_label: &str, +) -> Result { + let matching_by_project: Vec<_> = servers + .into_iter() + .filter(|s| s.project_id == project_id) + .collect(); + + if let Some(server_cfg) = configured_server { + matching_by_project + .into_iter() + .find(|s| s.srv_ip.as_deref() == Some(server_cfg.host.as_str())) + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "deploy.server.host ({}) is configured in stacker.yml, but no matching \ + server was found on the Stacker server for project '{}' (id={}).\n\ + Deploy to this server first with: stacker deploy --target {}", + server_cfg.host, project_name, project_id, target_label + )) + }) + } else { + matching_by_project.into_iter().next().ok_or_else(|| { + CliError::ConfigValidation(format!( + "No server found for project '{}' (id={}).\n\ + Deploy the project first with: stacker deploy --target {}", + project_name, project_id, target_label + )) + }) + } +} + fn build_agent_install_deploy_request( config: &crate::cli::config_parser::StackerConfig, server: &crate::cli::stacker_client::ServerInfo, @@ -3012,16 +3069,13 @@ impl CallableTrait for AgentInstallCommand { // 2. Find the server for this project progress::update_message(&pb, "Finding server..."); let servers = ctx.client.list_servers().await?; - let server = servers - .into_iter() - .find(|s| s.project_id == project.id) - .ok_or_else(|| { - CliError::ConfigValidation(format!( - "No server found for project '{}' (id={}).\n\ - Deploy the project first with: stacker deploy --target {}", - project_name, project.id, target_label - )) - })?; + let server = select_server_for_agent_install( + servers, + project.id, + config.deploy.server.as_ref(), + &project_name, + &target_label, + )?; // 3. Build a minimal deploy form with only the statuspanel feature progress::update_message(&pb, "Preparing deploy payload..."); @@ -3088,6 +3142,52 @@ mod tests { use super::*; use tempfile::TempDir; + // Regression test for GH issue #211: `merge_compose_service` attaches a + // network name to the newly-merged app service (copied from the + // *existing* project services via `align_service_networks_with_project`) + // that the app's own local compose (`app_doc`) never declared. Without + // also declaring it on `project_doc` directly, this produced "service X + // refers to undefined network default_network: invalid compose project" + // on `docker compose`. + #[test] + fn test_merge_compose_service_declares_network_inherited_from_project() { + // Existing remote project compose: "db" already joined + // `default_network` (e.g. it's NPM-proxied), but for whatever reason + // (hand-edited file, partial prior sync, backend-rendered compose) + // the top-level `networks:` mapping was never declared. + let project_compose = + "services:\n db:\n image: postgres:16\n networks: [default_network]\n"; + // App-local compose (e.g. a per-app override file) — has no idea + // about `default_network` at all. + let app_compose = "services:\n app:\n image: myorg/app:latest\n"; + + let merged = merge_compose_service(project_compose, app_compose, "app") + .expect("merge should succeed"); + let doc: serde_yaml::Value = serde_yaml::from_str(&merged).unwrap(); + + let app_networks: Vec<&str> = doc["services"]["app"]["networks"] + .as_sequence() + .expect("app service should have networks copied from the project") + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!( + app_networks.contains(&"default_network"), + "app service should reference default_network like the rest of the project: {merged}" + ); + + let declares_default_network = doc + .get("networks") + .and_then(|n| n.as_mapping()) + .map(|m| m.contains_key(serde_yaml::Value::String("default_network".to_string()))) + .unwrap_or(false); + assert!( + declares_default_network, + "app service references default_network but the top-level networks: mapping \ + never declares it, producing an invalid compose file:\n{merged}" + ); + } + fn label_value<'a>(labels: &'a serde_yaml::Mapping, key: &str) -> Option<&'a str> { labels .get(serde_yaml::Value::String(key.to_string())) @@ -3116,6 +3216,79 @@ mod tests { } } + // Regression tests for GH issue #223: `stacker agent install` picked + // the first server matching the project id, without checking it was + // actually the server configured in stacker.yml's `deploy.server` — + // installing against/creating a deployment for a stale/unrelated + // server left over from an earlier deploy attempt. + #[test] + fn select_server_for_agent_install_matches_configured_host() { + let servers = vec![ + crate::cli::stacker_client::ServerInfo { + srv_ip: Some("198.51.100.1".to_string()), + ..sample_server_info() + }, + crate::cli::stacker_client::ServerInfo { + srv_ip: Some("203.0.113.10".to_string()), + ..sample_server_info() + }, + ]; + let server_cfg = crate::cli::config_parser::ServerConfig { + host: "203.0.113.10".to_string(), + user: "root".to_string(), + ssh_key: None, + port: 22, + }; + + let selected = + select_server_for_agent_install(servers, 42, Some(&server_cfg), "demo", "server") + .expect("should find the matching server"); + + assert_eq!(selected.srv_ip.as_deref(), Some("203.0.113.10")); + } + + #[test] + fn select_server_for_agent_install_fails_clearly_when_no_server_matches_configured_host() { + let servers = vec![crate::cli::stacker_client::ServerInfo { + srv_ip: Some("198.51.100.1".to_string()), + ..sample_server_info() + }]; + let server_cfg = crate::cli::config_parser::ServerConfig { + host: "203.0.113.10".to_string(), + user: "root".to_string(), + ssh_key: None, + port: 22, + }; + + let err = + select_server_for_agent_install(servers, 42, Some(&server_cfg), "demo", "server") + .expect_err("should not silently pick an unrelated server"); + + let message = err.to_string(); + assert!(message.contains("203.0.113.10")); + assert!(message.contains("stacker deploy --target server")); + } + + #[test] + fn select_server_for_agent_install_falls_back_to_project_match_without_configured_server() { + let servers = vec![sample_server_info()]; + + let selected = select_server_for_agent_install(servers, 42, None, "demo", "cloud") + .expect("should fall back to the project-matching server"); + + assert_eq!(selected.project_id, 42); + } + + #[test] + fn select_server_for_agent_install_fails_when_no_server_for_project() { + let servers = vec![sample_server_info()]; + + let err = select_server_for_agent_install(servers, 999, None, "demo", "cloud") + .expect_err("no server should match an unrelated project id"); + + assert!(err.to_string().contains("No server found for project")); + } + fn stack_var_value<'a>(deploy_form: &'a serde_json::Value, key: &str) -> Option<&'a str> { deploy_form["stack"]["vars"] .as_array()? diff --git a/src/console/commands/cli/config.rs b/src/console/commands/cli/config.rs index 7e068404..c8b29224 100644 --- a/src/console/commands/cli/config.rs +++ b/src/console/commands/cli/config.rs @@ -18,16 +18,38 @@ use crate::cli::config_promote::{ }; use crate::cli::debug::cli_debug_enabled; use crate::cli::deployment_lock::DeploymentLock; -use crate::cli::error::CliError; +use crate::cli::error::{CliError, Severity}; use crate::cli::runtime::CliRuntime; use crate::cli::stacker_client::ProjectAppInfo; use crate::console::commands::cli::init::full_config_reference_example; use crate::console::commands::CallableTrait; -use crate::helpers::env_path::{compose_env_file_reference, remote_runtime_env_path}; +use crate::helpers::env_path::{compose_env_file_reference, remote_runtime_env_path_for}; use crate::services::runtime_env_contract_response; const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; +/// Surgically modify specific keys in stacker.yml without losing other keys. +/// Reads the file as raw YAML, applies the mutation closure, and writes back. +/// Creates a .bak backup before writing. +fn edit_stacker_yml(config_path: &Path, mutate: F) -> Result<(), CliError> +where + F: FnOnce(&mut serde_yaml::Mapping) -> Result<(), CliError>, +{ + let raw = std::fs::read_to_string(config_path)?; + let mut doc: serde_yaml::Value = serde_yaml::from_str(&raw) + .map_err(|e| CliError::ConfigValidation(format!("Invalid YAML: {}", e)))?; + let root = doc + .as_mapping_mut() + .ok_or_else(|| CliError::ConfigValidation("stacker.yml must be a YAML mapping".into()))?; + mutate(root)?; + let yaml = serde_yaml::to_string(&doc) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize: {}", e)))?; + let backup_path = format!("{}.bak", config_path.display()); + std::fs::copy(config_path, &backup_path)?; + std::fs::write(config_path, yaml)?; + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq)] enum RawPathIssueKind { Empty, @@ -363,7 +385,7 @@ pub fn run_generate_remote_payload( }); } - let mut config = StackerConfig::from_file_raw(path)?; + let config = StackerConfig::from_file_raw(path)?; let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); let output_path = match output { @@ -447,38 +469,61 @@ pub fn run_generate_remote_payload( .map(PathBuf::from) .unwrap_or_else(|_| output_path.clone()); - let existing_cloud = config.deploy.cloud.clone().unwrap_or(CloudConfig { - provider, - orchestrator: CloudOrchestrator::Remote, - region: Some(default_region_for_provider(provider).to_string()), - size: Some(default_size_for_provider(provider).to_string()), - install_image: None, - remote_payload_file: None, - ssh_key: None, - key: None, - server: None, - public_ports: Vec::new(), - }); - - config.deploy.target = DeployTarget::Cloud; - config.deploy.cloud = Some(CloudConfig { - provider: existing_cloud.provider, - orchestrator: CloudOrchestrator::Remote, - region: existing_cloud.region, - size: existing_cloud.size, - install_image: existing_cloud.install_image, - remote_payload_file: Some(remote_payload_file), - ssh_key: existing_cloud.ssh_key, - key: existing_cloud.key, - server: existing_cloud.server, - public_ports: existing_cloud.public_ports, - }); - - let backup_path = format!("{}.bak", config_path); - std::fs::copy(config_path, &backup_path)?; - let yaml = serde_yaml::to_string(&config) - .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; - std::fs::write(config_path, yaml)?; + // Surgically update only the deploy section to avoid losing other keys. + // The JSON payload uses the short provider code ("htz") for the install service, + // but stacker.yml must use the config enum name ("hetzner") for round-trip parsing. + let cloud_provider_yaml = serde_yaml::to_string(&provider) + .unwrap_or_default() + .trim() + .to_string(); + edit_stacker_yml(path, |root| { + // Ensure deploy section exists + if !root.contains_key(&serde_yaml::Value::String("deploy".to_string())) { + root.insert( + serde_yaml::Value::String("deploy".to_string()), + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()), + ); + } + if let Some(deploy) = root.get_mut("deploy") { + if let Some(deploy_map) = deploy.as_mapping_mut() { + deploy_map.insert( + serde_yaml::Value::String("target".to_string()), + serde_yaml::Value::String("cloud".to_string()), + ); + // Update cloud section + if !deploy_map.contains_key(&serde_yaml::Value::String("cloud".to_string())) { + let mut cloud_map = serde_yaml::Mapping::new(); + cloud_map.insert( + serde_yaml::Value::String("provider".to_string()), + serde_yaml::Value::String(cloud_provider_yaml.clone()), + ); + cloud_map.insert( + serde_yaml::Value::String("orchestrator".to_string()), + serde_yaml::Value::String("remote".to_string()), + ); + deploy_map.insert( + serde_yaml::Value::String("cloud".to_string()), + serde_yaml::Value::Mapping(cloud_map), + ); + } + if let Some(cloud) = deploy_map.get_mut("cloud") { + if let Some(cloud_map) = cloud.as_mapping_mut() { + cloud_map.insert( + serde_yaml::Value::String("orchestrator".to_string()), + serde_yaml::Value::String("remote".to_string()), + ); + cloud_map.insert( + serde_yaml::Value::String("remote_payload_file".to_string()), + serde_yaml::Value::String( + remote_payload_file.to_string_lossy().to_string(), + ), + ); + } + } + } + } + Ok(()) + })?; Ok(vec![ format!( @@ -488,7 +533,6 @@ pub fn run_generate_remote_payload( "Set deploy.target=cloud and deploy.cloud.orchestrator=remote (advanced mode)".to_string(), "Tip: regular users can skip this and run `stacker deploy --target cloud` directly" .to_string(), - format!("Backup written to {}", backup_path), ]) } @@ -645,16 +689,62 @@ pub fn run_setup_ai( config.ai.timeout = timeout; config.ai.tasks = tasks; - let backup_path = format!("{}.bak", config_path); - std::fs::copy(config_path, &backup_path)?; - let yaml = serde_yaml::to_string(&config) - .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; - std::fs::write(config_path, yaml)?; + // Surgically update only the ai section to avoid losing other keys. + let ai_provider_str = config.ai.provider.to_string(); + let ai_model = config.ai.model.clone(); + let ai_endpoint = config.ai.endpoint.clone(); + let ai_tasks = config.ai.tasks.clone(); + edit_stacker_yml(path, |root| { + if !root.contains_key(&serde_yaml::Value::String("ai".to_string())) { + root.insert( + serde_yaml::Value::String("ai".to_string()), + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()), + ); + } + if let Some(ai) = root.get_mut("ai") { + if let Some(ai_map) = ai.as_mapping_mut() { + ai_map.insert( + serde_yaml::Value::String("enabled".to_string()), + serde_yaml::Value::Bool(true), + ); + ai_map.insert( + serde_yaml::Value::String("provider".to_string()), + serde_yaml::Value::String(ai_provider_str.clone()), + ); + if let Some(model) = &ai_model { + ai_map.insert( + serde_yaml::Value::String("model".to_string()), + serde_yaml::Value::String(model.clone()), + ); + } + if let Some(endpoint) = &ai_endpoint { + ai_map.insert( + serde_yaml::Value::String("endpoint".to_string()), + serde_yaml::Value::String(endpoint.clone()), + ); + } + ai_map.insert( + serde_yaml::Value::String("timeout".to_string()), + serde_yaml::Value::Number(timeout.into()), + ); + if !ai_tasks.is_empty() { + let tasks_seq: Vec = ai_tasks + .iter() + .map(|t| serde_yaml::Value::String(t.clone())) + .collect(); + ai_map.insert( + serde_yaml::Value::String("tasks".to_string()), + serde_yaml::Value::Sequence(tasks_seq), + ); + } + } + } + Ok(()) + })?; Ok(vec![ "Enabled ai configuration".to_string(), - format!("Set ai.provider={}", config.ai.provider), - format!("Backup written to {}", backup_path), + format!("Set ai.provider={}", ai_provider_str), ]) } @@ -960,7 +1050,10 @@ pub fn run_fix_interactive(config_path: &str) -> Result, CliError> { } /// Core validate logic — loads config, runs semantic checks, returns issues. -pub fn run_validate(config_path: &str) -> Result, CliError> { +pub fn run_validate( + config_path: &str, + target_override: Option<&str>, +) -> Result, CliError> { let path = Path::new(config_path); if !path.exists() { return Err(CliError::ConfigNotFound { @@ -985,9 +1078,21 @@ pub fn run_validate(config_path: &str) -> Result, CliError> { Err(_) => Vec::new(), }; - let config = StackerConfig::from_file(path)?; + let config = StackerConfig::from_file_for_target(path, target_override)?; let issues = config.validate_semantics(); - messages.extend(issues.iter().map(|i| format!("{:?}", i))); + messages.extend(issues.iter().map(|issue| { + let severity = match issue.severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "info", + }; + match &issue.field { + Some(field) => { + format!("[{}] {} ({}): {}", issue.code, severity, field, issue.message) + } + None => format!("[{}] {}: {}", issue.code, severity, issue.message), + } + })); Ok(messages) } @@ -1038,7 +1143,7 @@ pub fn run_show_resolved(config_path: &str) -> Result { Ok(format!( "resolved_config:\n local_env_file: {}\n remote_runtime_env_file: {}\n compose_env_file: {}\n config_version: local\n config_hash: unavailable_until_deploy\n runtime_env_contract_version: {}\n runtime_env_contract_order: {}\n layers:\n{}\n", local_env_file, - remote_runtime_env_path(), + remote_runtime_env_path_for(&crate::models::project::sanitize_project_name(&config.name)), compose_env_file_reference(), runtime_env_contract.version, runtime_env_contract.order, @@ -1059,18 +1164,19 @@ fn resolve_display_path(config_dir: &Path, env_file: &Path) -> String { /// Validates a stacker.yml configuration file. pub struct ConfigValidateCommand { pub file: Option, + pub target: Option, } impl ConfigValidateCommand { - pub fn new(file: Option) -> Self { - Self { file } + pub fn new(file: Option, target: Option) -> Self { + Self { file, target } } } impl CallableTrait for ConfigValidateCommand { fn call(&self) -> Result<(), Box> { let path = resolve_config_path(&self.file); - let issues = run_validate(&path)?; + let issues = run_validate(&path, self.target.as_deref())?; if issues.is_empty() { eprintln!("✓ Configuration is valid"); @@ -2187,14 +2293,14 @@ mod tests { fn test_validate_returns_ok_for_valid_config() { let dir = tempfile::TempDir::new().unwrap(); let path = write_config(dir.path(), minimal_config_yaml()); - let result = run_validate(&path).unwrap(); + let result = run_validate(&path, None).unwrap(); // Minimal valid config should have zero or few issues assert!(result.len() < 5); } #[test] fn test_validate_missing_file_returns_error() { - let result = run_validate("/nonexistent/stacker.yml"); + let result = run_validate("/nonexistent/stacker.yml", None); assert!(result.is_err()); } @@ -2211,7 +2317,7 @@ app: "#, ); - let issues = run_validate(&path).unwrap(); + let issues = run_validate(&path, None).unwrap(); assert!(issues.iter().any(|issue| issue.contains("app.path"))); assert!(issues .iter() diff --git a/src/console/commands/cli/deploy.rs b/src/console/commands/cli/deploy.rs index c3f2b28e..43630b3c 100644 --- a/src/console/commands/cli/deploy.rs +++ b/src/console/commands/cli/deploy.rs @@ -487,6 +487,69 @@ fn print_server_unreachable_hint(server: &ServerConfig, check: &ssh_client::Syst eprintln!(); } +/// Render the config-file proxy's routing file (caddy `Caddyfile`, nginx +/// `conf.d`) next to the generated compose, from `proxy.domains`. +/// +/// The synthesized caddy/nginx proxy service bind-mounts this file from the +/// compose directory. For `--target local` and `--target server` the tfa proxy +/// role never runs, so without this the mount points at a nonexistent path and +/// Docker silently creates an empty directory — the proxy then serves nothing. +/// This mirrors what the tfa caddy/nginx role renders on cloud deploys, so the +/// three targets produce identical routing. Traefik routes via labels and needs +/// no file; NPM has no file (routing lives in its DB) — both are no-ops here. +fn write_local_proxy_config(config: &StackerConfig, output_dir: &Path) -> Result<(), CliError> { + use crate::cli::config_parser::ProxyType; + use crate::cli::proxy_manager::{generate_caddy_server_block, generate_nginx_server_block}; + + if config.proxy.domains.is_empty() { + return Ok(()); + } + + match config.proxy.proxy_type { + ProxyType::Caddy => { + let mut content = String::new(); + // Optional global ACME email block (matches the tfa caddy role). + if let Some(email) = config + .install + .inputs + .get("admin_email") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + content.push_str(&format!("{{\n email {}\n}}\n\n", email)); + } + for domain in &config.proxy.domains { + content.push_str(&generate_caddy_server_block(domain)?); + } + let path = output_dir.join("Caddyfile"); + std::fs::write(&path, content)?; + eprintln!( + " Generated {}/Caddyfile from proxy.domains ({} site(s))", + OUTPUT_DIR, + config.proxy.domains.len() + ); + } + ProxyType::Nginx => { + let mut content = String::new(); + for domain in &config.proxy.domains { + content.push_str(&generate_nginx_server_block(domain)?); + } + // The nginx service mounts ./nginx/conf.d; nginx.conf includes *.conf. + let conf_dir = output_dir.join("nginx").join("conf.d"); + std::fs::create_dir_all(&conf_dir)?; + std::fs::write(conf_dir.join("stacker.conf"), content)?; + eprintln!( + " Generated {}/nginx/conf.d/stacker.conf from proxy.domains ({} site(s))", + OUTPUT_DIR, + config.proxy.domains.len() + ); + } + _ => {} + } + Ok(()) +} + fn normalize_generated_compose_paths(compose_path: &Path) -> Result<(), CliError> { let is_stacker_compose = compose_path .components() @@ -2870,6 +2933,18 @@ pub fn run_deploy_for_environment_with_policy( ) } +/// True when `host` is still a literal, unresolved `${VAR}` placeholder. +/// +/// `StackerConfig::from_file_for_target` intentionally leaves `deploy.server` +/// unresolved for a cloud deploy (its env vars aren't required — GH #239). +/// Any code that inspects `deploy.server.host` for a cloud-target deploy +/// must check this first: `is_private_host` would otherwise misclassify the +/// template string itself as a private/intranet host (it contains no '.') +/// and silently switch the deploy target based on garbage. +fn host_is_unresolved_placeholder(host: &str) -> bool { + host.contains("${") && host.contains('}') +} + #[allow(clippy::too_many_arguments)] fn run_deploy_with_credentials_manager( project_dir: &Path, @@ -2891,8 +2966,8 @@ fn run_deploy_with_credentials_manager( None => project_dir.join(DEFAULT_CONFIG_FILE), }; - let mut config = - StackerConfig::from_file(&config_path)?.with_resolved_deploy_target(target_override)?; + let mut config = StackerConfig::from_file_for_target(&config_path, target_override)? + .with_resolved_deploy_target(target_override)?; let selected_environment = if let Some((environment, environment_config)) = config.resolve_environment_config(environment_override)? { @@ -2922,7 +2997,9 @@ fn run_deploy_with_credentials_manager( let mut lock_server_name: Option = None; if deploy_target == DeployTarget::Cloud && !force_new { if let Some(ref server_cfg) = config.deploy.server { - if crate::helpers::ip::is_private_host(&server_cfg.host) { + if host_is_unresolved_placeholder(&server_cfg.host) { + // fall through — cloud target stays as-is + } else if crate::helpers::ip::is_private_host(&server_cfg.host) { // Private/intranet host — skip SSH check and switch to Server target. // The russh client cannot reliably reach intranet IPs from the CLI's // routing stack (EHOSTUNREACH), but the local SSH binary can. Trust @@ -3214,6 +3291,15 @@ fn run_deploy_with_credentials_manager( if force_rebuild || !compose_out.exists() { let compose = ComposeDefinition::try_from(&config)?; compose.write_to(&compose_out, force_rebuild)?; + // The synthesized caddy/nginx proxy service mounts a config file + // (./Caddyfile, ./nginx/conf.d) from the compose directory. For + // local/server deploys the tfa proxy role does NOT run, so the + // CLI must render that file itself — otherwise Docker bind-mounts + // a nonexistent path (creating an empty directory) and the proxy + // serves nothing. Cloud deploys strip this service and let the + // role render it remotely, so the generated file is simply unused + // there. Idempotent-friendly: regenerated alongside the compose. + write_local_proxy_config(&config, &output_dir)?; } else { eprintln!( " Using existing {}/docker-compose.yml (use --force-rebuild to regenerate)", @@ -3351,7 +3437,7 @@ fn run_deploy_with_credentials_manager( let origin_trusted = config.is_trusted(); - // 6a. Execute pre-build hook + // 6a. Execute pre-build hook (always runs locally — builds happen locally). if !dry_run { run_hook( executor, @@ -3366,6 +3452,12 @@ fn run_deploy_with_credentials_manager( // 6b. Deploy let deploy_result = strategy.deploy(&config, &context, executor); + // Hooks that reference application binaries (post_deploy, on_failure) + // only make sense for local deploys. For cloud/server deploys, the app + // runs on the remote host — running the hook locally would fail with + // "command not found" and is a security concern (arbitrary remote exec). + let is_remote = matches!(deploy_target, DeployTarget::Cloud | DeployTarget::Server); + match deploy_result { Ok(result) => { // 6c. Execute post-deploy hook on success. @@ -3377,21 +3469,27 @@ fn run_deploy_with_credentials_manager( // scrolls off the terminal. A clean-content hook that just // exits non-zero at runtime stays best-effort (WARN + Ok). if !dry_run { - match run_hook( - executor, - project_dir, - &config.hooks.post_deploy, - "post_deploy", - hook_policy, - origin_trusted, - ) { - Ok(()) => {} - Err(err @ CliError::HookRejected { .. }) => { - eprintln!(" [ERROR] {}", err); - return Err(err); + if is_remote { + if config.hooks.post_deploy.is_some() { + eprintln!(" ℹ post_deploy hook skipped — hooks run locally and cannot reach the remote host."); } - Err(err) => { - eprintln!(" [WARN] post_deploy hook failed: {}", err); + } else { + match run_hook( + executor, + project_dir, + &config.hooks.post_deploy, + "post_deploy", + hook_policy, + origin_trusted, + ) { + Ok(()) => {} + Err(err @ CliError::HookRejected { .. }) => { + eprintln!(" [ERROR] {}", err); + return Err(err); + } + Err(err) => { + eprintln!(" [WARN] post_deploy hook failed: {}", err); + } } } } @@ -3406,7 +3504,7 @@ fn run_deploy_with_credentials_manager( // into the returned error, so the operator sees BOTH the // primary cause AND the fact that a hostile cleanup script // was refused. - if !dry_run { + if !dry_run && !is_remote { match run_hook( executor, project_dir, @@ -3607,7 +3705,7 @@ impl CallableTrait for DeployCommand { // Ansible provisioning fails (e.g. nginx_proxy_manager port-conflict bug), // so the user can always SSH into the created server. self.save_local_backup_keypair_early(&result); - watch_outcome = watch_cloud_deployment(&result)?; + watch_outcome = watch_cloud_deployment(&result, self.force_new)?; } _ => {} } @@ -3662,6 +3760,7 @@ impl DeployCommand { project_id as i32, DeployTarget::Cloud, result.server_name.as_deref(), + false, ) { Ok(Some(server)) => server, _ => return, @@ -3696,6 +3795,7 @@ impl DeployCommand { project_id as i32, DeployTarget::Cloud, result.server_name.as_deref(), + false, ) { Ok(Some(server)) => server, Ok(None) => { @@ -3820,6 +3920,16 @@ impl DeployCommand { if l.ssh_port.is_none() { l.ssh_port = Some(server_cfg.port); } + // `from_result` always initializes ssh_key to None — + // without this, every successful server deploy wrote + // ssh_key: null to the lock regardless of what was + // correctly configured/resolved in stacker.yml, so any + // later command that reads the *lock* (not + // stacker.yml) for connection details would fail SSH + // key auth. See GH issue #225. + if l.ssh_key.is_none() { + l.ssh_key = server_cfg.ssh_key.clone(); + } } } @@ -3829,6 +3939,7 @@ impl DeployCommand { project_id as i32, DeployTarget::Server, result.server_name.as_deref(), + false, ) { Ok(Some(info)) => { l = l.with_server_info( @@ -3883,6 +3994,7 @@ impl DeployCommand { project_id as i32, DeployTarget::Cloud, result.server_name.as_deref(), + self.force_new, ) { Ok(Some(info)) => { l = l.with_server_info( @@ -4005,6 +4117,7 @@ fn fetch_server_for_project( project_id: i32, target: DeployTarget, preferred_server_name: Option<&str>, + force_new: bool, ) -> Result, Box> { use std::time::Duration; @@ -4024,6 +4137,7 @@ fn fetch_server_for_project( let deploy_timeout = Duration::from_secs(600); let deploy_start = std::time::Instant::now(); let mut fallback_server_ip: Option = None; + let mut deployment_failed = false; loop { match client.get_deployment_status_by_project(project_id).await { @@ -4033,7 +4147,8 @@ fn fetch_server_for_project( .as_deref() .and_then(extract_ipv4_from_text) }); - if info.status != "completed" { + deployment_failed = info.status != "completed"; + if deployment_failed { eprintln!( " Deployment #{} finished with status '{}' — server IP may not be available.", info.id, info.status @@ -4067,13 +4182,15 @@ fn fetch_server_for_project( } // Phase 2: deployment is terminal (or timed out) — poll for the server IP. - let ip_retries = 6; + // When the deployment failed, skip the retry loop — no point waiting for + // an IP when provisioning failed. + let ip_retries = if deployment_failed { 1 } else { 6 }; let ip_delay = Duration::from_secs(10); for attempt in 0..ip_retries { let servers = client.list_servers().await?; - let server = choose_server_for_project(servers, project_id, preferred_server_name); + let server = choose_server_for_project(servers, project_id, preferred_server_name, force_new); match server { Some(ref s) if s.srv_ip.is_some() => { @@ -4127,12 +4244,20 @@ fn choose_server_for_project( servers: Vec, project_id: i32, preferred_server_name: Option<&str>, + force_new: bool, ) -> Option { let mut matching: Vec = servers .into_iter() .filter(|server| server.project_id == project_id) .collect(); + // When --force-new is set, prefer the most recently created server + // (highest id) to avoid picking a stale server from a previous deploy. + if force_new && matching.len() > 1 { + matching.sort_by(|a, b| b.id.cmp(&a.id)); + return matching.into_iter().next(); + } + if let Some(preferred_name) = preferred_server_name .map(str::trim) .filter(|name| !name.is_empty()) @@ -4301,6 +4426,7 @@ enum DeploymentWatchOutcome { /// Watch remote deployment status until it reaches a terminal state. fn watch_cloud_deployment( result: &DeployResult, + force_new: bool, ) -> Result> { use std::time::Duration; @@ -4337,8 +4463,42 @@ fn watch_cloud_deployment( let start = std::time::Instant::now(); let mut last_status = String::new(); let mut last_message: Option = None; + // For `--force-new` cloud deploys the server is created *during* + // provisioning, so the pre-watch keypair save finds no server and + // skips. Save the local backup keypair the moment the server first + // appears here, so an interrupted watch (Ctrl-C / timeout) never + // leaves a reachable server without a local key. Runs once. + let mut keypair_saved = false; loop { + if !keypair_saved && result.target == DeployTarget::Cloud { + if let Ok(servers) = client.list_servers().await { + if let Some(server) = choose_server_for_project( + servers, + project_id, + result.server_name.as_deref(), + force_new, + ) { + match crate::console::commands::cli::ssh_key::ensure_local_backup_keypair( + server.id, + ) { + Ok(keypair) => { + eprintln!( + " ✓ Local SSH backup key saved: {}", + keypair.private_key_path.display() + ); + keypair_saved = true; + } + Err(err) => { + eprintln!(" ⚠ Could not save local backup SSH key: {}", err); + // Don't hammer on a persistent failure. + keypair_saved = true; + } + } + } + } + } + match client.get_deployment_status_by_project(project_id).await { Ok(Some(info)) => { let status_changed = info.status != last_status; @@ -4586,7 +4746,7 @@ services: server_info(3, 75, Some("coolify-current"), None), ]; - let selected = choose_server_for_project(servers, 75, Some("coolify-current")) + let selected = choose_server_for_project(servers, 75, Some("coolify-current"), false) .expect("matching server should be selected"); assert_eq!(selected.id, 2); @@ -4601,12 +4761,25 @@ services: server_info(3, 75, Some("ready"), Some("203.0.113.42")), ]; - let selected = choose_server_for_project(servers, 75, None) + let selected = choose_server_for_project(servers, 75, None, false) .expect("server with IP should be selected"); assert_eq!(selected.id, 3); } + #[test] + fn choose_server_for_project_prefers_newest_on_force_new() { + let servers = vec![ + server_info(10, 75, Some("old-server"), Some("203.0.113.10")), + server_info(20, 75, Some("new-server"), None), + ]; + + let selected = choose_server_for_project(servers, 75, None, true) + .expect("newest server should be selected on force_new"); + + assert_eq!(selected.id, 20); + } + #[test] fn extracts_server_ip_from_deployment_status_message() { assert_eq!( @@ -4904,6 +5077,138 @@ services: ); } + #[test] + fn test_host_is_unresolved_placeholder() { + assert!(host_is_unresolved_placeholder("${EXISTING_SERVER_HOST}")); + assert!(host_is_unresolved_placeholder("${BASE_PATH}/host")); + assert!(!host_is_unresolved_placeholder("203.0.113.10")); + assert!(!host_is_unresolved_placeholder("my.example.com")); + assert!(!host_is_unresolved_placeholder("")); + } + + // Regression test for GH #239: a dual-target stacker.yml with both + // deploy.server (host: ${EXISTING_SERVER_HOST}) and deploy.cloud + // previously crashed on the missing env var entirely. After that fix, + // `deploy.server` is left deliberately unresolved for a cloud deploy — + // but the pre-existing "auto-switch to server if deploy.server looks + // private/intranet" check then misclassified the literal `${...}` + // placeholder as a private host (it contains no '.') and silently + // switched the target, attempting a nonsense server deploy instead of + // the requested cloud one. + #[test] + fn test_deploy_cloud_with_unresolved_server_placeholder_stays_cloud() { + let config = "name: test-app\napp:\n type: static\n path: .\n\ +deploy:\n target: server\n server:\n host: ${EXISTING_SERVER_HOST}\n user: ${EXISTING_SERVER_USER}\n\ + cloud:\n provider: hetzner\n region: eu-central\n size: cpx11\n"; + let dir = setup_local_project(&[("stacker.yml", config)]); + let executor = MockExecutor::success(); + let store = FileCredentialStore::new(dir.path().join("credentials.json")); + let cred_manager = CredentialsManager::new(store); + cred_manager + .save(&StoredCredentials { + access_token: "test-token".to_string(), + refresh_token: None, + token_type: "Bearer".to_string(), + expires_at: chrono::Utc::now() + chrono::Duration::hours(1), + email: Some("test@example.com".to_string()), + server_url: Some("https://example.test".to_string()), + org: None, + domain: None, + }) + .unwrap(); + + // No EXISTING_SERVER_HOST/EXISTING_SERVER_USER set anywhere. This + // dry-run still reaches out to the (fake, unreachable) Stacker API + // to resolve the cloud project, so it's expected to fail here on a + // network/DNS error — the point of this test is that the failure's + // `target` stays Cloud, proving the deploy target was never + // silently switched to Server by the unresolved-placeholder host. + let result = run_deploy_with_credentials_manager( + dir.path(), + None, + Some("cloud"), + None, + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + &cred_manager, + HookPolicy::default(), + ); + + match result { + Err(CliError::DeployFailed { target, .. }) => { + assert_eq!( + target, + DeployTarget::Cloud, + "deploy target must not be silently switched to Server because of an \ + unresolved deploy.server.host placeholder" + ); + } + other => panic!("expected a DeployFailed(target: Cloud) error, got: {:?}", other), + } + } + + // Regression test for GH issue #225: `stacker deploy --target server` + // (and by extension `stacker target server`, which reuses the same + // lock) permanently wrote `ssh_key: null` to the deployment lock after + // a successful deploy, because `DeploymentLock::from_result` always + // initializes `ssh_key: None` and `save_deployment_lock`'s + // DeployTarget::Server branch copied server_ip/ssh_user/ssh_port from + // `stacker.yml`'s resolved `deploy.server` but never copied `ssh_key`. + // Any later command reading the lock (not stacker.yml) for connection + // details then failed SSH key auth, regardless of how correctly + // EXISTING_SERVER_KEY-style env vars were configured. + #[test] + fn test_save_deployment_lock_carries_ssh_key_for_server_target() { + let config = "name: test-app\napp:\n type: static\n path: .\n\ +deploy:\n target: server\n server:\n host: 203.0.113.5\n user: deploy\n ssh_key: /home/me/.ssh/stacker-project-test\n"; + let dir = setup_local_project(&[("stacker.yml", config)]); + + let cmd = DeployCommand { + service: None, + target: Some("server".to_string()), + environment: None, + file: None, + dry_run: false, + force_rebuild: false, + project_name: None, + key_name: None, + key_id: None, + server_name: None, + watch: None, + lock: false, + force_new: false, + runtime: "runc".to_string(), + plan: false, + apply_plan: None, + no_hooks: false, + allow_untrusted_hooks: false, + notify: false, + }; + let result = DeployResult { + target: DeployTarget::Server, + message: "ok".to_string(), + server_ip: None, + deployment_id: None, + project_id: None, + server_name: None, + }; + + cmd.save_deployment_lock(dir.path(), &result, false).unwrap(); + + let lock = DeploymentLock::load_for_target(dir.path(), "server") + .unwrap() + .expect("server lock should have been written"); + assert_eq!( + lock.ssh_key, + Some(PathBuf::from("/home/me/.ssh/stacker-project-test")), + "ssh_key from stacker.yml's deploy.server must survive into the lock" + ); + } + #[test] fn test_deploy_cloud_requires_provider() { // Cloud target but no cloud config @@ -5153,6 +5458,39 @@ services: ); } + #[test] + fn write_local_proxy_config_renders_caddyfile_from_domains() { + // The synthesized caddy service mounts ./Caddyfile; for local/server + // deploys the CLI must render it so the bind mount is a real file. + let config = StackerConfig::from_str( + "name: t\napp:\n type: custom\n image: app:1\nproxy:\n type: caddy\n domains:\n - domain: a.example.com\n ssl: \"off\"\n upstream: app:80\n - domain: b.example.com\n ssl: auto\n upstream: api:9000\n", + ) + .unwrap(); + let dir = tempfile::tempdir().unwrap(); + write_local_proxy_config(&config, dir.path()).unwrap(); + let caddyfile = std::fs::read_to_string(dir.path().join("Caddyfile")).unwrap(); + assert!( + caddyfile.contains("http://a.example.com {") && caddyfile.contains("reverse_proxy app:80"), + "ssl:off site must use http:// scheme:\n{caddyfile}" + ); + assert!( + caddyfile.contains("b.example.com {") && caddyfile.contains("reverse_proxy api:9000"), + "ssl:auto site must use the bare domain:\n{caddyfile}" + ); + } + + #[test] + fn write_local_proxy_config_noop_without_domains() { + // No proxy.domains → nothing to render, no file written. + let config = StackerConfig::from_str( + "name: t\napp:\n type: custom\n image: app:1\nproxy:\n type: caddy\n", + ) + .unwrap(); + let dir = tempfile::tempdir().unwrap(); + write_local_proxy_config(&config, dir.path()).unwrap(); + assert!(!dir.path().join("Caddyfile").exists()); + } + #[test] fn test_deploy_runs_post_deploy_hook_on_success() { let config = diff --git a/src/console/commands/cli/destroy.rs b/src/console/commands/cli/destroy.rs index 1acff6fa..0a8ec7e4 100644 --- a/src/console/commands/cli/destroy.rs +++ b/src/console/commands/cli/destroy.rs @@ -3,10 +3,9 @@ use std::path::Path; use crate::cli::config_parser::DeployTarget; use crate::cli::error::CliError; use crate::cli::install_runner::{CommandExecutor, ShellExecutor}; -use crate::cli::local_compose::resolve_local_compose_path; +use crate::cli::local_compose::{resolve_local_compose_path, resolve_local_compose_project_name}; use crate::console::commands::CallableTrait; -#[allow(dead_code)] const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; /// `stacker destroy [--volumes] [--confirm]` @@ -24,9 +23,20 @@ impl DestroyCommand { } /// Build `docker compose down` arguments. -pub fn build_destroy_args(compose_path: &str, volumes: bool) -> Vec { +/// +/// `project_name` MUST be passed via `-p` — without it Compose falls back to +/// the compose file's containing directory basename, which is the same +/// `.stacker/` for every project, defaulting to the shared project name +/// "stacker" for all of them. `down` on that shared scope removes/orphans +/// *any* running container whose service name happens to match one in the +/// current project's compose file, regardless of which project actually +/// started it — the same collision `LocalDeploy::deploy`/`destroy` in +/// install_runner.rs were fixed for. See GH issue #235. +pub fn build_destroy_args(compose_path: &str, project_name: &str, volumes: bool) -> Vec { let mut args = vec![ "compose".to_string(), + "-p".to_string(), + project_name.to_string(), "-f".to_string(), compose_path.to_string(), "down".to_string(), @@ -61,7 +71,8 @@ pub fn run_destroy( })?; let compose_str = compose_path.to_string_lossy().to_string(); - let args = build_destroy_args(&compose_str, volumes); + let project_name = resolve_local_compose_project_name(project_dir); + let args = build_destroy_args(&compose_str, &project_name, volumes); let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); let output = executor.execute("docker", &args_refs)?; @@ -149,10 +160,64 @@ mod tests { #[test] fn test_destroy_with_volumes_flag() { - let args = build_destroy_args("/path/compose.yml", true); + let args = build_destroy_args("/path/compose.yml", "myproject", true); assert!(args.contains(&"--volumes".to_string())); } + // Regression test for GH issue #235: `stacker destroy` previously never + // passed `-p `, so Compose fell back to the shared ".stacker" + // directory-basename project name ("stacker") for every project — a + // `destroy` in one project's directory could remove/orphan another, + // unrelated project's containers sharing that same default scope. + #[test] + fn test_destroy_namespaces_compose_project_by_identity() { + let dir = setup_with_compose(); + std::fs::write( + dir.path().join(DEFAULT_CONFIG_FILE), + "name: Miniflux Prod\ndeploy:\n target: local\n", + ) + .unwrap(); + let executor = MockExecutor::new(); + + run_destroy(dir.path(), false, true, &executor).unwrap(); + + let calls = executor.recorded_calls(); + assert_eq!(calls.len(), 1); + let args = &calls[0].1; + let p_index = args + .iter() + .position(|a| a == "-p") + .expect("docker compose down should pass -p "); + assert_eq!( + args.get(p_index + 1).map(String::as_str), + Some("miniflux-prod"), + "project name should be derived from stacker.yml's name/identity, not the \ + compose file's directory, got args: {:?}", + args + ); + } + + #[test] + fn test_destroy_uses_project_identity_over_name_for_project_name() { + let dir = setup_with_compose(); + std::fs::write( + dir.path().join(DEFAULT_CONFIG_FILE), + "name: stacker\nproject:\n identity: miniflux-blue\ndeploy:\n target: local\n", + ) + .unwrap(); + let executor = MockExecutor::new(); + + run_destroy(dir.path(), false, true, &executor).unwrap(); + + let calls = executor.recorded_calls(); + let args = &calls[0].1; + let p_index = args.iter().position(|a| a == "-p").unwrap(); + assert_eq!( + args.get(p_index + 1).map(String::as_str), + Some("miniflux-blue") + ); + } + #[test] fn test_destroy_requires_confirmation() { let dir = setup_with_compose(); @@ -195,8 +260,10 @@ mod tests { let calls = executor.recorded_calls(); assert_eq!(calls.len(), 1); + let args = &calls[0].1; + let f_index = args.iter().position(|a| a == "-f").unwrap(); assert_eq!( - calls[0].1[2], + args[f_index + 1], dir.path() .join("docker/local/compose.yml") .to_string_lossy() diff --git a/src/console/commands/cli/explain.rs b/src/console/commands/cli/explain.rs index 416056c3..0056e2c2 100644 --- a/src/console/commands/cli/explain.rs +++ b/src/console/commands/cli/explain.rs @@ -3,7 +3,8 @@ use std::path::{Path, PathBuf}; use crate::cli::config_parser::{ServiceDefinition, StackerConfig}; use crate::cli::error::CliError; use crate::console::commands::CallableTrait; -use crate::helpers::{remote_runtime_compose_path, remote_runtime_env_path}; +use crate::helpers::{remote_runtime_compose_path_for, remote_runtime_env_path_for}; +use crate::models::project::sanitize_project_name; use crate::services::config_renderer::EnvRenderInput; use crate::services::{ build_explain_env, build_explain_topology, ExplainTopologyService, TypedErrorEnvelope, @@ -128,12 +129,13 @@ impl ExplainEnvCommand { .clone() .unwrap_or_else(|| "unbound".to_string()); let local_env_path = resolve_local_env_path(&project_dir, &config)?; + let stack_code = sanitize_project_name(&config.name); let explain = build_explain_env( &deployment_hash, &self.app, &local_env_path.to_string_lossy(), - remote_runtime_env_path(), - remote_runtime_compose_path(), + &remote_runtime_env_path_for(&stack_code), + &remote_runtime_compose_path_for(&stack_code), build_env_input(&config, &self.app)?, ) .map_err(|err| CliError::ConfigValidation(err.to_string()))?; @@ -198,13 +200,14 @@ impl CallableTrait for ExplainTopologyCommand { }), ); + let stack_code = sanitize_project_name(&config.name); let topology = build_explain_topology( &deployment_hash, &config.deploy.target.to_string(), &local_compose_path.to_string_lossy(), - remote_runtime_compose_path(), + &remote_runtime_compose_path_for(&stack_code), &local_env_path.to_string_lossy(), - remote_runtime_env_path(), + &remote_runtime_env_path_for(&stack_code), services, ); diff --git a/src/console/commands/cli/init.rs b/src/console/commands/cli/init.rs index ec5d6905..b774e5c2 100644 --- a/src/console/commands/cli/init.rs +++ b/src/console/commands/cli/init.rs @@ -1659,6 +1659,7 @@ fn convert_compose_to_stacker(ai_output: &str, repo_name: &str) -> Option::new()); assert!(rendered.contains("target: local")); diff --git a/src/console/commands/cli/mod.rs b/src/console/commands/cli/mod.rs index 05e17aae..c98a2068 100644 --- a/src/console/commands/cli/mod.rs +++ b/src/console/commands/cli/mod.rs @@ -6,6 +6,7 @@ pub mod config; pub mod connect; pub mod deploy; pub mod deployment; +pub mod monitor; pub mod destroy; pub mod explain; pub mod init; diff --git a/src/console/commands/cli/monitor.rs b/src/console/commands/cli/monitor.rs new file mode 100644 index 00000000..57c486ad --- /dev/null +++ b/src/console/commands/cli/monitor.rs @@ -0,0 +1,156 @@ +//! `stacker monitor` — CLI-side container-health alarm + basic scheduler. +//! +//! Polls the deployment's live container health (via the Status Panel agent), +//! runs the pure [`health_monitor`] engine to edge-detect down/recovery +//! transitions, and fires the configured `monitoring.alerts.notify` target on a +//! change. State is persisted to `.stacker/monitor.state` so `--once` (cron) +//! invocations stay edge-triggered across runs. +//! +//! The alarm logic lives in the standalone `health-monitor` crate; this module +//! is just the I/O + scheduler shell around it. + +use std::io::Write as _; +use std::path::PathBuf; + +use health_monitor::{alert_message, detect_transition, parse_container_health, WatchState}; + +use crate::cli::config_parser::AlertTarget; +use crate::cli::error::CliError; +use crate::cli::runtime::CliRuntime; +use crate::console::commands::CallableTrait; + +pub struct MonitorCommand { + /// Run a single check and exit (cron-friendly). Otherwise loops forever. + pub once: bool, + /// Override the poll interval (seconds) from config. + pub interval: Option, + pub deployment: Option, +} + +impl MonitorCommand { + pub fn new(once: bool, interval: Option, deployment: Option) -> Self { + Self { + once, + interval, + deployment, + } + } +} + +fn state_path() -> PathBuf { + PathBuf::from(".stacker").join("monitor.state") +} + +fn read_state() -> WatchState { + std::fs::read_to_string(state_path()) + .ok() + .and_then(|s| serde_json::from_str(s.trim()).ok()) + .unwrap_or_default() +} + +fn write_state(state: WatchState) { + if let Some(parent) = state_path().parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string(&state) { + if let Ok(mut f) = std::fs::File::create(state_path()) { + let _ = f.write_all(json.as_bytes()); + } + } +} + +/// Deliver an alert message via the configured `AlertTarget`. +/// - `Terminal` → OS/terminal notification (this is the "notify in terminal" target). +/// - `Webhook` → HTTP POST (ntfy/Slack/…). +/// - `Pipe` → acknowledged but deferred (needs the pipe runtime wired — follow-up). +fn dispatch(target: &AlertTarget, message: &str) -> Result<(), CliError> { + match target { + AlertTarget::Terminal { .. } => { + crate::cli::notify::notify_message("Stacker container alert", message); + Ok(()) + } + AlertTarget::Webhook { url, method } => { + let client = reqwest::blocking::Client::new(); + let method = reqwest::Method::from_bytes(method.to_ascii_uppercase().as_bytes()) + .unwrap_or(reqwest::Method::POST); + let resp = client + .request(method, url) + .body(message.to_string()) + .send() + .map_err(|e| CliError::ConfigValidation(format!("Alert delivery failed: {e}")))?; + if !resp.status().is_success() { + return Err(CliError::ConfigValidation(format!( + "Alert endpoint returned {}", + resp.status() + ))); + } + Ok(()) + } + AlertTarget::Pipe { pipe } => { + eprintln!( + " (alert would run pipe '{pipe}', but pipe-target alerts aren't wired yet — \ + use a terminal or webhook target for now)" + ); + Ok(()) + } + } +} + +impl CallableTrait for MonitorCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let config = crate::cli::config_parser::StackerConfig::from_file( + &project_dir.join("stacker.yml"), + ) + .map_err(|e| CliError::ConfigValidation(format!("Failed to read stacker.yml: {e}")))?; + + let Some(alerts) = config.monitoring.alerts.clone() else { + return Err(CliError::ConfigValidation( + "No `monitoring.alerts` configured in stacker.yml. Add an `alerts:` block with a \ + `notify:` target to enable the container-down alarm." + .into(), + ) + .into()); + }; + let interval = self.interval.unwrap_or(alerts.interval).max(1); + + let ctx = CliRuntime::new("monitor")?; + let hash = super::agent::resolve_deployment_hash(&self.deployment, &ctx)?; + + loop { + // One check cycle. + match super::agent::fetch_live_containers(&ctx, &hash) { + Ok(containers) => { + let raw = serde_json::Value::Array(containers.unwrap_or_default()); + let snapshot = parse_container_health(&raw); + let prev = read_state(); + let transition = detect_transition(prev, &snapshot); + + if let Some(message) = alert_message(&transition, alerts.on_recovery) { + println!("● {message}"); + if let Err(e) = dispatch(&alerts.target, &message) { + eprintln!(" alert dispatch error: {e}"); + } + } else { + println!( + "· {} container(s), all healthy", + snapshot.len() + ); + } + // Persist the *current* health as the new baseline. + write_state(WatchState::from(health_monitor::evaluate(&snapshot))); + } + Err(e) => { + // A transient fetch failure shouldn't kill a long-running watch. + eprintln!(" health fetch failed: {e}"); + } + } + + if self.once { + break; + } + std::thread::sleep(std::time::Duration::from_secs(interval)); + } + Ok(()) + } +} diff --git a/src/console/commands/cli/pipe.rs b/src/console/commands/cli/pipe.rs index 239b1e94..69df272c 100644 --- a/src/console/commands/cli/pipe.rs +++ b/src/console/commands/cli/pipe.rs @@ -2145,11 +2145,27 @@ pub struct PipeCreateCommand { pub ai: bool, pub no_ai: bool, pub ml: bool, + /// Manual "METHOD /path" source endpoint; when set (with target_endpoint) + /// discovery is skipped entirely. + pub source_endpoint: Option, + pub target_endpoint: Option, + pub source_fields: Vec, + pub target_fields: Vec, + pub name: Option, + /// Retry policy for delivery, written into the pipe's `config` so the agent + /// can honor it. `None` on each → engine default (3 / 1000ms / 30000ms). + pub retry: Option, + pub retry_backoff_ms: Option, + pub retry_backoff_max_ms: Option, + /// Lifecycle handlers (another pipe by name) → pipe `config`. + pub on_failure: Option, + pub on_success: Option, pub json: bool, pub deployment: Option, } impl PipeCreateCommand { + #[allow(clippy::too_many_arguments)] pub fn new( source: String, target: String, @@ -2157,6 +2173,16 @@ impl PipeCreateCommand { ai: bool, no_ai: bool, ml: bool, + source_endpoint: Option, + target_endpoint: Option, + source_fields: Vec, + target_fields: Vec, + name: Option, + retry: Option, + retry_backoff_ms: Option, + retry_backoff_max_ms: Option, + on_failure: Option, + on_success: Option, json: bool, deployment: Option, ) -> Self { @@ -2167,10 +2193,46 @@ impl PipeCreateCommand { ai, no_ai, ml, + source_endpoint, + target_endpoint, + source_fields, + target_fields, + name, + retry, + retry_backoff_ms, + retry_backoff_max_ms, + on_failure, + on_success, json, deployment, } } + + /// Build the typed `PipeConfig` from the retry/handler flags (empty when no + /// flag was given, so existing behavior is unchanged). + fn pipe_config_from_flags(&self) -> crate::models::pipe_config::PipeConfig { + use crate::models::agent_protocol::RetryPolicy; + use crate::models::pipe_config::{HandlerRef, PipeConfig}; + + let default = RetryPolicy::default(); + let retry = if self.retry.is_some() + || self.retry_backoff_ms.is_some() + || self.retry_backoff_max_ms.is_some() + { + Some(RetryPolicy { + max_retries: self.retry.unwrap_or(default.max_retries), + backoff_base_ms: self.retry_backoff_ms.unwrap_or(default.backoff_base_ms), + backoff_max_ms: self.retry_backoff_max_ms.unwrap_or(default.backoff_max_ms), + }) + } else { + None + }; + PipeConfig { + retry, + on_failure: self.on_failure.clone().map(HandlerRef::Pipe), + on_success: self.on_success.clone().map(HandlerRef::Pipe), + } + } } #[derive(Debug, Clone)] @@ -2843,6 +2905,106 @@ mod selectable_operation_tests { use serde_json::json; use tempfile::tempdir; + #[test] + fn parse_manual_endpoint_splits_method_and_path() { + assert_eq!( + parse_manual_endpoint("POST /pipetest"), + ("POST".to_string(), "/pipetest".to_string()) + ); + // lowercase method is upper-cased + assert_eq!( + parse_manual_endpoint("get /items"), + ("GET".to_string(), "/items".to_string()) + ); + // bare path defaults to GET + assert_eq!( + parse_manual_endpoint("/status"), + ("GET".to_string(), "/status".to_string()) + ); + } + + #[test] + fn manual_operation_carries_fields_and_no_container() { + let op = manual_operation("POST /pipetest", &["message".to_string()]); + assert_eq!(op.method, "POST"); + assert_eq!(op.path, "/pipetest"); + assert_eq!(op.fields, vec!["message".to_string()]); + assert!(op.container.is_none() && op.adapter.is_none()); + } + + #[test] + fn build_manual_field_mapping_matches_by_name_then_position_then_identity() { + // same-name match + let m = build_manual_field_mapping(&["message".into()], &["message".into()]); + assert_eq!(m["message"], json!("$.message")); + // positional fallback when names differ + let m = build_manual_field_mapping(&["body".into()], &["message".into()]); + assert_eq!(m["message"], json!("$.body")); + // identity when no source field is available + let m = build_manual_field_mapping(&[], &["message".into()]); + assert_eq!(m["message"], json!("$.message")); + // empty target → empty (pass-through) mapping + assert_eq!(build_manual_field_mapping(&["x".into()], &[]), json!({})); + } + + /// Build a bare create command, overriding only the resilience flags. + fn create_cmd_with_retry( + retry: Option, + base: Option, + max: Option, + on_failure: Option<&str>, + ) -> PipeCreateCommand { + PipeCreateCommand::new( + "app".into(), + "ntfy".into(), + false, + false, + false, + false, + None, + None, + vec![], + vec![], + None, + retry, + base, + max, + on_failure.map(str::to_string), + None, + false, + None, + ) + } + + #[test] + fn pipe_config_from_flags_is_empty_without_flags() { + // No resilience flags → default (empty) config, so create is unchanged. + let cmd = create_cmd_with_retry(None, None, None, None); + assert_eq!( + cmd.pipe_config_from_flags(), + crate::models::pipe_config::PipeConfig::default() + ); + } + + #[test] + fn pipe_config_from_flags_builds_retry_and_handler() { + // A single --retry fills the rest of the policy from engine defaults. + let cmd = create_cmd_with_retry(Some(5), None, None, Some("oncall")); + let cfg = cmd.pipe_config_from_flags(); + let retry = cfg.retry.as_ref().expect("retry set"); + assert_eq!(retry.max_retries, 5); + assert_eq!(retry.backoff_base_ms, 1000); // default + assert_eq!(retry.backoff_max_ms, 30_000); // default + assert_eq!( + cfg.on_failure, + Some(crate::models::pipe_config::HandlerRef::Pipe("oncall".into())) + ); + // And it merges into an existing config without clobbering other keys. + let merged = cfg.merge_into(Some(json!({ "retry_count": 3, "matching_mode": "manual" }))); + assert_eq!(merged["retry"]["max_retries"], json!(5)); + assert_eq!(merged["matching_mode"], json!("manual")); + } + #[test] fn extract_operations_includes_html_forms_and_container() { let info = AgentCommandInfo { @@ -3126,6 +3288,57 @@ mod selectable_operation_tests { } } +/// Parse a manual endpoint spec ("METHOD /path" or a bare "/path" → GET) into +/// an (uppercased method, path) pair. Matches the documented manual-endpoint +/// formats in docs/pipe-howto.md. +fn parse_manual_endpoint(spec: &str) -> (String, String) { + let trimmed = spec.trim(); + let mut parts = trimmed.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or("").trim(); + match parts.next().map(str::trim) { + Some(rest) if !rest.is_empty() => (first.to_ascii_uppercase(), rest.to_string()), + // Bare path (no method token) defaults to GET. + _ => ("GET".to_string(), first.to_string()), + } +} + +/// Build a SelectableOperation from an explicit endpoint spec, bypassing +/// discovery. Used by the manual-endpoint fast path so apps whose APIs aren't +/// auto-discoverable (or arbitrary URLs) can still be piped and scripted. +fn manual_operation(spec: &str, fields: &[String]) -> SelectableOperation { + let (method, path) = parse_manual_endpoint(spec); + SelectableOperation { + container: None, + adapter: None, + method, + path, + summary: "manual endpoint".to_string(), + fields: fields.to_vec(), + sample: None, + } +} + +/// Deterministic field mapping for the manual path: each target field draws from +/// a same-named source field, else the positionally-aligned source field, else +/// itself (identity). An empty target list yields an empty map (pass-through). +fn build_manual_field_mapping(src: &[String], tgt: &[String]) -> serde_json::Value { + let mut mapping = serde_json::Map::new(); + for (idx, target_field) in tgt.iter().enumerate() { + let source_ref = if src.iter().any(|s| s == target_field) { + target_field.clone() + } else if let Some(s) = src.get(idx) { + s.clone() + } else { + target_field.clone() + }; + mapping.insert( + target_field.clone(), + serde_json::Value::String(format!("$.{}", source_ref)), + ); + } + serde_json::Value::Object(mapping) +} + impl CallableTrait for PipeCreateCommand { fn call(&self) -> Result<(), Box> { let project_dir = std::env::current_dir().map_err(CliError::Io)?; @@ -3146,12 +3359,18 @@ impl CallableTrait for PipeCreateCommand { }; let create_protocols = default_pipe_create_protocols(); + // Manual-endpoint fast path: when both endpoints are given explicitly, + // skip discovery entirely (clap guarantees they come as a pair). This + // lets apps whose APIs aren't auto-discoverable be piped, and makes + // `pipe create` fully scriptable/non-interactive. + let manual_endpoints = self.source_endpoint.is_some() && self.target_endpoint.is_some(); + let source_adapter_meta = builtin_adapter_for_selector(&self.source, PipeAdapterRole::Source); let target_adapter_meta = builtin_adapter_for_selector(&self.target, PipeAdapterRole::Target); - let source_run = if source_adapter_meta.is_none() { + let source_run = if !manual_endpoints && source_adapter_meta.is_none() { Some(if local_mode { println!( "{}Preparing local discovery for source '{}'...", @@ -3184,7 +3403,7 @@ impl CallableTrait for PipeCreateCommand { } else { None }; - let target_run = if target_adapter_meta.is_none() { + let target_run = if !manual_endpoints && target_adapter_meta.is_none() { Some(if local_mode { println!( "{}Preparing local discovery for target '{}'...", @@ -3256,13 +3475,23 @@ impl CallableTrait for PipeCreateCommand { return Ok(()); } - // Step 2: Extract discovered endpoints - let source_ops = if let Some(metadata) = &source_adapter_meta { + // Step 2: Extract discovered endpoints (or synthesize from manual flags) + let source_ops = if manual_endpoints { + vec![manual_operation( + self.source_endpoint.as_deref().expect("source endpoint"), + &self.source_fields, + )] + } else if let Some(metadata) = &source_adapter_meta { vec![synthetic_adapter_operation(metadata)] } else { extract_operations(&source_run.as_ref().expect("source discovery").info) }; - let target_ops = if let Some(metadata) = &target_adapter_meta { + let target_ops = if manual_endpoints { + vec![manual_operation( + self.target_endpoint.as_deref().expect("target endpoint"), + &self.target_fields, + )] + } else if let Some(metadata) = &target_adapter_meta { vec![synthetic_adapter_operation(metadata)] } else { extract_operations(&target_run.as_ref().expect("target discovery").info) @@ -3335,10 +3564,13 @@ impl CallableTrait for PipeCreateCommand { let tgt_fields = &tgt_op.fields; // Step 5: Build field mapping (smart matching with sample data) - let (field_mapping, match_result) = if !self.manual - && !src_fields.is_empty() - && !tgt_fields.is_empty() - { + let (field_mapping, match_result) = if manual_endpoints { + println!("\n Manual endpoints — building field mapping from provided fields."); + ( + build_manual_field_mapping(&self.source_fields, &self.target_fields), + None, + ) + } else if !self.manual && !src_fields.is_empty() && !tgt_fields.is_empty() { let matcher = select_field_matcher(self.ai, self.no_ai, self.ml); let result = matcher.match_fields(src_fields, tgt_fields, src_sample.as_ref()); let mode_label = match result.mode { @@ -3420,12 +3652,16 @@ impl CallableTrait for PipeCreateCommand { (serde_json::json!({}), None) }; - // Step 6: Ask for pipe name + // Step 6: Pipe name — use --name when given (non-interactive), else prompt. let default_name = format!("{}-to-{}", self.source, self.target); - let pipe_name: String = dialoguer::Input::new() - .with_prompt("Pipe name") - .default(default_name) - .interact_text()?; + let pipe_name: String = if let Some(name) = &self.name { + name.clone() + } else { + dialoguer::Input::new() + .with_prompt("Pipe name") + .default(default_name) + .interact_text()? + }; // Step 7: Create template via API — include matching metadata in config let mut config = serde_json::json!({"retry_count": 3}); @@ -3454,6 +3690,14 @@ impl CallableTrait for PipeCreateCommand { } } + // Merge typed retry/handler settings (from --retry / --on-failure / …) + // into the config blob, preserving the matching metadata above. No-op + // when no resilience flag was given, so default behavior is unchanged. + let pipe_config = self.pipe_config_from_flags(); + if pipe_config != crate::models::pipe_config::PipeConfig::default() { + config = pipe_config.merge_into(Some(config)); + } + let template_request = CreatePipeTemplateApiRequest { name: pipe_name.clone(), description: Some(format!( @@ -4227,9 +4471,325 @@ async fn run_local_target_adapter( } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -// stacker pipe activate — activate a pipe instance +// stacker pipe diff — compare declared `pipes:` vs deployed (read-only) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Normalize a deployed template's endpoint JSON (`{method,path}`) to +/// "METHOD /path" for comparison with the declarative spec. +fn deployed_endpoint_string(endpoint: &serde_json::Value) -> String { + let method = endpoint["method"].as_str().unwrap_or("GET"); + let path = endpoint["path"].as_str().unwrap_or("/"); + format!("{} {}", method.to_ascii_uppercase(), path) +} + +pub struct PipeDiffCommand { + pub json: bool, + pub deployment: Option, +} + +impl PipeDiffCommand { + pub fn new(json: bool, deployment: Option) -> Self { + Self { json, deployment } + } +} + +impl CallableTrait for PipeDiffCommand { + fn call(&self) -> Result<(), Box> { + use crate::cli::pipe_apply::{diff_pipes, plan_is_clean, DeployedPipe, PipeAction}; + + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let config_path = project_dir.join("stacker.yml"); + let config = crate::cli::config_parser::StackerConfig::from_file(&config_path) + .map_err(|e| CliError::ConfigValidation(format!("Failed to read stacker.yml: {e}")))?; + + // Fetch deployed pipe templates for this deployment and reduce them to + // the comparable view. + let ctx = CliRuntime::new("pipe diff")?; + let deploy_ctx = resolve_deployment_context(&self.deployment, &ctx)?; + let hash = match &deploy_ctx { + DeploymentContext::Remote(hash) => hash.clone(), + DeploymentContext::Local => String::new(), + }; + let pb = progress::spinner("Fetching deployed pipes..."); + let templates = ctx + .block_on(ctx.client.list_pipe_templates(None, None)) + .map_err(|e| { + progress::finish_error(&pb, "Failed to fetch deployed pipes"); + e + })?; + progress::finish_success(&pb, "Fetched deployed pipes"); + let _ = hash; // deployment scoping is applied server-side by auth/context + + let deployed: Vec = templates + .iter() + .map(|t| DeployedPipe { + name: t.name.clone(), + source_app: t.source_app_type.clone(), + target_app: t.target_app_type.clone(), + source_endpoint: deployed_endpoint_string(&t.source_endpoint), + target_endpoint: deployed_endpoint_string(&t.target_endpoint), + }) + .collect(); + + let plan = diff_pipes(&config.pipes, &deployed); + + if self.json { + let rows: Vec = plan + .iter() + .map(|e| { + let (action, changes) = match &e.action { + PipeAction::Create => ("create", vec![]), + PipeAction::Update { changes } => ("update", changes.clone()), + PipeAction::Unchanged => ("unchanged", vec![]), + PipeAction::Orphan => ("orphan", vec![]), + }; + serde_json::json!({ "name": e.name, "action": action, "changes": changes }) + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "clean": plan_is_clean(&plan), + "pipes": rows, + }))? + ); + return Ok(()); + } + + if config.pipes.is_empty() && deployed.is_empty() { + println!("No pipes declared in stacker.yml and none deployed."); + return Ok(()); + } + + for e in &plan { + match &e.action { + PipeAction::Create => println!(" + {} (create)", e.name), + PipeAction::Unchanged => println!(" = {} (unchanged)", e.name), + PipeAction::Orphan => { + println!(" - {} (deployed but not declared; `apply --prune` to remove)", e.name) + } + PipeAction::Update { changes } => { + println!(" ~ {} (update)", e.name); + for c in changes { + println!(" {}", c); + } + } + } + } + + if plan_is_clean(&plan) { + println!("\n✓ In sync — declared pipes match what's deployed."); + } else { + println!("\nRun `stacker pipe apply` to reconcile."); + } + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// stacker pipe apply — reconcile declared `pipes:` into the deployment // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +pub struct PipeApplyCommand { + pub prune: bool, + pub dry_run: bool, + pub json: bool, + pub deployment: Option, +} + +impl PipeApplyCommand { + pub fn new(prune: bool, dry_run: bool, json: bool, deployment: Option) -> Self { + Self { + prune, + dry_run, + json, + deployment, + } + } +} + +impl CallableTrait for PipeApplyCommand { + fn call(&self) -> Result<(), Box> { + use crate::cli::pipe_apply::{ + diff_pipes, endpoint_to_json, field_mapping_for, DeployedPipe, PipeAction, + }; + + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let config_path = project_dir.join("stacker.yml"); + let config = crate::cli::config_parser::StackerConfig::from_file(&config_path) + .map_err(|e| CliError::ConfigValidation(format!("Failed to read stacker.yml: {e}")))?; + + if config.pipes.is_empty() { + println!("No `pipes:` declared in stacker.yml — nothing to apply."); + return Ok(()); + } + + let ctx = CliRuntime::new("pipe apply")?; + let deploy_ctx = resolve_deployment_context(&self.deployment, &ctx)?; + let hash = match &deploy_ctx { + DeploymentContext::Remote(h) => h.clone(), + DeploymentContext::Local => { + return Err(CliError::ConfigValidation( + "`pipe apply` targets a deployed stack; no active deployment was resolved." + .into(), + ) + .into()) + } + }; + + let templates = ctx.block_on(ctx.client.list_pipe_templates(None, None))?; + let deployed: Vec = templates + .iter() + .map(|t| DeployedPipe { + name: t.name.clone(), + source_app: t.source_app_type.clone(), + target_app: t.target_app_type.clone(), + source_endpoint: deployed_endpoint_string(&t.source_endpoint), + target_endpoint: deployed_endpoint_string(&t.target_endpoint), + }) + .collect(); + + // Instances are only needed to prune orphans (delete instance → template). + let instances = if self.prune && !self.dry_run { + ctx.block_on(ctx.client.list_pipe_instances(&hash)) + .unwrap_or_default() + } else { + Vec::new() + }; + + let plan = diff_pipes(&config.pipes, &deployed); + let mut created = Vec::new(); + let mut skipped_update = Vec::new(); + let mut skipped_orphan = Vec::new(); + let mut pruned = Vec::new(); + + for entry in &plan { + match &entry.action { + PipeAction::Unchanged => {} + PipeAction::Create => { + let spec = config + .pipes + .iter() + .find(|s| s.name == entry.name) + .expect("plan create refers to a declared pipe"); + + if self.dry_run { + created.push(format!("{} (dry-run)", spec.name)); + continue; + } + + // 1. Template carries the routing + typed config (retry/handlers). + let config_value = spec.to_pipe_config().merge_into(None); + let template_request = + crate::cli::stacker_client::CreatePipeTemplateApiRequest { + name: spec.name.clone(), + description: Some(format!( + "{} → {}", + spec.source_endpoint, spec.target_endpoint + )), + source_app_type: spec.source.clone(), + source_endpoint: endpoint_to_json(&spec.source_endpoint), + target_app_type: spec.target.clone(), + target_endpoint: endpoint_to_json(&spec.target_endpoint), + target_external_url: None, + field_mapping: field_mapping_for( + &spec.source_fields, + &spec.target_fields, + ), + config: Some(config_value), + is_public: Some(false), + }; + let template = ctx + .block_on(ctx.client.create_pipe_template(&template_request))?; + + // 2. Instance binds the template to this deployment's containers. + let instance_request = + crate::cli::stacker_client::CreatePipeInstanceApiRequest { + deployment_hash: Some(hash.clone()), + source_adapter: None, + source_container: spec.source.clone(), + target_adapter: None, + target_container: Some(spec.target.clone()), + target_url: None, + template_id: Some(template.id.clone()), + field_mapping_override: None, + config_override: None, + }; + ctx.block_on(ctx.client.create_pipe_instance(&instance_request))?; + created.push(spec.name.clone()); + } + PipeAction::Update { .. } => skipped_update.push(entry.name.clone()), + PipeAction::Orphan => { + if self.prune && !self.dry_run { + // Resolve the orphan's template, delete its instances + // first (FK), then the template itself. + if let Some(tmpl) = templates.iter().find(|t| t.name == entry.name) { + for inst in instances + .iter() + .filter(|i| i.template_id.as_deref() == Some(tmpl.id.as_str())) + { + ctx.block_on(ctx.client.delete_pipe_instance(&inst.id))?; + } + ctx.block_on(ctx.client.delete_pipe_template(&tmpl.id))?; + pruned.push(entry.name.clone()); + } else { + skipped_orphan.push(entry.name.clone()); + } + } else { + skipped_orphan.push(entry.name.clone()); + } + } + } + } + + if self.json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "created": created, + "pruned": pruned, + "skipped_update": skipped_update, + "orphans": skipped_orphan, + "dry_run": self.dry_run, + }))? + ); + return Ok(()); + } + + for name in &created { + println!(" ✓ created {name}"); + } + for name in &pruned { + println!(" ✓ pruned {name}"); + } + // In-place update needs an API endpoint that doesn't exist yet (only + // template/instance create + delete and status-update are available). + for name in &skipped_update { + println!( + " ! {name}: differs from the deployed pipe — in-place update not \ + yet supported (re-apply with `--prune`, or edit via the API)." + ); + } + for name in &skipped_orphan { + println!(" - {name}: deployed but not declared (pass `--prune` to remove)."); + } + + println!( + "\n{} created, {} pruned, {} needing update, {} orphan(s).{}", + created.len(), + pruned.len(), + skipped_update.len(), + skipped_orphan.len(), + if self.dry_run { " (dry-run — nothing applied)" } else { "" } + ); + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// stacker pipe activate — activate a pipe instance +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + pub struct PipeActivateCommand { pub pipe_id: String, pub trigger: String, @@ -4306,7 +4866,7 @@ impl CallableTrait for PipeActivateCommand { progress::finish_success(&pb, "Pipe found"); // Get template info for endpoint details (if linked) - let (source_endpoint, source_method, target_endpoint, target_method, field_mapping) = + let (source_endpoint, source_method, target_endpoint, target_method, field_mapping, config) = if let Some(ref tid) = pipe.template_id { let templates = ctx.block_on(ctx.client.list_pipe_templates(None, None))?; if let Some(tmpl) = templates.iter().find(|t| &t.id == tid) { @@ -4330,6 +4890,9 @@ impl CallableTrait for PipeActivateCommand { pipe.field_mapping_override .clone() .unwrap_or(tmpl.field_mapping.clone()), + // Instance override wins over the template config, so the + // agent gets the effective retry policy + handlers. + pipe.config_override.clone().or_else(|| tmpl.config.clone()), ) } else { ( @@ -4338,6 +4901,7 @@ impl CallableTrait for PipeActivateCommand { "/".to_string(), "POST".to_string(), serde_json::json!({}), + pipe.config_override.clone(), ) } } else { @@ -4349,6 +4913,7 @@ impl CallableTrait for PipeActivateCommand { pipe.field_mapping_override .clone() .unwrap_or(serde_json::json!({})), + pipe.config_override.clone(), ) }; @@ -4376,6 +4941,9 @@ impl CallableTrait for PipeActivateCommand { "field_mapping": field_mapping, "trigger_type": self.trigger, "poll_interval_secs": self.poll_interval, + // Effective pipe config (retry policy + on_failure/on_success) so the + // agent can apply resilience/lifecycle behavior. null when unset. + "config": config, }); let request = AgentEnqueueRequest::new(&hash, "activate_pipe").with_raw_parameters(params); diff --git a/src/console/commands/cli/proxy.rs b/src/console/commands/cli/proxy.rs index 421adc5e..d57484b6 100644 --- a/src/console/commands/cli/proxy.rs +++ b/src/console/commands/cli/proxy.rs @@ -4,8 +4,8 @@ use crate::cli::config_parser::{ use crate::cli::deployment_lock::DeploymentLock; use crate::cli::error::CliError; use crate::cli::proxy_manager::{ - detect_proxy, detect_proxy_from_snapshot, generate_nginx_server_block, ContainerRuntime, - DockerCliRuntime, ProxyDetection, + detect_proxy, detect_proxy_from_snapshot, generate_caddy_server_block, + generate_nginx_server_block, ContainerRuntime, DockerCliRuntime, ProxyDetection, }; use crate::cli::runtime::CliRuntime; use crate::cli::stacker_client::AgentEnqueueRequest; @@ -125,10 +125,118 @@ fn persist_proxy_config_to_stacker_yml( return Ok(None); } - let mut config = StackerConfig::from_file_raw(&config_path)?; - let changed = upsert_proxy_domain_config(&mut config, proxy_type, domain_config); let backup_path = PathBuf::from(format!("{}.bak", config_path.display())); + // Read raw YAML to surgically update proxy without losing other keys. + let raw_text = std::fs::read_to_string(&config_path)?; + let mut root: serde_yaml::Value = serde_yaml::from_str(&raw_text) + .map_err(|e| CliError::ConfigValidation(format!("Invalid YAML: {}", e)))?; + + // Check if domain already exists and needs update + let changed = if let Some(proxy) = root.get_mut("proxy") { + // Ensure proxy type matches the requested provider even when the + // proxy section already exists (e.g. `type: none` -> nginx-proxy-manager). + let mut changed = false; + if let Some(map) = proxy.as_mapping_mut() { + let type_str = match proxy_type { + ProxyType::Nginx => "nginx".to_string(), + ProxyType::NginxProxyManager => "nginx-proxy-manager".to_string(), + ProxyType::Traefik => "traefik".to_string(), + ProxyType::Caddy => "caddy".to_string(), + ProxyType::None => "none".to_string(), + }; + let type_key = serde_yaml::Value::String("type".to_string()); + match map.get_mut(&type_key) { + Some(current) if current.as_str() != Some(type_str.as_str()) => { + *current = serde_yaml::Value::String(type_str); + changed = true; + } + None => { + map.insert(type_key, serde_yaml::Value::String(type_str)); + changed = true; + } + _ => {} + } + } + if let Some(domains) = proxy.get_mut("domains") { + if let Some(arr) = domains.as_sequence_mut() { + let _domain_lower = domain_config.domain.to_lowercase(); + let existing = arr.iter_mut().find(|entry| { + entry + .get("domain") + .and_then(|v| v.as_str()) + .map(|d| d.eq_ignore_ascii_case(&domain_config.domain)) + .unwrap_or(false) + }); + if let Some(entry) = existing { + let mut entry_changed = false; + if let Some(ssl) = entry.get_mut("ssl") { + let new_ssl = serde_yaml::to_value(&domain_config.ssl).unwrap_or_default(); + if *ssl != new_ssl { + *ssl = new_ssl; + entry_changed = true; + } + } + if let Some(upstream) = entry.get_mut("upstream") { + let new_upstream = + serde_yaml::Value::String(domain_config.upstream.clone()); + if *upstream != new_upstream { + *upstream = new_upstream; + entry_changed = true; + } + } + changed = changed || entry_changed; + changed + } else { + // Add new domain entry + let new_entry = serde_yaml::to_value(&domain_config).unwrap_or_default(); + arr.push(new_entry); + true + } + } else { + // domains is not an array — replace it + *domains = serde_yaml::Value::Sequence(vec![ + serde_yaml::to_value(&domain_config).unwrap_or_default() + ]); + true + } + } else { + // No domains key — add it + if let Some(map) = proxy.as_mapping_mut() { + let key = serde_yaml::Value::String("domains".to_string()); + let val = serde_yaml::Value::Sequence(vec![ + serde_yaml::to_value(&domain_config).unwrap_or_default() + ]); + map.insert(key, val); + } + true + } + } else { + // No proxy key — add it with type and domains + if let Some(map) = root.as_mapping_mut() { + let type_str = serde_yaml::Value::String(match proxy_type { + ProxyType::Nginx => "nginx".to_string(), + ProxyType::NginxProxyManager => "nginx-proxy-manager".to_string(), + ProxyType::Traefik => "traefik".to_string(), + ProxyType::Caddy => "caddy".to_string(), + ProxyType::None => "none".to_string(), + }); + let mut proxy_map = serde_yaml::Mapping::new(); + proxy_map.insert(serde_yaml::Value::String("type".to_string()), type_str); + proxy_map.insert( + serde_yaml::Value::String("domains".to_string()), + serde_yaml::Value::Sequence(vec![ + serde_yaml::to_value(&domain_config).unwrap_or_default() + ]), + ); + map.insert( + serde_yaml::Value::String("proxy".to_string()), + serde_yaml::Value::Mapping(proxy_map), + ); + } + true + }; + if !changed { return Ok(Some(ProxyConfigPersistence { config_path, @@ -137,7 +245,7 @@ fn persist_proxy_config_to_stacker_yml( })); } - let yaml = serde_yaml::to_string(&config) + let yaml = serde_yaml::to_string(&root) .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; std::fs::copy(&config_path, &backup_path)?; std::fs::write(&config_path, yaml)?; @@ -263,6 +371,18 @@ impl CallableTrait for ProxyAddCommand { build_domain_config(&self.domain, self.upstream.as_deref(), self.ssl.as_deref()); let use_agent = self.deployment.is_some() || is_cloud_or_remote(&project_dir); if use_agent { + // Persist to stacker.yml first — the local config update does not + // depend on the remote agent or Vault. If the agent call later + // fails, the user's config is still saved. + let persistence = persist_proxy_config_to_stacker_yml( + &project_dir, + ProxyType::NginxProxyManager, + domain_config, + )?; + if !self.json { + print_proxy_config_persistence(persistence.as_ref()); + } + let upstream = self.upstream.as_deref().unwrap_or("app:8080"); let target = parse_proxy_upstream(upstream)?; let ssl_enabled = parse_ssl_mode(self.ssl.as_deref()) != SslMode::Off; @@ -277,28 +397,68 @@ impl CallableTrait for ProxyAddCommand { self.json, self.deployment.clone(), ); - command.call()?; + if let Err(err) = command.call() { + eprintln!( + " ⚠ Proxy config saved locally, but remote NPM configuration failed: {}", + err + ); + eprintln!( + " The proxy will be configured on the remote server on the next deploy." + ); + } + return Ok(()); + } + + // Keep an already-configured Caddy/Traefik proxy as-is; default to + // nginx otherwise, matching prior behavior for everyone who hasn't + // opted into either. + let existing_proxy_type = StackerConfig::from_file(&project_dir.join("stacker.yml")) + .map(|config| config.proxy.proxy_type) + .unwrap_or(ProxyType::Nginx); + + if existing_proxy_type == ProxyType::Traefik { + // Traefik routes via labels baked directly into the generated + // docker-compose.yml (see `ComposeDefinition::try_from`'s + // domain-driven label injection) — there's no separate config + // file to print and hand-apply like nginx/Caddy. let persistence = persist_proxy_config_to_stacker_yml( &project_dir, - ProxyType::NginxProxyManager, + ProxyType::Traefik, domain_config, )?; if !self.json { print_proxy_config_persistence(persistence.as_ref()); } + eprintln!( + "✓ Domain {} saved to stacker.yml; Traefik routing labels are generated \ + automatically on the target service the next time you run `stacker deploy`.", + self.domain + ); return Ok(()); } - let block = generate_nginx_server_block(&domain_config)?; + let (block, proxy_type, kind_label) = if existing_proxy_type == ProxyType::Caddy { + ( + generate_caddy_server_block(&domain_config)?, + ProxyType::Caddy, + "Caddyfile", + ) + } else { + ( + generate_nginx_server_block(&domain_config)?, + ProxyType::Nginx, + "nginx", + ) + }; let persistence = - persist_proxy_config_to_stacker_yml(&project_dir, ProxyType::Nginx, domain_config)?; + persist_proxy_config_to_stacker_yml(&project_dir, proxy_type, domain_config)?; println!("{}", block); if !self.json { print_proxy_config_persistence(persistence.as_ref()); } eprintln!( - "✓ Proxy config generated for {}; apply this nginx snippet to configure a local proxy", - self.domain + "✓ Proxy config generated for {}; apply this {} snippet to configure a local proxy", + self.domain, kind_label ); Ok(()) } diff --git a/src/console/commands/cli/secrets.rs b/src/console/commands/cli/secrets.rs index cb3646ad..9c6c47ab 100644 --- a/src/console/commands/cli/secrets.rs +++ b/src/console/commands/cli/secrets.rs @@ -1529,6 +1529,7 @@ mod tests { env_file: None, env: Default::default(), config_contract: Default::default(), + pipes: Vec::new(), origin: Default::default(), app_present: false, }; diff --git a/src/console/commands/cli/service.rs b/src/console/commands/cli/service.rs index 1dcb5407..911e18ec 100644 --- a/src/console/commands/cli/service.rs +++ b/src/console/commands/cli/service.rs @@ -56,6 +56,13 @@ impl CallableTrait for ServiceAddCommand { })); } + // Load raw YAML as Value so we can surgically update only the + // `services` array when writing back. This preserves every other + // key (config_contract, deploy, proxy, etc.) exactly as the user + // authored it — including null-free Option fields and comments. + let raw_text = std::fs::read_to_string(path)?; + let mut root: serde_yaml::Value = serde_yaml::from_str(&raw_text)?; + // Load existing config without resolving ${VAR} placeholders so // that sensitive values from .env are not written back to the file. let mut config = StackerConfig::from_file_raw(path)?; @@ -110,6 +117,7 @@ impl CallableTrait for ServiceAddCommand { // Add missing dependencies first, then the requested service, tracking // the canonical codes so we can resolve their configurable inputs. let mut added_codes: Vec = Vec::new(); + let mut added_services: Vec = Vec::new(); for dep in &entry.service.depends_on { if !config.services.iter().any(|s| &s.name == dep) { // Try to resolve the dependency too @@ -118,13 +126,15 @@ impl CallableTrait for ServiceAddCommand { " + Adding dependency: {} ({})", dep_entry.name, dep_entry.service.image ); - config.services.push(dep_entry.service); + config.services.push(dep_entry.service.clone()); added_codes.push(dep_entry.code); + added_services.push(serde_yaml::to_value(&dep_entry.service)?); } } } config.services.push(entry.service.clone()); added_codes.push(entry.code.clone()); + added_services.push(serde_yaml::to_value(&entry.service)?); // Resolve configurable inputs (passwords, etc.) for the added services // and persist them to `.env`. The service definitions reference `${KEY}`, @@ -139,8 +149,28 @@ impl CallableTrait for ServiceAddCommand { apply_service_inputs(&env_path, &inputs)?; } - // Serialize back to YAML - let yaml = serde_yaml::to_string(&config).map_err(|e| { + // Surgically append new services to the raw YAML Value instead of + // re-serializing the entire StackerConfig. This preserves every + // field exactly as the user authored it — no null injection, no + // config_contract collapse. + if let Some(services) = root.get_mut("services") { + if let Some(arr) = services.as_sequence_mut() { + for svc in added_services { + arr.push(svc); + } + } else { + // services was null or not a sequence — replace with a new array + *services = serde_yaml::Value::Sequence(added_services); + } + } else { + // No services key at all — add it + root.as_mapping_mut().unwrap().insert( + serde_yaml::Value::String("services".to_string()), + serde_yaml::Value::Sequence(added_services), + ); + } + + let yaml = serde_yaml::to_string(&root).map_err(|e| { CliError::ConfigValidation(format!("Failed to serialize config: {}", e)) })?; @@ -594,7 +624,7 @@ impl CallableTrait for ServiceRemoveCommand { })); } - let mut config = StackerConfig::from_file_raw(path)?; + let config = StackerConfig::from_file_raw(path)?; let canonical = ServiceCatalog::resolve_alias(&self.name); if !config.services.iter().any(|s| s.name == canonical) { @@ -613,9 +643,23 @@ impl CallableTrait for ServiceRemoveCommand { return Ok(()); } - config.services.retain(|s| s.name != canonical); + // Read raw YAML and surgically remove only the matching service + // from the services array, preserving all other fields exactly. + let raw_text = std::fs::read_to_string(path)?; + let mut root: serde_yaml::Value = serde_yaml::from_str(&raw_text)?; + + if let Some(services) = root.get_mut("services") { + if let Some(arr) = services.as_sequence_mut() { + arr.retain(|v| { + v.get("name") + .and_then(|n| n.as_str()) + .map(|n| n != canonical) + .unwrap_or(true) + }); + } + } - let yaml = serde_yaml::to_string(&config).map_err(|e| { + let yaml = serde_yaml::to_string(&root).map_err(|e| { CliError::ConfigValidation(format!("Failed to serialize config: {}", e)) })?; @@ -658,14 +702,36 @@ fn validate_no_duplicate_services( fn import_services_into_config( path: &Path, - mut config: StackerConfig, + _config: StackerConfig, plan: &ServiceImportPlan, ) -> Result> { - for service in &plan.services { - config.services.push(service.clone()); + // Read raw YAML to surgically append services without losing other keys. + let raw_text = std::fs::read_to_string(path)?; + let mut root: serde_yaml::Value = serde_yaml::from_str(&raw_text)?; + + // Convert ServiceDefinition to serde_yaml::Value for insertion + let new_services: Vec = plan + .services + .iter() + .map(|svc| serde_yaml::to_value(svc).unwrap_or_default()) + .collect(); + + if let Some(services) = root.get_mut("services") { + if let Some(arr) = services.as_sequence_mut() { + for svc in new_services { + arr.push(svc); + } + } + } else { + // No services key exists — create it + if let Some(map) = root.as_mapping_mut() { + let key = serde_yaml::Value::String("services".to_string()); + let val = serde_yaml::Value::Sequence(new_services); + map.insert(key, val); + } } - let yaml = serde_yaml::to_string(&config) + let yaml = serde_yaml::to_string(&root) .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; let config_path = path.to_string_lossy().to_string(); diff --git a/src/console/commands/cli/status.rs b/src/console/commands/cli/status.rs index 47bc72e0..4dd192dd 100644 --- a/src/console/commands/cli/status.rs +++ b/src/console/commands/cli/status.rs @@ -4,7 +4,7 @@ use crate::cli::config_parser::{CloudOrchestrator, DeployTarget, ProxyType, Stac use crate::cli::credentials::{CredentialsManager, StoredCredentials}; use crate::cli::error::CliError; use crate::cli::install_runner::{CommandExecutor, CommandOutput, ShellExecutor}; -use crate::cli::local_compose::resolve_local_compose_path; +use crate::cli::local_compose::{resolve_local_compose_path, resolve_local_compose_project_name}; use crate::cli::notify; use crate::cli::stacker_client::{self, DeploymentStatusInfo, ServerInfo, StackerClient}; use crate::console::commands::cli::ssh_key::{format_ssh_command, local_backup_private_key_path}; @@ -38,9 +38,16 @@ impl StatusCommand { } /// Build `docker compose ps` arguments. -pub fn build_status_args(compose_path: &str, json: bool) -> Vec { +/// +/// `project_name` must be passed via `-p` — without it Compose falls back +/// to the compose file's directory basename, `.stacker` for every project, +/// so `stacker status` for one project could report another, unrelated +/// project's containers under the same shared default scope. See GH #235. +pub fn build_status_args(compose_path: &str, project_name: &str, json: bool) -> Vec { let mut args = vec![ "compose".to_string(), + "-p".to_string(), + project_name.to_string(), "-f".to_string(), compose_path.to_string(), "ps".to_string(), @@ -63,7 +70,8 @@ pub fn run_status( let compose_path = resolve_local_compose_path(project_dir)?; let compose_str = compose_path.to_string_lossy().to_string(); - let args = build_status_args(&compose_str, json); + let project_name = resolve_local_compose_project_name(project_dir); + let args = build_status_args(&compose_str, &project_name, json); let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); let output = executor.execute("docker", &args_refs)?; @@ -153,6 +161,27 @@ fn emergency_ssh_command(server: &ServerInfo) -> Option { )) } +/// Formats the main `app:` container as a "Services" list entry, mirroring +/// how `config.services` entries are printed. Returns `None` when the config +/// carries no real `app:` section (services-only stacks), so a phantom `app` +/// line never appears. +fn app_service_line(config: &StackerConfig) -> Option { + let app = &config.app; + let app_declared = config.app_present + || app.image.is_some() + || app.dockerfile.is_some() + || app.build.is_some(); + if !app_declared { + return None; + } + let ports_str = if app.ports.is_empty() { + String::new() + } else { + format!(" (ports: {})", app.ports.join(", ")) + }; + Some(format!("• app{}", ports_str)) +} + /// Pretty-print a deployment status with optional server/config context. fn print_deployment_status_rich(info: &DeploymentStatusInfo, json: bool, ctx: &StatusContext<'_>) { if json { @@ -282,8 +311,12 @@ fn print_deployment_status_rich(info: &DeploymentStatusInfo, json: bool, ctx: &S let srv_ip = ctx.server.and_then(|s| s.srv_ip.as_deref()); // Services - if !config.services.is_empty() { + let app_line = app_service_line(config); + if app_line.is_some() || !config.services.is_empty() { println!("\n── Services ───────────────────────────────"); + if let Some(line) = app_line { + println!(" {}", line); + } for svc in &config.services { let ports_str = if svc.ports.is_empty() { String::new() @@ -757,15 +790,60 @@ mod tests { // cargo runs lib tests in parallel threads. static ENV_LOCK: Mutex<()> = Mutex::new(()); + // Regression tests for GH issue #219: `stacker status --watch` omits the + // main `app` container from the printed "Services" list — only + // `config.services` (db/redis/sidekiq-style backend services) were shown. + #[test] + fn test_app_service_line_included_for_dockerfile_app() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("mastodon") + .app_dockerfile("Dockerfile") + .build() + .unwrap(); + + let line = app_service_line(&config).expect("declared app: section should be listed"); + assert_eq!(line, "• app"); + } + + #[test] + fn test_app_service_line_includes_ports() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("mastodon") + .app_image("myorg/mastodon:latest") + .build() + .unwrap(); + let mut config = config; + config.app.ports = vec!["127.0.0.1:3000:3000".to_string()]; + + let line = app_service_line(&config).unwrap(); + assert_eq!(line, "• app (ports: 127.0.0.1:3000:3000)"); + } + + #[test] + fn test_app_service_line_absent_for_services_only_stack() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("services-only") + .build() + .unwrap(); + + assert!( + app_service_line(&config).is_none(), + "a config with no declared app: section should not synthesize a phantom app line" + ); + } + #[test] fn test_status_local_constructs_query() { - let args = build_status_args("/path/compose.yml", false); - assert_eq!(args, vec!["compose", "-f", "/path/compose.yml", "ps"]); + let args = build_status_args("/path/compose.yml", "myproject", false); + assert_eq!( + args, + vec!["compose", "-p", "myproject", "-f", "/path/compose.yml", "ps"] + ); } #[test] fn test_status_json_flag() { - let args = build_status_args("/path/compose.yml", true); + let args = build_status_args("/path/compose.yml", "myproject", true); assert!(args.contains(&"--format".to_string())); assert!(args.contains(&"json".to_string())); } @@ -831,14 +909,67 @@ mod tests { let calls = executor.calls.lock().unwrap(); assert_eq!(calls.len(), 1); + let f_index = calls[0].iter().position(|a| a == "-f").unwrap(); assert_eq!( - calls[0][2], + calls[0][f_index + 1], dir.path() .join("docker/local/compose.yml") .to_string_lossy() ); } + // Regression test for GH issue #235: `stacker status` previously never + // passed `-p `, so it queried Compose's shared directory-basename + // default scope ("stacker") — the same project name every project's + // `.stacker/` compose file falls back to — risking a status report that + // shows another, unrelated project's containers. + #[test] + fn test_status_namespaces_compose_project_by_identity() { + struct MockExec { + calls: std::sync::Mutex>>, + } + + impl CommandExecutor for MockExec { + fn execute(&self, _p: &str, args: &[&str]) -> Result { + self.calls + .lock() + .unwrap() + .push(args.iter().map(|arg| arg.to_string()).collect()); + Ok(CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + }) + } + } + + let dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".stacker")).unwrap(); + std::fs::write( + dir.path().join(".stacker/docker-compose.yml"), + "services: {}\n", + ) + .unwrap(); + std::fs::write( + dir.path().join(DEFAULT_CONFIG_FILE), + "name: Miniflux Prod\ndeploy:\n target: local\n", + ) + .unwrap(); + + let executor = MockExec { + calls: std::sync::Mutex::new(Vec::new()), + }; + + run_status(dir.path(), false, &executor).unwrap(); + + let calls = executor.calls.lock().unwrap(); + let p_index = calls[0] + .iter() + .position(|a| a == "-p") + .expect("docker compose ps should pass -p "); + assert_eq!(calls[0].get(p_index + 1).map(String::as_str), Some("miniflux-prod")); + } + #[test] fn test_is_terminal_status() { assert!(is_terminal("completed")); diff --git a/src/console/commands/mq/listener.rs b/src/console/commands/mq/listener.rs index 48495d7b..a18d9b9e 100644 --- a/src/console/commands/mq/listener.rs +++ b/src/console/commands/mq/listener.rs @@ -91,6 +91,107 @@ fn progress_message_server_ip(msg: &ProgressMessage) -> Option { .or_else(|| extract_ipv4_from_text(&msg.message)) } +/// Reconcile `server.srv_ip` directly from the cloud provider. +/// +/// The listener normally learns the IP from the install service's progress +/// message (`progress_message_server_ip`). When provisioning succeeds but the +/// install service ends the deploy `paused`/`failed` *without* reporting the IP, +/// that path leaves `srv_ip` null forever — the reported "server created, no SSH +/// access" bug. Here we go straight to the provider (Hetzner assigns a public +/// IPv4 at creation) and persist it, so the record isn't orphaned. +/// +/// Best-effort: only acts on servers that have a cloud but no IP yet, only for +/// Hetzner today, and logs (never propagates) any provider error. +async fn reconcile_server_ip_from_provider(pool: &PgPool, project_id: i32) { + let servers = match db::server::fetch_by_project(pool, project_id).await { + Ok(servers) => servers, + Err(e) => { + eprintln!( + "IP reconcile: could not load servers for project {}: {}", + project_id, e + ); + return; + } + }; + + for server in servers { + let needs_ip = server + .srv_ip + .as_deref() + .map(str::trim) + .map_or(true, str::is_empty); + if !needs_ip { + continue; + } + let Some(cloud_id) = server.cloud_id else { + continue; + }; + let Some(name) = server + .name + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty()) + else { + continue; + }; + + let cloud = match db::cloud::fetch(pool, cloud_id).await { + Ok(Some(cloud)) => cloud, + Ok(None) => continue, + Err(e) => { + eprintln!("IP reconcile: could not load cloud {}: {}", cloud_id, e); + continue; + } + }; + // Only Hetzner is supported for provider-side IP lookup today. + if normalize_provider(&cloud.provider) != Some("htz") { + continue; + } + let cloud = if cloud.save_token == Some(true) { + CloudForm::decode_model(cloud, true) + } else { + cloud + }; + let Some(token) = cloud + .cloud_token + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + else { + continue; + }; + + match crate::connectors::hetzner::fetch_server_ipv4_by_name( + &crate::connectors::hetzner::api_base_url(), + token, + name, + ) + .await + { + Ok(Some(ip)) => { + match db::server::update_srv_ip(pool, project_id, &ip, server.ssh_port).await { + Ok(s) => println!( + "IP reconcile: set server {} srv_ip={} from provider for project {}", + s.id, ip, project_id + ), + Err(e) => eprintln!( + "IP reconcile: failed to persist srv_ip for project {}: {}", + project_id, e + ), + } + } + Ok(None) => println!( + "IP reconcile: provider has no IP yet for server '{}' (project {})", + name, project_id + ), + Err(e) => eprintln!( + "IP reconcile: provider lookup failed for server '{}' (project {}): {}", + name, project_id, e + ), + } + } +} + fn extract_public_ports(metadata: &Value) -> Vec { metadata .get("public_ports") @@ -426,11 +527,12 @@ impl crate::console::commands::CallableTrait for ListenCommand { // but the IP is already known after Terraform succeeds // even when the subsequent Ansible step fails (status // "paused" / "failed"). - if let Some(ip) = progress_message_server_ip(&msg) { + let ip_from_message = progress_message_server_ip(&msg); + if let Some(ip) = &ip_from_message { match db::server::update_srv_ip( db_pool.get_ref(), row.project_id, - &ip, + ip, msg.ssh_port, ) .await @@ -446,6 +548,24 @@ impl crate::console::commands::CallableTrait for ListenCommand { } } + // Fallback: the install service provisioned a server but + // never reported its IP (deployment ends paused/failed with + // srv_ip null — the reported root cause of "no SSH access"). + // Reconcile the IP straight from the cloud provider so the + // record isn't left orphaned forever. + if ip_from_message.is_none() + && matches!( + row.status.as_str(), + "paused" | "failed" | "error" | "completed" + ) + { + reconcile_server_ip_from_provider( + db_pool.get_ref(), + row.project_id, + ) + .await; + } + let is_completed = row.status == "completed"; let row_for_firewall = row.clone(); println!( diff --git a/src/console/main.rs b/src/console/main.rs index 2cbd0fee..d8ea8f5f 100644 --- a/src/console/main.rs +++ b/src/console/main.rs @@ -240,6 +240,9 @@ enum StackerConfigCommands { Validate { #[arg(long, value_name = "FILE")] file: Option, + /// Deploy target to validate against (local, cloud, server). + #[arg(long)] + target: Option, }, /// Show resolved configuration Show { @@ -513,8 +516,10 @@ fn get_command( stacker::console::commands::cli::rollback::RollbackCommand::new(version, confirm), )), StackerCommands::Config { command: cfg_cmd } => match cfg_cmd { - StackerConfigCommands::Validate { file } => Ok(Box::new( - stacker::console::commands::cli::config::ConfigValidateCommand::new(file), + StackerConfigCommands::Validate { file, target } => Ok(Box::new( + stacker::console::commands::cli::config::ConfigValidateCommand::new( + file, target, + ), )), StackerConfigCommands::Show { file, resolved } => Ok(Box::new( stacker::console::commands::cli::config::ConfigShowCommand::new(file, resolved), diff --git a/src/db/agent.rs b/src/db/agent.rs index f39646d7..b6835b78 100644 --- a/src/db/agent.rs +++ b/src/db/agent.rs @@ -81,6 +81,22 @@ pub async fn fetch_active_by_project( project_id: i32, ) -> Result, String> { let query_span = tracing::info_span!("Fetching active agent by project"); + // Order by the deployment's own creation time, NOT agent heartbeat + // recency. A project can have multiple deployments (e.g. one to an + // existing server, one to cloud), each with its own agent/heartbeat. + // Ordering by `a.last_heartbeat` picked whichever agent happened to + // heartbeat most recently at query time — a moving target that could + // (and did, see GH stacker#234) flip between two calls milliseconds + // apart if an older, stale/paused deployment's agent was still + // heartbeating, causing `stacker agent status` and `stacker agent + // health` to silently resolve to *different* deployments for the same + // project. Deployment creation time is stable: "the project's active + // deployment" always means the most recently deployed one, regardless + // of which agent's heartbeat is momentarily freshest. Whether that + // deployment's agent is currently online is reported separately via + // `effective_status` in the snapshot handler — an offline agent for + // the latest deployment should be reported as offline, not silently + // swapped for an older deployment that happens to still be alive. sqlx::query_as::<_, models::Agent>( r#" SELECT a.id, a.deployment_hash, a.capabilities, a.version, a.system_info, @@ -88,8 +104,8 @@ pub async fn fetch_active_by_project( FROM agents a JOIN deployment d ON a.deployment_hash = d.deployment_hash WHERE d.project_id = $1 - AND a.last_heartbeat > NOW() - INTERVAL '5 minutes' - ORDER BY a.last_heartbeat DESC + AND d.deleted = false + ORDER BY d.created_at DESC LIMIT 1 "#, ) diff --git a/src/db/marketplace.rs b/src/db/marketplace.rs index d0405d5a..ebfb4158 100644 --- a/src/db/marketplace.rs +++ b/src/db/marketplace.rs @@ -2173,6 +2173,8 @@ pub async fn admin_update_pricing( billing_cycle: Option<&str>, required_plan_name: Option<&str>, currency: Option<&str>, + daily_rate: Option, + monthly_cap: Option, ) -> Result { let query_span = tracing::info_span!( "marketplace_admin_update_pricing", @@ -2190,7 +2192,9 @@ pub async fn admin_update_pricing( price = COALESCE($2, price), billing_cycle = COALESCE($3, billing_cycle), required_plan_name = COALESCE($4, required_plan_name), - currency = COALESCE($5, currency) + currency = COALESCE($5, currency), + daily_rate = COALESCE($6, daily_rate), + monthly_cap = COALESCE($7, monthly_cap) WHERE id = $1"#, ) .bind(*template_id) @@ -2198,6 +2202,8 @@ pub async fn admin_update_pricing( .bind(billing_cycle) .bind(required_plan_name) .bind(currency) + .bind(daily_rate) + .bind(monthly_cap) .execute(pool) .instrument(query_span) .await @@ -2647,3 +2653,49 @@ pub async fn get_template_events_by_creator( ) -> Result, String> { Err("get_template_events_by_creator not implemented - requires owner-scoped query".to_string()) } + +/// Fetch an approved template by slug. Used by the one-click clone endpoint +/// to look up billing_cycle and pricing fields. +pub async fn get_approved_by_slug( + pool: &PgPool, + slug: &str, +) -> Result, String> { + sqlx::query_as::<_, StackTemplate>( + r#"SELECT + t.id, + t.creator_user_id, + t.creator_name, + t.name, + t.slug, + t.short_description, + t.long_description, + c.name AS category_code, + t.product_id, + t.tags, + t.tech_stack, + t.status, + t.is_configurable, + t.view_count, + t.deploy_count, + t.required_plan_name, + t.price, + t.billing_cycle, + t.currency, + t.daily_rate::float8, + t.monthly_cap::float8, + t.created_at, + t.updated_at, + t.approved_at, + t.verifications, + t.infrastructure_requirements, + t.public_ports, + t.vendor_url + FROM stack_template t + LEFT JOIN stack_category c ON t.category_id = c.id + WHERE t.slug = $1 AND t.status = 'approved'"#, + ) + .bind(slug) + .fetch_optional(pool) + .await + .map_err(|e| format!("get_approved_by_slug: {}", e)) +} diff --git a/src/db/marketplace_billing.rs b/src/db/marketplace_billing.rs index 5b9c33f6..455e08f5 100644 --- a/src/db/marketplace_billing.rs +++ b/src/db/marketplace_billing.rs @@ -35,6 +35,14 @@ pub struct AuthorizationRow { pub expires_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, + // Daily billing fields + pub billing_cycle: Option, + pub daily_rate: Option, + pub monthly_cap: Option, + pub total_charged_minor: Option, + pub last_daily_charge_at: Option>, + pub server_deleted_at: Option>, + pub suspended_at: Option>, } /// Input for inserting a fresh authorization row. The row is always inserted @@ -49,6 +57,9 @@ pub struct NewAuthorization { pub amount_minor: i64, pub currency: String, pub expires_at: Option>, + pub billing_cycle: Option, + pub daily_rate: Option, + pub monthly_cap: Option, } /// Insert (or fetch, on idempotency-key replay) an authorization row. @@ -67,8 +78,9 @@ pub async fn insert_authorization( let inserted: Option = sqlx::query_as::<_, AuthorizationRow>( r#"INSERT INTO marketplace_install_authorization (user_id, template_id, idempotency_key, authorization_id, - amount_minor, currency, status, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, 'authorized', $7) + amount_minor, currency, status, expires_at, + billing_cycle, daily_rate, monthly_cap) + VALUES ($1, $2, $3, $4, $5, $6, 'authorized', $7, $8, $9, $10) ON CONFLICT (user_id, idempotency_key) DO NOTHING RETURNING *"#, ) @@ -79,6 +91,9 @@ pub async fn insert_authorization( .bind(row.amount_minor) .bind(&row.currency) .bind(row.expires_at) + .bind(&row.billing_cycle) + .bind(row.daily_rate) + .bind(row.monthly_cap) .fetch_optional(&mut *tx) .await .map_err(|e| format!("insert_authorization: {}", e))?; @@ -207,3 +222,116 @@ pub async fn list_expired_authorized( .await .map_err(|e| format!("list_expired_authorized: {}", e)) } + +// ─── Deployment-daily billing helpers ─────────────────────────────────── + +/// Rows eligible for daily charge sweep: +/// billing_cycle='deployment_daily', status='captured', not yet at monthly cap, +/// last charge was 24+ hours ago, server not deleted. +pub async fn list_daily_sweep_candidates( + pool: &PgPool, + limit: i64, +) -> Result, String> { + sqlx::query_as::<_, AuthorizationRow>( + r#"SELECT * FROM marketplace_install_authorization + WHERE billing_cycle = 'deployment_daily' + AND status = 'captured' + AND server_deleted_at IS NULL + AND suspended_at IS NULL + AND (last_daily_charge_at IS NULL OR last_daily_charge_at < now() - interval '24 hours') + ORDER BY last_daily_charge_at ASC NULLS FIRST + LIMIT $1"#, + ) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| format!("list_daily_sweep_candidates: {}", e)) +} + +/// Mark a daily charge as applied: bump total_charged_minor and last_daily_charge_at. +pub async fn mark_daily_charged( + pool: &PgPool, + authorization_id: &str, + charged_minor: i64, +) -> Result<(), String> { + sqlx::query( + r#"UPDATE marketplace_install_authorization + SET total_charged_minor = total_charged_minor + $2, + last_daily_charge_at = now(), + updated_at = now() + WHERE authorization_id = $1"#, + ) + .bind(authorization_id) + .bind(charged_minor) + .execute(pool) + .await + .map_err(|e| format!("mark_daily_charged: {}", e))?; + Ok(()) +} + +/// Mark server as deleted (user-initiated deletion). +pub async fn mark_server_deleted(pool: &PgPool, authorization_id: &str) -> Result<(), String> { + sqlx::query( + r#"UPDATE marketplace_install_authorization + SET server_deleted_at = now(), updated_at = now() + WHERE authorization_id = $1 AND server_deleted_at IS NULL"#, + ) + .bind(authorization_id) + .execute(pool) + .await + .map_err(|e| format!("mark_server_deleted: {}", e))?; + Ok(()) +} + +/// Mark server as suspended (grace period expired). +pub async fn mark_suspended(pool: &PgPool, authorization_id: &str) -> Result<(), String> { + sqlx::query( + r#"UPDATE marketplace_install_authorization + SET suspended_at = now(), updated_at = now() + WHERE authorization_id = $1 AND suspended_at IS NULL"#, + ) + .bind(authorization_id) + .execute(pool) + .await + .map_err(|e| format!("mark_suspended: {}", e))?; + Ok(()) +} + +/// Update billing_cycle and daily rate fields on an authorization row. +pub async fn set_daily_billing( + pool: &PgPool, + authorization_id: &str, + daily_rate: f64, + monthly_cap: f64, +) -> Result<(), String> { + sqlx::query( + r#"UPDATE marketplace_install_authorization + SET billing_cycle = 'deployment_daily', + daily_rate = $2, + monthly_cap = $3, + updated_at = now() + WHERE authorization_id = $1"#, + ) + .bind(authorization_id) + .bind(daily_rate) + .bind(monthly_cap) + .execute(pool) + .await + .map_err(|e| format!("set_daily_billing: {}", e))?; + Ok(()) +} + +/// Fetch authorization by deployment_hash (for one-click flow). +pub async fn find_by_deployment_hash_optional( + pool: &PgPool, + deployment_hash: &str, +) -> Result, String> { + sqlx::query_as::<_, AuthorizationRow>( + r#"SELECT * FROM marketplace_install_authorization + WHERE deployment_hash = $1"#, + ) + .bind(deployment_hash) + .fetch_optional(pool) + .await + .map_err(|e| format!("find_by_deployment_hash_optional: {}", e)) +} diff --git a/src/db/mod.rs b/src/db/mod.rs index fc319898..3274460b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -19,3 +19,4 @@ pub mod rating; pub mod remote_secret; pub mod resilience; pub(crate) mod server; +pub mod server_type_daily_rate; diff --git a/src/db/server_type_daily_rate.rs b/src/db/server_type_daily_rate.rs new file mode 100644 index 00000000..336aa753 --- /dev/null +++ b/src/db/server_type_daily_rate.rs @@ -0,0 +1,89 @@ +use sqlx::PgPool; + +use crate::models::ServerTypeDailyRate; + +/// Fetch daily rate configuration for a server type. +pub async fn fetch( + pool: &PgPool, + server_type: &str, +) -> Result, String> { + sqlx::query_as::<_, ServerTypeDailyRate>( + r#"SELECT server_type, daily_rate, monthly_cap, hetzner_monthly_eur, + created_at, updated_at + FROM server_type_daily_rate + WHERE server_type = $1"#, + ) + .bind(server_type) + .fetch_optional(pool) + .await + .map_err(|e| format!("Failed to fetch server_type_daily_rate: {}", e)) +} + +/// List all server type daily rate configurations. +pub async fn list(pool: &PgPool) -> Result, String> { + sqlx::query_as::<_, ServerTypeDailyRate>( + r#"SELECT server_type, daily_rate, monthly_cap, hetzner_monthly_eur, + created_at, updated_at + FROM server_type_daily_rate + ORDER BY daily_rate ASC"#, + ) + .fetch_all(pool) + .await + .map_err(|e| format!("Failed to list server_type_daily_rate: {}", e)) +} + +/// Upsert daily rate for a server type. +pub async fn upsert( + pool: &PgPool, + server_type: &str, + daily_rate: f64, + monthly_cap: f64, + hetzner_monthly_eur: Option, +) -> Result { + sqlx::query_as::<_, ServerTypeDailyRate>( + r#"INSERT INTO server_type_daily_rate (server_type, daily_rate, monthly_cap, hetzner_monthly_eur, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (server_type) DO UPDATE SET + daily_rate = EXCLUDED.daily_rate, + monthly_cap = EXCLUDED.monthly_cap, + hetzner_monthly_eur = EXCLUDED.hetzner_monthly_eur, + updated_at = NOW() + RETURNING server_type, daily_rate, monthly_cap, hetzner_monthly_eur, created_at, updated_at"#, + ) + .bind(server_type) + .bind(daily_rate) + .bind(monthly_cap) + .bind(hetzner_monthly_eur) + .fetch_one(pool) + .await + .map_err(|e| format!("Failed to upsert server_type_daily_rate: {}", e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calculate_daily_rate_formula() { + // cpx11: €4.85/mo → $0.27/day + let rate = ServerTypeDailyRate::calculate_daily_rate(4.85); + assert!((rate - 0.27).abs() < 0.01, "expected ~0.27, got {}", rate); + + // cpx32: €15.59/mo → $0.86/day + let rate = ServerTypeDailyRate::calculate_daily_rate(15.59); + assert!((rate - 0.86).abs() < 0.01, "expected ~0.86, got {}", rate); + + // cpx42: €30.39/mo → $1.67/day + let rate = ServerTypeDailyRate::calculate_daily_rate(30.39); + assert!((rate - 1.67).abs() < 0.01, "expected ~1.67, got {}", rate); + } + + #[test] + fn calculate_monthly_cap() { + let cap = ServerTypeDailyRate::calculate_monthly_cap(0.86); + assert!((cap - 25.80).abs() < 0.01, "expected ~25.80, got {}", cap); + + let cap = ServerTypeDailyRate::calculate_monthly_cap(1.67); + assert!((cap - 50.10).abs() < 0.01, "expected ~50.10, got {}", cap); + } +} diff --git a/src/forms/project/deploy.rs b/src/forms/project/deploy.rs index 37f99d05..c0d8db4e 100644 --- a/src/forms/project/deploy.rs +++ b/src/forms/project/deploy.rs @@ -80,6 +80,14 @@ pub struct Deploy { /// Each string is a port number or "port/protocol" (e.g. "8000" or "8000/tcp"). #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) public_ports: Option>, + + /// Reverse-proxy routing domains (from `proxy.domains` in stacker.yml), + /// forwarded verbatim to the Install Service for config-file proxies + /// (caddy Caddyfile, nginx conf.d). Pass-through JSON array of + /// `{domain, upstream, ssl}`. Traefik routes via container labels and does + /// not use this. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) proxy_domains: Option, } impl std::fmt::Debug for Deploy { diff --git a/src/forms/project/payload.rs b/src/forms/project/payload.rs index bb864344..d948491c 100644 --- a/src/forms/project/payload.rs +++ b/src/forms/project/payload.rs @@ -41,6 +41,20 @@ pub struct Payload { pub config_bundle: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub runtime_artifact_bundle: Option, + /// Reverse-proxy routing domains (from `proxy.domains` in stacker.yml), + /// forwarded to the Install Service where AppVarsMapper exposes them as the + /// `stacker_proxy_domains` Ansible extra-var so the config-file proxy roles + /// (caddy Caddyfile, nginx conf.d) and the NPM proxy-host task can route. + /// Top-level so `install_data["proxy_domains"]` resolves. JSON array of + /// `{domain, upstream, ssl}`. Traefik routes via labels and ignores this. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_domains: Option, + /// Per-project directory name on the remote server (e.g., "my-app"). + /// Used by the Ansible `custom` role as `stack_source` to namespace + /// the deploy directory: `/home/trydirect/{stack_code}/`. + /// Falls back to "project" when not set (backward compatibility). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stack_code: Option, } impl std::fmt::Debug for Payload { @@ -181,4 +195,32 @@ mod tests { json!("production") ); } + + #[test] + fn payload_serializes_proxy_domains_at_top_level() { + // The Install Service's AppVarsMapper reads install_data["proxy_domains"] + // (top-level) to build the `stacker_proxy_domains` extra-var. Regression + // guard: proxy_domains must serialize as a top-level key named exactly + // `proxy_domains` — not nested under stack/custom, and not renamed. + let mut payload = Payload::default(); + payload.proxy_domains = Some(json!([ + {"domain": "ntfy.example.com", "upstream": "app:80", "ssl": "off"} + ])); + + let serialized = serde_json::to_value(&payload).expect("serialize payload"); + assert_eq!( + serialized["proxy_domains"][0]["domain"], + json!("ntfy.example.com"), + "proxy_domains must be a top-level key so install_data['proxy_domains'] resolves" + ); + assert_eq!(serialized["proxy_domains"][0]["upstream"], json!("app:80")); + assert_eq!(serialized["proxy_domains"][0]["ssl"], json!("off")); + + // Absent by default (skip_serializing_if) so non-proxy deploys stay clean. + let empty = serde_json::to_value(Payload::default()).expect("serialize default"); + assert!( + empty.get("proxy_domains").is_none(), + "proxy_domains must be omitted when unset" + ); + } } diff --git a/src/forms/server.rs b/src/forms/server.rs index bae896e4..d567e015 100644 --- a/src/forms/server.rs +++ b/src/forms/server.rs @@ -36,6 +36,12 @@ pub struct ServerForm { /// Not persisted to the database. #[serde(skip_serializing_if = "Option::is_none")] pub ssh_private_key: Option, + /// Additional SSH public keys to install on the server alongside the + /// Vault-managed key. Used for cloud deploys to install the user's own + /// SSH key from `deploy.cloud.ssh_key` so they can SSH directly. + /// Not persisted to the database. + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_public_keys: Option>, } impl std::fmt::Debug for ServerForm { @@ -56,6 +62,7 @@ impl std::fmt::Debug for ServerForm { .field("vault_key_path", &self.vault_key_path) .field("public_key", &"[REDACTED]") .field("ssh_private_key", &"[REDACTED]") + .field("additional_public_keys", &"[REDACTED]") .finish() } } @@ -137,6 +144,7 @@ mod tests { vault_key_path: Some("/vault/path".to_string()), public_key: None, ssh_private_key: None, + additional_public_keys: None, }; let server: models::Server = (&form).into(); assert_eq!(server.cloud_id, Some(5)); diff --git a/src/helpers/bake.rs b/src/helpers/bake.rs index ffa3cafd..f409c90a 100644 --- a/src/helpers/bake.rs +++ b/src/helpers/bake.rs @@ -143,10 +143,21 @@ mod tests { async fn list_server_types( &self, _t: &str, - _l: Option<&str>, ) -> Result, crate::connectors::ConnectorError> { Ok(vec![]) } + async fn add_ssh_key( + &self, + _t: &str, + _n: &str, + _k: &str, + ) -> Result + { + Ok(crate::connectors::hetzner::HetznerSshKey { + id: 1, + name: "mock".into(), + }) + } } #[tokio::test] diff --git a/src/helpers/env_path.rs b/src/helpers/env_path.rs index 5012ce46..6ad33a5e 100644 --- a/src/helpers/env_path.rs +++ b/src/helpers/env_path.rs @@ -1,17 +1,25 @@ -pub const REMOTE_RUNTIME_ENV_PATH: &str = "/home/trydirect/project/.env"; +const DEFAULT_PROJECT: &str = "project"; + pub const REMOTE_RUNTIME_ENV_FILE: &str = ".env"; -pub const REMOTE_RUNTIME_COMPOSE_PATH: &str = "/home/trydirect/project/docker-compose.yml"; -pub fn remote_runtime_env_path() -> &'static str { - REMOTE_RUNTIME_ENV_PATH +pub fn remote_runtime_env_path_for(stack_code: &str) -> String { + format!("/home/trydirect/{}/.env", stack_code) +} + +pub fn remote_runtime_compose_path_for(stack_code: &str) -> String { + format!("/home/trydirect/{}/docker-compose.yml", stack_code) } pub fn compose_env_file_reference() -> &'static str { REMOTE_RUNTIME_ENV_FILE } -pub fn remote_runtime_compose_path() -> &'static str { - REMOTE_RUNTIME_COMPOSE_PATH +pub fn remote_runtime_env_path() -> String { + remote_runtime_env_path_for(DEFAULT_PROJECT) +} + +pub fn remote_runtime_compose_path() -> String { + remote_runtime_compose_path_for(DEFAULT_PROJECT) } #[cfg(test)] @@ -35,4 +43,20 @@ mod tests { "/home/trydirect/project/docker-compose.yml" ); } + + #[test] + fn remote_runtime_env_path_for_custom_stack() { + assert_eq!( + remote_runtime_env_path_for("my-app"), + "/home/trydirect/my-app/.env" + ); + } + + #[test] + fn remote_runtime_compose_path_for_custom_stack() { + assert_eq!( + remote_runtime_compose_path_for("my-app"), + "/home/trydirect/my-app/docker-compose.yml" + ); + } } diff --git a/src/mcp/tools/config.rs b/src/mcp/tools/config.rs index 7bfd80d3..6f9c05ad 100644 --- a/src/mcp/tools/config.rs +++ b/src/mcp/tools/config.rs @@ -164,7 +164,10 @@ impl ToolHandler for SetAppEnvVarTool { .await .map_err(|e| format!("Failed to load remote service secrets: {}", e))?; - if service_secrets.iter().any(|secret| secret.name == params.name) { + if service_secrets + .iter() + .any(|secret| secret.name == params.name) + { return Err(format!( "Environment variable '{}' is managed as a remote service secret. Use 'stacker secrets set {} --scope service --project {} --service {}' instead.", params.name, params.name, params.project_id, params.app_code diff --git a/src/mcp/tools/deployment.rs b/src/mcp/tools/deployment.rs index 4a98c9fb..ddecb4d2 100644 --- a/src/mcp/tools/deployment.rs +++ b/src/mcp/tools/deployment.rs @@ -960,7 +960,8 @@ mod tests { }, runtime: DeploymentRuntimeState { compose_path: "/opt/stacker/docker-compose.remote.yml".to_string(), - env_path: "/home/trydirect/project/.env".to_string(), + env_path: "/home/trydirect/test-project/.env".to_string(), + stack_code: "test-project".to_string(), }, apps: vec![], drift: DeploymentDriftState { diff --git a/src/mcp/tools/explain.rs b/src/mcp/tools/explain.rs index 2880d6e9..39f103e4 100644 --- a/src/mcp/tools/explain.rs +++ b/src/mcp/tools/explain.rs @@ -3,9 +3,10 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::db; -use crate::helpers::{remote_runtime_compose_path, remote_runtime_env_path}; +use crate::helpers::{remote_runtime_compose_path_for, remote_runtime_env_path_for}; use crate::mcp::protocol::{Tool, ToolContent}; use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::models::project::sanitize_project_name; use crate::models::{Project, ProjectApp}; use crate::services::config_renderer::EnvRenderInput; use crate::services::{ @@ -96,12 +97,13 @@ fn local_authoring_env_path(project: &Project) -> String { } fn runtime_compose_path(project: &Project) -> String { + let stack_code = format!("{}-{}", sanitize_project_name(&project.name), project.id); project .request_json .pointer("/custom/deployment_artifacts/config_bundle/remote_compose_path") .and_then(|value| value.as_str()) .map(ToOwned::to_owned) - .unwrap_or_else(|| remote_runtime_compose_path().to_string()) + .unwrap_or_else(|| remote_runtime_compose_path_for(&stack_code)) } fn project_target(project: &Project) -> String { @@ -252,11 +254,12 @@ impl ToolHandler for ExplainEnvTool { .or_else(|| apps.first()) .ok_or_else(|| "No deployment apps found".to_string())?; + let stack_code = format!("{}-{}", sanitize_project_name(&project.name), project.id); let explain = build_explain_env( &deployment.deployment_hash, &app.code, &local_authoring_env_path(&project), - remote_runtime_env_path(), + &remote_runtime_env_path_for(&stack_code), &runtime_compose_path(&project), app_env_input(app), ) @@ -293,13 +296,14 @@ impl ToolHandler for ExplainTopologyTool { .await .map_err(|err| format!("Failed to fetch apps: {err}"))?; + let stack_code = format!("{}-{}", sanitize_project_name(&project.name), project.id); let topology = build_explain_topology( &deployment.deployment_hash, &project_target(&project), "stacker.yml", &runtime_compose_path(&project), &local_authoring_env_path(&project), - remote_runtime_env_path(), + &remote_runtime_env_path_for(&stack_code), topology_services(&apps), ); @@ -384,7 +388,7 @@ mod tests { &deployment.deployment_hash, &app.code, &local_authoring_env_path(&project), - remote_runtime_env_path(), + &remote_runtime_env_path_for("test-project"), &runtime_compose_path(&project), app_env_input(&app), ) @@ -407,8 +411,8 @@ mod tests { "deployment_demo", "api", "docker/prod/.env", - remote_runtime_env_path(), - remote_runtime_compose_path(), + &remote_runtime_env_path_for("test-project"), + &remote_runtime_compose_path_for("test-project"), app_env_input(&{ let mut app = ProjectApp::new( 1, @@ -448,9 +452,9 @@ mod tests { "deployment_state_online", "cloud", "docker/prod/compose.yml", - remote_runtime_compose_path(), + &remote_runtime_compose_path_for("test-project"), "docker/prod/.env", - remote_runtime_env_path(), + &remote_runtime_env_path_for("test-project"), vec![ExplainTopologyService { code: "upload".to_string(), name: "Upload".to_string(), diff --git a/src/mcp/tools/pipes.rs b/src/mcp/tools/pipes.rs index 160dff8a..932be818 100644 --- a/src/mcp/tools/pipes.rs +++ b/src/mcp/tools/pipes.rs @@ -165,7 +165,7 @@ async fn activate_pipe_request( trigger: &str, poll_interval: u32, ) -> Result { - let (source_endpoint, source_method, target_endpoint, target_method, field_mapping) = + let (source_endpoint, source_method, target_endpoint, target_method, field_mapping, config) = if let Some(template_id) = pipe.template_id.as_ref() { let templates = client .list_pipe_templates(None, None) @@ -196,6 +196,11 @@ async fn activate_pipe_request( pipe.field_mapping_override .clone() .unwrap_or(template.field_mapping.clone()), + // Instance override wins over the template's config, so the + // agent receives the effective retry policy + handlers. + pipe.config_override + .clone() + .or_else(|| template.config.clone()), ) } else { ( @@ -204,6 +209,7 @@ async fn activate_pipe_request( "/".to_string(), "POST".to_string(), serde_json::json!({}), + pipe.config_override.clone(), ) } } else { @@ -215,6 +221,7 @@ async fn activate_pipe_request( pipe.field_mapping_override .clone() .unwrap_or(serde_json::json!({})), + pipe.config_override.clone(), ) }; @@ -232,6 +239,9 @@ async fn activate_pipe_request( "field_mapping": field_mapping, "trigger_type": trigger, "poll_interval_secs": poll_interval, + // Effective pipe config (retry policy + on_failure/on_success handlers) + // so the agent can apply resilience/lifecycle behavior. Omitted when unset. + "config": config, }); Ok( diff --git a/src/models/agent_protocol.rs b/src/models/agent_protocol.rs index be622bf8..bc23df66 100644 --- a/src/models/agent_protocol.rs +++ b/src/models/agent_protocol.rs @@ -38,7 +38,7 @@ pub struct StepResultMsg { } /// Retry policy configuration for step execution. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RetryPolicy { pub max_retries: u32, pub backoff_base_ms: u64, diff --git a/src/models/marketplace.rs b/src/models/marketplace.rs index e5928feb..884192fc 100644 --- a/src/models/marketplace.rs +++ b/src/models/marketplace.rs @@ -31,6 +31,12 @@ pub struct StackTemplate { pub price: Option, pub billing_cycle: Option, pub currency: Option, + #[serde(default)] + #[sqlx(default)] + pub daily_rate: Option, + #[serde(default)] + #[sqlx(default)] + pub monthly_cap: Option, pub created_at: Option>, pub updated_at: Option>, pub approved_at: Option>, diff --git a/src/models/mod.rs b/src/models/mod.rs index d8e9140a..59f7f055 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -12,6 +12,7 @@ pub mod dag; pub(crate) mod deployment; pub mod marketplace; pub mod pipe; +pub mod pipe_config; mod product; pub mod project; pub mod project_app; @@ -22,6 +23,7 @@ mod remote_secret; pub mod resilience; mod rules; mod server; +pub mod server_type_daily_rate; pub mod user; pub use agent::*; @@ -36,6 +38,7 @@ pub use dag::*; pub use deployment::*; pub use marketplace::*; pub use pipe::*; +pub use pipe_config::*; pub use product::*; pub use project::*; pub use project_app::*; @@ -45,4 +48,5 @@ pub use rating::*; pub use remote_secret::*; pub use rules::*; pub use server::*; +pub use server_type_daily_rate::*; pub use user::*; diff --git a/src/models/pipe_config.rs b/src/models/pipe_config.rs new file mode 100644 index 00000000..d539b906 --- /dev/null +++ b/src/models/pipe_config.rs @@ -0,0 +1,156 @@ +//! Typed view over a pipe's `config` JSON blob. +//! +//! `PipeTemplate.config` (and `PipeInstance.config_override`) is an untyped +//! `JsonValue` today — it only ever carried `{"retry_count": 3}`. `PipeConfig` +//! gives the resilience/lifecycle settings a typed home while staying +//! **backward-compatible**: parsing is lenient (unknown keys such as the legacy +//! `retry_count` are ignored, not rejected), absence falls back to defaults, and +//! writing merges *over* the existing blob so unrelated keys are preserved. +//! +//! See `config/docs/PIPE_IAC_AND_RESILIENCE_PLAN.md` (Phase 0 + §4). + +use serde::{Deserialize, Serialize}; +use sqlx::types::JsonValue; + +use crate::models::agent_protocol::RetryPolicy; + +fn default_notify_method() -> String { + "POST".to_string() +} + +/// Where a pipe's success/failure notification is delivered. Also reused by the +/// (future) `monitoring.alerts` dispatch — see plan §10. +/// +/// Serde is externally tagged, matching the declarative schema: +/// `{ "pipe": "oncall-notify" }` or `{ "notify": { "url": "...", "method": "POST" } }`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HandlerRef { + /// Run another declared pipe by name. + Pipe(String), + /// Deliver to an HTTP endpoint (e.g. an ntfy topic). + Notify { + url: String, + #[serde(default = "default_notify_method")] + method: String, + }, +} + +/// Typed resilience + lifecycle settings for a pipe, serialized into the pipe's +/// `config` JSON. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct PipeConfig { + /// Retry policy for delivery. Absent → engine default (3 / 1000ms / 30000ms). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry: Option, + /// Handler fired after retries are exhausted (delivery failed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_failure: Option, + /// Handler fired after a successful delivery. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_success: Option, +} + +impl PipeConfig { + /// Parse a `PipeConfig` from a pipe's optional `config` blob. Lenient: + /// unknown/legacy keys are ignored and a missing or unparseable config + /// yields defaults, so existing pipes (e.g. `{"retry_count": 3}`) keep + /// working unchanged. + pub fn from_value(config: &Option) -> Self { + config + .as_ref() + .and_then(|value| serde_json::from_value::(value.clone()).ok()) + .unwrap_or_default() + } + + /// Merge these typed settings *over* an existing config blob, preserving any + /// unrelated keys already present (legacy `retry_count`, custom fields, …). + /// Returns a JSON object suitable for `PipeTemplate.config`. + pub fn merge_into(&self, base: Option) -> JsonValue { + let mut obj = match base { + Some(JsonValue::Object(map)) => map, + _ => serde_json::Map::new(), + }; + if let Ok(JsonValue::Object(mine)) = serde_json::to_value(self) { + for (key, value) in mine { + obj.insert(key, value); + } + } + JsonValue::Object(obj) + } + + /// Effective retry policy for the delivery step (falls back to the engine + /// default when unset). This is the value #4a feeds into the agent's + /// `StepCommand.retry_policy`. + pub fn retry_or_default(&self) -> RetryPolicy { + self.retry.clone().unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn from_value_defaults_when_absent_or_legacy() { + // None → all-default, and retry falls back to the engine default. + let cfg = PipeConfig::from_value(&None); + assert!(cfg.retry.is_none() && cfg.on_failure.is_none()); + assert_eq!(cfg.retry_or_default().max_retries, 3); + + // Legacy blob with only the old key parses cleanly (key ignored). + let legacy = PipeConfig::from_value(&Some(json!({ "retry_count": 3 }))); + assert_eq!(legacy, PipeConfig::default()); + } + + #[test] + fn round_trips_retry_and_handlers() { + let cfg = PipeConfig { + retry: Some(RetryPolicy { + max_retries: 5, + backoff_base_ms: 500, + backoff_max_ms: 30_000, + }), + on_failure: Some(HandlerRef::Pipe("oncall-notify".into())), + on_success: Some(HandlerRef::Notify { + url: "https://ntfy.example.com/ok".into(), + method: "POST".into(), + }), + }; + let value = serde_json::to_value(&cfg).unwrap(); + // Externally-tagged handler shape. + assert_eq!(value["on_failure"], json!({ "pipe": "oncall-notify" })); + assert_eq!(value["retry"]["max_retries"], json!(5)); + assert_eq!(PipeConfig::from_value(&Some(value)), cfg); + } + + #[test] + fn notify_method_defaults_to_post() { + // method omitted → defaults to POST on parse. + let h: HandlerRef = + serde_json::from_value(json!({ "notify": { "url": "https://x/y" } })).unwrap(); + assert_eq!( + h, + HandlerRef::Notify { + url: "https://x/y".into(), + method: "POST".into() + } + ); + } + + #[test] + fn merge_into_preserves_unrelated_keys() { + let base = json!({ "retry_count": 9, "custom": true }); + let cfg = PipeConfig { + retry: Some(RetryPolicy::default()), + ..Default::default() + }; + let merged = cfg.merge_into(Some(base)); + // Our typed keys are written … + assert_eq!(merged["retry"]["max_retries"], json!(3)); + // … and pre-existing unrelated keys survive. + assert_eq!(merged["retry_count"], json!(9)); + assert_eq!(merged["custom"], json!(true)); + } +} diff --git a/src/models/server_type_daily_rate.rs b/src/models/server_type_daily_rate.rs new file mode 100644 index 00000000..400f51f0 --- /dev/null +++ b/src/models/server_type_daily_rate.rs @@ -0,0 +1,29 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// Platform default daily billing rates per Hetzner server type. +/// Stored in the `server_type_daily_rate` table. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, FromRow)] +pub struct ServerTypeDailyRate { + pub server_type: String, + pub daily_rate: f64, + pub monthly_cap: f64, + pub hetzner_monthly_eur: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +impl ServerTypeDailyRate { + /// Calculate daily rate from Hetzner monthly EUR cost. + /// Formula: hetzner_eur × 1.1 (exchange) × 1.5 (margin) / 30 days + pub fn calculate_daily_rate(hetzner_monthly_eur: f64) -> f64 { + let daily = hetzner_monthly_eur * 1.1 * 1.5 / 30.0; + (daily * 100.0).round() / 100.0 // round to 2 decimal places + } + + /// Calculate monthly cap from daily rate (30 days). + pub fn calculate_monthly_cap(daily_rate: f64) -> f64 { + (daily_rate * 30.0 * 100.0).round() / 100.0 + } +} diff --git a/src/routes/marketplace/admin.rs b/src/routes/marketplace/admin.rs index 40f2e8e1..fe6520b1 100644 --- a/src/routes/marketplace/admin.rs +++ b/src/routes/marketplace/admin.rs @@ -509,6 +509,10 @@ pub struct AdminPricingRequest { pub billing_cycle: Option, pub required_plan_name: Option, pub currency: Option, + /// Daily rate for deployment_daily billing (USD) + pub daily_rate: Option, + /// Monthly cap for deployment_daily billing (USD) + pub monthly_cap: Option, } #[tracing::instrument(name = "Admin update template pricing", skip_all)] @@ -530,6 +534,8 @@ pub async fn pricing_handler( req.billing_cycle.as_deref(), req.required_plan_name.as_deref(), req.currency.as_deref(), + req.daily_rate, + req.monthly_cap, ) .await .map_err(|err| JsonResponse::::build().bad_request(err))?; diff --git a/src/routes/marketplace/creator.rs b/src/routes/marketplace/creator.rs index f5441ad2..02098ab9 100644 --- a/src/routes/marketplace/creator.rs +++ b/src/routes/marketplace/creator.rs @@ -84,6 +84,10 @@ pub struct CreateTemplateRequest { pub price: Option, /// ISO 4217 currency code, default "USD" pub currency: Option, + /// Daily rate for deployment_daily billing (USD) + pub daily_rate: Option, + /// Monthly cap for deployment_daily billing (USD) + pub monthly_cap: Option, pub infrastructure_requirements: Option, /// Public ports: [{"name": "web", "port": 8080}, ...] pub public_ports: Option, diff --git a/src/routes/marketplace/install.rs b/src/routes/marketplace/install.rs index d04c5b0e..c9cdf95d 100644 --- a/src/routes/marketplace/install.rs +++ b/src/routes/marketplace/install.rs @@ -900,6 +900,9 @@ async fn install_stack_template( amount_minor: handle.amount_minor, currency: handle.currency.clone(), expires_at, + billing_cycle: Some("per_install".to_string()), + daily_rate: None, + monthly_cap: None, }, ) .await diff --git a/src/routes/oneclick_deploy/clone.rs b/src/routes/oneclick_deploy/clone.rs index ce9d1ecd..5f73d7b1 100644 --- a/src/routes/oneclick_deploy/clone.rs +++ b/src/routes/oneclick_deploy/clone.rs @@ -13,13 +13,17 @@ use std::sync::Arc; use actix_web::web::Data; use actix_web::{post, web, HttpResponse, Responder}; use serde::{Deserialize, Serialize}; +use serde_json::json; use sqlx::PgPool; +use uuid::Uuid; use crate::connectors::config::HetznerConfig; use crate::connectors::hetzner::{ HetznerCloudClient, HetznerCloudConnector, HetznerCreateServerRequest, }; +use crate::connectors::user_service::UserServiceConnector; use crate::helpers::cloud_init::{render_user_data, BootConfig}; +use crate::helpers::VaultClient; use crate::models::User; #[derive(Debug, Deserialize)] @@ -63,6 +67,14 @@ pub struct CloneResponse { pub public_ipv4: Option, pub stack: String, pub provider: String, + pub deployment_hash: String, + /// SSH private key (PEM) for the deploy key injected into the cloned server. + /// The user service must pass this to the install service for Ansible access. + pub ssh_private_key: String, + /// Present only for deployment_daily templates. The user service stores + /// this so it can void on failure or pass to deploy-complete for capture. + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization_id: Option, } #[post("/clone")] @@ -70,6 +82,7 @@ pub async fn clone_server( user: web::ReqData>, form: web::Json, pg_pool: Data, + user_service: Data>, ) -> impl Responder { tracing::debug!( user_id = %user.id, @@ -78,6 +91,23 @@ pub async fn clone_server( "clone deploy requested" ); + // Require a real user token up-front. The middleware resolves the user + // from several auth methods (agent, jwt, hmac, ...) that don't carry a + // user-service token; clone needs one for billing and service callbacks. + // 401 here lets the frontend redirect the user to sign in. + if user + .access_token + .as_deref() + .map(str::trim) + .unwrap_or("") + .is_empty() + { + return HttpResponse::Unauthorized().json(json!({ + "error": "Unauthorized", + "details": "User access token is missing", + })); + } + // Resolve the baked snapshot image_id. let snapshot = match if let Some(version) = &form.version { crate::db::baked_snapshot::resolve(&pg_pool, &form.stack, version, &form.provider).await @@ -132,6 +162,214 @@ pub async fn clone_server( })); }; + // Generate deployment_hash and persist Project + Deployment records. + let deployment_hash = format!("deployment_{}", Uuid::new_v4()); + let hex = &deployment_hash[deployment_hash.len() - 8..]; + let project_name = format!("oneclick-{}-{}", form.stack, hex); + + let project = match crate::db::project::insert( + &pg_pool, + crate::models::Project::new( + user.id.clone(), + project_name, + json!({"source": "oneclick_clone", "stack": form.stack}), + json!({}), + ), + ) + .await + { + Ok(p) => p, + Err(err) => { + tracing::error!(error = %err, "failed to create project for clone deploy"); + return HttpResponse::InternalServerError().json(json!({ + "error": "project creation failed", + "details": err, + })); + } + }; + + let mut deployment = crate::models::Deployment::new( + project.id, + Some(user.id.clone()), + deployment_hash.clone(), + "in_progress".to_string(), + "runc".to_string(), + json!({ + "source": "oneclick_clone", + "stack": form.stack, + "domain": form.domain, + "provider": form.provider, + "region": form.region, + }), + ); + deployment = match crate::db::deployment::insert(&pg_pool, deployment).await { + Ok(d) => d, + Err(err) => { + tracing::error!(error = %err, "failed to create deployment for clone"); + return HttpResponse::InternalServerError().json(json!({ + "error": "deployment creation failed", + "details": err, + })); + } + }; + tracing::info!( + deployment_id = deployment.id, + deployment_hash = %deployment_hash, + project_id = project.id, + "clone deployment records created" + ); + + // ── Deployment-daily billing: authorize before server creation ──────── + let mut authorization_id: Option = None; + match crate::db::marketplace::get_approved_by_slug(&pg_pool, &form.stack).await { + Ok(Some(template)) => { + tracing::info!( + template_slug = %form.stack, + billing_cycle = ?template.billing_cycle, + daily_rate = ?template.daily_rate, + "template found for billing check" + ); + if template.billing_cycle.as_deref() == Some("deployment_daily") { + // Resolve daily_rate: template override or server-type default + let daily_rate = if let Some(rate) = template.daily_rate { + rate + } else if let Ok(Some(cfg)) = + crate::db::server_type_daily_rate::fetch(&pg_pool, &form.server_type).await + { + cfg.daily_rate + } else { + 0.87 // fallback default + }; + let monthly_cap = template.monthly_cap.unwrap_or_else(|| daily_rate * 30.0); + + // Convert to minor units (cents) + let amount_minor = (daily_rate * 100.0).round() as i64; + let currency = template + .currency + .clone() + .unwrap_or_else(|| "USD".to_string()); + let idem_key = format!("oneclick-{}", deployment_hash); + + // Get user's access token for authorization + let user_token = user.access_token.as_deref().unwrap_or(""); + if !user_token.is_empty() { + match user_service + .authorize_install_charge( + user_token, + &template.id, + amount_minor, + ¤cy, + &idem_key, + ) + .await + { + Ok(handle) => { + let expires_at = handle + .expires_at + .as_deref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + + match crate::db::marketplace_billing::insert_authorization( + &pg_pool, + crate::db::marketplace_billing::NewAuthorization { + user_id: user.id.clone(), + template_id: template.id, + idempotency_key: idem_key, + authorization_id: handle.authorization_id.clone(), + amount_minor: handle.amount_minor, + currency: handle.currency.clone(), + expires_at, + billing_cycle: Some("deployment_daily".to_string()), + daily_rate: Some(daily_rate), + monthly_cap: Some(monthly_cap), + }, + ) + .await + { + Ok(auth_row) => { + crate::db::marketplace_billing::attach_deployment_hash( + &pg_pool, + auth_row.id, + &deployment_hash, + ) + .await + .ok(); + authorization_id = Some(handle.authorization_id); + tracing::info!( + deployment_hash = %deployment_hash, + daily_rate = daily_rate, + monthly_cap = monthly_cap, + "deployment_daily authorization created" + ); + } + Err(err) => { + tracing::error!("Failed to store authorization: {}", err); + let _ = user_service + .void_install_charge( + user_token, + &handle.authorization_id, + "db_write_failed", + ) + .await; + return HttpResponse::InternalServerError().json(json!({ + "error": "authorization storage failed", + "details": err, + })); + } + } + } + Err(err) => { + tracing::error!("authorize_install_charge failed: {:?}", err); + return HttpResponse::PaymentRequired().json(json!({ + "error": "Payment authorization failed", + "details": format!("{:?}", err), + })); + } + } + } else { + tracing::warn!( + "deployment_daily template but user has no access_token, skipping authorize" + ); + } + } else { + tracing::info!( + template_slug = %form.stack, + billing_cycle = ?template.billing_cycle, + "template is not deployment_daily, skipping billing" + ); + } + } + Ok(None) => { + tracing::warn!( + template_slug = %form.stack, + "stack not registered in stack_template; refusing to deploy" + ); + return HttpResponse::NotFound().json(json!({ + "error": "Unknown stack", + "details": format!( + "stack '{}' is not registered in the marketplace catalog", + form.stack + ), + })); + } + Err(err) => { + tracing::warn!(error = %err, "failed to look up template for billing"); + } + } + + // Generate a per-deploy SSH keypair so Ansible can reach the server post-boot. + let (public_key, private_key) = match VaultClient::generate_ssh_keypair() { + Ok(pair) => pair, + Err(err) => { + tracing::error!(error = %err, "failed to generate SSH keypair"); + return HttpResponse::InternalServerError().json(json!({ + "error": "SSH key generation failed", + "details": err, + })); + } + }; + let client = match HetznerCloudClient::new(htz.base_url.clone()) { Ok(client) => client, Err(err) => { @@ -142,12 +380,40 @@ pub async fn clone_server( } }; + let mut ssh_key_ids: Vec = Vec::new(); + match client + .add_ssh_key( + token, + &format!( + "deploy-{}-{}", + form.stack, + &deployment_hash[deployment_hash.len() - 8..] + ), + &public_key, + ) + .await + { + Ok(ssh_key) => { + ssh_key_ids.push(ssh_key.id); + } + Err(err) => { + // Non-fatal: the server will be created without the key. The user + // can still add it manually, but post-deploy Ansible will fail. + tracing::warn!(error = %err, "failed to register SSH key on Hetzner — post-deploy setup may fail"); + } + } + let request = HetznerCreateServerRequest { - name: format!("{}-{}", form.stack, snapshot.version), + name: format!( + "{}-{}-{}", + form.stack, + snapshot.version, + &deployment_hash[11..19] + ), server_type: form.server_type.clone(), location: form.region.clone(), image_id, - ssh_key_ids: Vec::new(), + ssh_key_ids, user_data: Some(user_data), }; @@ -167,5 +433,87 @@ pub async fn clone_server( public_ipv4: provisioned.public_ipv4, stack: form.stack.clone(), provider: form.provider.clone(), + deployment_hash, + ssh_private_key: private_key, + authorization_id, }) } + +#[cfg(test)] +mod tests { + use super::*; + use actix_web::test; + use actix_web::web; + use actix_web::HttpMessage; + + fn test_user(token: Option) -> Arc { + Arc::new(User { + id: "test-user-1".to_string(), + first_name: "Test".to_string(), + last_name: "User".to_string(), + email: "test@example.com".to_string(), + role: "user".to_string(), + email_confirmed: true, + mfa_verified: true, + access_token: token, + }) + } + + fn valid_payload() -> serde_json::Value { + serde_json::json!({ + "stack": "wordpress", + "provider": "hetzner", + "region": "fsn1", + "server_type": "cpx11", + "domain": "example.com", + "admin_email": "admin@example.com", + "env": {}, + }) + } + + async fn call_clone(user: Arc) -> actix_web::http::StatusCode { + let pg_pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://postgres:postgres@localhost/stacker_test") + .expect("lazy pool"); + let user_service: Arc = + Arc::new(crate::connectors::user_service::mock::MockUserServiceConnector); + + let app = test::init_service( + actix_web::App::new() + .app_data(web::Data::new(pg_pool)) + .app_data(web::Data::new(user_service)) + .service(clone_server), + ) + .await; + + let req = test::TestRequest::post() + .uri("/clone") + .set_json(valid_payload()) + .insert_header(("Authorization", "Bearer dummy-token")) + .to_request(); + req.extensions_mut().insert(Arc::clone(&user)); + + let resp = test::call_service(&app, req).await; + resp.status() + } + + #[actix_web::test] + async fn clone_without_access_token_returns_401() { + let status = call_clone(test_user(None)).await; + assert_eq!(status, actix_web::http::StatusCode::UNAUTHORIZED); + } + + #[actix_web::test] + async fn clone_with_blank_access_token_returns_401() { + let status = call_clone(test_user(Some(" ".to_string()))).await; + assert_eq!(status, actix_web::http::StatusCode::UNAUTHORIZED); + } + + #[actix_web::test] + async fn clone_with_access_token_passes_auth_guard() { + // The guard should pass; the handler then proceeds (and fails later + // on snapshot/DB/network, not on auth). + let status = call_clone(test_user(Some("valid-token".to_string()))).await; + assert_ne!(status, actix_web::http::StatusCode::UNAUTHORIZED); + } +} diff --git a/src/routes/oneclick_deploy/mod.rs b/src/routes/oneclick_deploy/mod.rs index 2a8c587b..bd70c068 100644 --- a/src/routes/oneclick_deploy/mod.rs +++ b/src/routes/oneclick_deploy/mod.rs @@ -128,7 +128,9 @@ fn to_issue_json(issues: &[&crate::cli::error::ValidationIssue]) -> Vec impl Responder { // Parse. A malformed YAML is a config error, not a server error. - let config = match StackerConfig::from_str(&body) { + // Use from_str_raw so config_contract install inputs (`${VAR}`) that are + // filled at deploy time do not fail validation when undefined here. + let config = match StackerConfig::from_str_raw(&body) { Ok(config) => config, Err(err) => { let failed = ValidateFailed { diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index 4e96698e..7780ecae 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -272,20 +272,6 @@ struct HetznerIpv4 { ip: String, } -#[derive(Debug, Deserialize)] -struct HetznerServerTypesResponse { - #[serde(default)] - server_types: Vec, -} - -#[derive(Debug, Deserialize)] -struct HetznerServerTypeEntry { - name: String, - /// Non-null when Hetzner has deprecated this type; value is an ISO-8601 timestamp. - #[serde(default)] - deprecated: Option, -} - fn hetzner_api_base_url() -> String { std::env::var("STACKER_HETZNER_API_URL") .unwrap_or_else(|_| "https://api.hetzner.cloud/v1".to_string()) @@ -471,90 +457,16 @@ async fn validate_hetzner_server_type( None => return Ok(()), }; - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(8)) - .build() - .map_err(|err| format!("Could not initialize Hetzner API client: {}", err))?; - - let url = match region { - Some(loc) => format!("{}/server_types?location={}", hetzner_api_base_url(), loc), - None => format!("{}/server_types", hetzner_api_base_url()), - }; - - let response = match client.get(&url).bearer_auth(&token).send().await { - Ok(r) => r, - Err(err) => { - tracing::warn!( - "Could not reach Hetzner API to validate server type '{}': {}; proceeding", - server_type, - err - ); - return Ok(()); - } - }; - - if !response.status().is_success() { - tracing::warn!( - "Hetzner server_types API returned HTTP {}; skipping server type validation", - response.status().as_u16() - ); - return Ok(()); - } - - let body = match response.json::().await { - Ok(b) => b, - Err(err) => { - tracing::warn!( - "Invalid Hetzner server types response: {}; skipping validation", - err - ); - return Ok(()); - } - }; - - // Check if the requested type is deprecated before checking availability. - if let Some(entry) = body - .server_types - .iter() - .find(|t| t.name.eq_ignore_ascii_case(server_type)) - { - if entry.deprecated.is_some() { - let active: Vec<&str> = body - .server_types - .iter() - .filter(|t| t.deprecated.is_none()) - .map(|t| t.name.as_str()) - .collect(); - return Err(format!( - "Server type '{}' is deprecated in Hetzner and can no longer be used to create new servers. \ - Set `deploy.cloud.size` in stacker.yml to an active type: {}", - server_type, - if active.is_empty() { - "none found".to_string() - } else { - active.join(", ") - } - )); - } - return Ok(()); - } - - let available: Vec<&str> = body - .server_types - .iter() - .filter(|t| t.deprecated.is_none()) - .map(|t| t.name.as_str()) - .collect(); - - Err(format!( - "Server type '{}' is not available in Hetzner. Available types: {}", + // Delegate to the shared connector so the route handler and the CLI + // local-orchestrator path enforce identical rules. See + // `crate::connectors::hetzner::validate_server_type_availability`. + crate::connectors::hetzner::validate_server_type_availability( + &hetzner_api_base_url(), + &token, server_type, - if available.is_empty() { - "none found".to_string() - } else { - available.join(", ") - } - )) + region, + ) + .await } async fn validate_template_server_capacity_requirements( @@ -1459,6 +1371,23 @@ async fn execute_deployment( server }; + // Merge any additional public keys (e.g., user's own SSH key from + // deploy.cloud.ssh_key) into the new_public_key so the Install Service + // installs all of them in authorized_keys. + if let Some(additional) = form.server.additional_public_keys.as_ref() { + if !additional.is_empty() { + let combined = match new_public_key.take() { + Some(vault_key) => { + let mut keys = vec![vault_key]; + keys.extend(additional.iter().cloned()); + keys.join("\n") + } + None => additional.join("\n"), + }; + new_public_key = Some(combined); + } + } + let has_existing_ip = server.srv_ip.as_ref().map_or(false, |ip| !ip.is_empty()); if has_existing_ip && new_public_key.is_none() && server.vault_key_path.is_none() { tracing::error!( @@ -1529,6 +1458,18 @@ async fn execute_deployment( ); } } + + // Record reverse-proxy routing domains on the deployment's request_json for + // audit/rollback. NOTE: this stored record is NOT what reaches the Install + // Service — the MQ payload is built from the project + form in + // `install_service.deploy()`, so actual delivery is via `payload.proxy_domains` + // (threaded through the deploy call below). AppVarsMapper reads it as the + // `stacker_proxy_domains` extra var. + if let Some(ref proxy_domains) = form.proxy_domains { + if let Some(obj) = json_request.as_object_mut() { + obj.insert("proxy_domains".to_string(), proxy_domains.clone()); + } + } let deployment_hash = format!("deployment_{}", Uuid::new_v4()); let deployment = models::Deployment::new( dc.project.id, @@ -1599,6 +1540,7 @@ async fn execute_deployment( mq_manager, new_public_key, new_private_key, + form.proxy_domains.clone(), ) .await .map_err(|err| JsonResponse::::build().internal_server_error(err))?; @@ -2333,6 +2275,8 @@ mod tests { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, created_at: None, updated_at: None, approved_at: None, diff --git a/src/routes/server/delete.rs b/src/routes/server/delete.rs index 6211309d..520cde5b 100644 --- a/src/routes/server/delete.rs +++ b/src/routes/server/delete.rs @@ -6,6 +6,12 @@ use actix_web::{delete, get, web, Responder, Result}; use sqlx::PgPool; use std::sync::Arc; +use crate::connectors::config::HetznerConfig; +use crate::connectors::hetzner::{ + HetznerCloudClient, HetznerCloudConnector, HetznerSnapshotTarget, +}; +use crate::connectors::user_service::UserServiceConnector; + /// Preview what would be deleted if the server is removed. /// Returns: ssh_key_shared, affected_deployments, agent_count #[tracing::instrument(name = "Preview server deletion impact.", skip_all)] @@ -78,6 +84,7 @@ pub async fn item( path: web::Path<(i32,)>, pg_pool: web::Data, vault_client: web::Data, + user_service: web::Data>, ) -> Result { let (id,) = path.into_inner(); @@ -120,7 +127,40 @@ pub async fn item( if let Ok(Some(deployment)) = db::deployment::fetch_by_project_id(pg_pool.get_ref(), server.project_id).await { - // Delete agent record + // 3a. Stop daily billing for deployment_daily authorizations + if let Ok(Some(auth)) = db::marketplace_billing::find_by_deployment_hash( + pg_pool.get_ref(), + &deployment.deployment_hash, + ) + .await + { + if auth.billing_cycle.as_deref() == Some("deployment_daily") + && auth.server_deleted_at.is_none() + { + tracing::info!( + "Marking deployment_daily authorization {} as server_deleted", + auth.authorization_id + ); + if let Err(err) = db::marketplace_billing::mark_server_deleted( + pg_pool.get_ref(), + &auth.authorization_id, + ) + .await + { + tracing::warn!("mark_server_deleted error: {}", err); + } + // Void remaining hold + let service_token = std::env::var("STACKER_SERVICE_TOKEN").unwrap_or_default(); + if let Err(err) = user_service + .void_install_charge(&service_token, &auth.authorization_id, "server_deleted") + .await + { + tracing::warn!("void_install_charge after server delete failed: {}", err); + } + } + } + + // 3b. Delete agent record if let Ok(Some(agent)) = db::agent::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment.deployment_hash) .await diff --git a/src/services/config_renderer.rs b/src/services/config_renderer.rs index 9616e48f..b09149ce 100644 --- a/src/services/config_renderer.rs +++ b/src/services/config_renderer.rs @@ -10,7 +10,8 @@ use crate::configuration::DeploymentSettings; use crate::db; -use crate::helpers::env_path::{compose_env_file_reference, remote_runtime_env_path}; +use crate::helpers::env_path::{compose_env_file_reference, remote_runtime_env_path_for}; +use crate::models::project::sanitize_project_name; use crate::models::{Project, ProjectApp}; use crate::services::env_model::{ normalize_optional_json_env, reconcile_env_layers, EnvLayer, ReconciledEnv, @@ -442,6 +443,13 @@ pub struct PortMapping { pub protocol: String, } +/// True for a Compose named-volume reference (e.g. "postgres_data"), false +/// for a bind mount (paths starting with `.`, `/`, or `~`), which Compose +/// does not declare under top-level `volumes:`. +fn is_named_volume(source: &str) -> bool { + !source.starts_with('.') && !source.starts_with('/') && !source.starts_with('~') +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VolumeMount { pub source: String, @@ -547,10 +555,11 @@ impl ConfigRenderer { app_contexts.push(context); let rendered_env = self.render_env_file(app, deployment_hash, &environment)?; + let stack_code = format!("{}-{}", sanitize_project_name(&project.name), project.id); let config = AppConfig { content: rendered_env.content, content_type: "env".to_string(), - destination_path: remote_runtime_env_path().to_string(), + destination_path: remote_runtime_env_path_for(&stack_code), file_mode: "0600".to_string(), owner: Some("trydirect".to_string()), group: Some("docker".to_string()), @@ -905,14 +914,61 @@ impl ConfigRenderer { context.insert("project_id", &project.stack_id.to_string()); context.insert("env_file", compose_env_file_reference()); - // Extract network configuration from project metadata - let default_network = project - .metadata - .get("network") - .and_then(|v| v.as_str()) - .unwrap_or("trydirect_network") - .to_string(); + // The top-level `networks:` block must declare whatever name the + // per-app `networks:` list actually references (usually + // "default_network", resolved from the project's own network + // config — see `ProjectAppService::default_network_from_project`). + // `project.metadata["network"]` is an unrelated, never-populated + // field; falling back to it (or to the "trydirect_network" literal) + // when apps already carry a real network name produced a top-level + // declaration that didn't match any app's `networks:` entry — + // "service X refers to undefined network default_network". + let default_network = apps + .iter() + .find_map(|app| app.networks.first().cloned()) + .or_else(|| { + project + .metadata + .get("network") + .and_then(|v| v.as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| "trydirect_network".to_string()); + // "default_network" is the platform's shared external network — the + // agent/status-panel and every project's containers all attach to + // the *same*, pre-existing host network by that literal name (see + // `forms::project::network::Network::default()` and + // `compose_service_sync::upsert_external_network`, which both + // always mark it `external: true`). Declaring it here as a plain + // `driver: bridge` network instead makes `docker compose -p + // up` create a brand new project-scoped network + // (`_default_network`) rather than joining the real + // shared one — the redeployed container ends up isolated from the + // rest of the stack (DNS lookups for sibling services fail). See + // GH #237. Any other network name (the "trydirect_network" + // fallback) keeps its own project-scoped bridge network, matching + // `Network::into()`'s `is_default` check. + let default_network_is_external = default_network == "default_network"; context.insert("default_network", &default_network); + context.insert("default_network_is_external", &default_network_is_external); + + // Same class of bug as the network mismatch above (GH #211): the + // per-service `volumes:` list can reference a named volume (e.g. + // "postgres_data"), but nothing declared it at the top level, so + // `docker compose` rejected the file with "service X refers to + // undefined volume postgres_data". Declare every named volume + // actually referenced by a service — bind mounts (paths starting + // with `.`, `/`, or `~`) are left alone, since those aren't + // declared under top-level `volumes:` at all. + let mut named_volumes: Vec = Vec::new(); + for app in apps { + for vol in &app.volumes { + if is_named_volume(&vol.source) && !named_volumes.contains(&vol.source) { + named_volumes.push(vol.source.clone()); + } + } + } + context.insert("named_volumes", &named_volumes); self.tera .render("docker-compose.yml.tera", &context) @@ -956,10 +1012,11 @@ impl ConfigRenderer { ) -> Result<(AppConfig, String)> { let environment = self.resolve_app_environment(pool, project, app).await?; let rendered_env = self.render_env_file(app, deployment_hash, &environment)?; + let stack_code = format!("{}-{}", sanitize_project_name(&project.name), project.id); let config = AppConfig { content: rendered_env.content, content_type: "env".to_string(), - destination_path: remote_runtime_env_path().to_string(), + destination_path: remote_runtime_env_path_for(&stack_code), file_mode: "0600".to_string(), owner: Some("trydirect".to_string()), group: Some("docker".to_string()), @@ -1186,7 +1243,17 @@ services: {% endfor %} networks: {{ default_network }}: +{% if default_network_is_external %} + external: true +{% else %} driver: bridge +{% endif %} +{% if named_volumes | length > 0 %} +volumes: +{% for vol in named_volumes %} + {{ vol }}: +{% endfor %} +{% endif %} "#; /// Individual service template (for partial updates) @@ -1604,7 +1671,10 @@ mod tests { #[test] fn test_env_destination_path_format() { // Test that .env files have correct destination paths - assert_eq!(remote_runtime_env_path(), "/home/trydirect/project/.env"); + assert_eq!( + remote_runtime_env_path_for("project"), + "/home/trydirect/project/.env" + ); } #[test] @@ -1613,7 +1683,7 @@ mod tests { let config = AppConfig { content: "FOO=bar\nBAZ=qux".to_string(), content_type: "env".to_string(), - destination_path: remote_runtime_env_path().to_string(), + destination_path: remote_runtime_env_path_for("test-project"), file_mode: "0600".to_string(), owner: Some("trydirect".to_string()), group: Some("docker".to_string()), @@ -1621,7 +1691,7 @@ mod tests { assert_eq!(config.content_type, "env"); assert_eq!(config.file_mode, "0600"); - assert_eq!(config.destination_path, remote_runtime_env_path()); + assert_eq!(config.destination_path, "/home/trydirect/test-project/.env"); } #[test] @@ -1654,7 +1724,7 @@ mod tests { AppConfig { content: "INFLUX_TOKEN=xxx".to_string(), content_type: "env".to_string(), - destination_path: remote_runtime_env_path().to_string(), + destination_path: remote_runtime_env_path_for("test-project"), file_mode: "0600".to_string(), owner: Some("trydirect".to_string()), group: Some("docker".to_string()), @@ -1666,7 +1736,7 @@ mod tests { AppConfig { content: "DOMAIN=example.com".to_string(), content_type: "env".to_string(), - destination_path: remote_runtime_env_path().to_string(), + destination_path: remote_runtime_env_path_for("test-project"), file_mode: "0600".to_string(), owner: Some("trydirect".to_string()), group: Some("docker".to_string()), @@ -1804,6 +1874,222 @@ mod tests { assert!(json.get("runtime").is_none() || json["runtime"].is_null()); } + // Regression test for GH issue #211: the rendered compose's top-level + // `networks:` block must declare the same name each app's own + // `networks:` list references. Before the fix, the top-level block + // always used `project.metadata["network"]` (never populated in + // practice) falling back to the unrelated literal "trydirect_network", + // while apps carry the project's real network name (typically + // "default_network", resolved via `custom.networks`) — producing + // "service X refers to undefined network default_network: invalid + // compose project" on every single-app deploy_app re-render. + #[test] + fn render_compose_top_level_network_matches_app_networks() { + let renderer = ConfigRenderer::new().unwrap(); + let project = Project { + name: "miniflux".to_string(), + ..Project::default() + }; + let app_ctx = AppRenderContext { + code: "app".to_string(), + name: "app".to_string(), + image: "miniflux/miniflux:latest".to_string(), + environment: HashMap::new(), + ports: vec![], + volumes: vec![], + domain: None, + ssl_enabled: false, + networks: vec!["default_network".to_string()], + depends_on: vec![], + restart_policy: "unless-stopped".to_string(), + resources: ResourceLimits::default(), + labels: HashMap::new(), + healthcheck: None, + runtime: None, + }; + let db_ctx = AppRenderContext { + code: "postgres".to_string(), + networks: vec!["default_network".to_string()], + ..app_ctx.clone() + }; + + let compose = renderer + .render_compose(&[app_ctx, db_ctx], &project) + .unwrap(); + let doc: serde_yaml::Value = serde_yaml::from_str(&compose).unwrap(); + + for service in ["app", "postgres"] { + let service_networks: Vec<&str> = doc["services"][service]["networks"] + .as_sequence() + .unwrap_or_else(|| panic!("{service} should declare networks:\n{compose}")) + .iter() + .filter_map(|v| v.as_str()) + .collect(); + for network in &service_networks { + assert!( + doc["networks"] + .as_mapping() + .map(|m| m.contains_key(serde_yaml::Value::String(network.to_string()))) + .unwrap_or(false), + "{service} references network '{network}' but top-level networks: \ + never declares it:\n{compose}" + ); + } + } + } + + // Regression test for GH issue #237: declaring `default_network` as a + // plain `driver: bridge` network (rather than `external: true`) makes + // `docker compose -p up` create a brand new project-scoped + // network instead of joining the shared external network the rest of + // the stack (and the agent/status-panel) actually run on — the + // redeployed container ends up isolated, unable to resolve sibling + // services by hostname. `default_network` must always render as + // `external: true`, matching `Network::default()` / + // `upsert_external_network` elsewhere in the codebase. + #[test] + fn render_compose_declares_default_network_as_external() { + let renderer = ConfigRenderer::new().unwrap(); + let project = Project { + name: "miniflux".to_string(), + ..Project::default() + }; + let app_ctx = AppRenderContext { + code: "miniflux".to_string(), + name: "miniflux".to_string(), + image: "miniflux/miniflux:latest".to_string(), + environment: HashMap::new(), + ports: vec![], + volumes: vec![], + domain: None, + ssl_enabled: false, + networks: vec!["default_network".to_string()], + depends_on: vec![], + restart_policy: "unless-stopped".to_string(), + resources: ResourceLimits::default(), + labels: HashMap::new(), + healthcheck: None, + runtime: None, + }; + + let compose = renderer.render_compose(&[app_ctx], &project).unwrap(); + let doc: serde_yaml::Value = serde_yaml::from_str(&compose).unwrap(); + + assert_eq!( + doc["networks"]["default_network"]["external"], + serde_yaml::Value::Bool(true), + "default_network must be declared external: true so `docker compose -p ` \ + attaches to the real shared network instead of creating a project-scoped copy:\n{compose}" + ); + } + + // Regression test for GH issue #236: same class of bug as #211, but for + // volumes. A named volume referenced under a service's `volumes:` list + // must also be declared under the top-level `volumes:` block, or + // `docker compose` rejects the file with "service X refers to + // undefined volume Y". Bind mounts (host paths) must NOT be declared. + #[test] + fn render_compose_declares_named_volumes_referenced_by_services() { + let renderer = ConfigRenderer::new().unwrap(); + let project = Project { + name: "miniflux".to_string(), + ..Project::default() + }; + let postgres_ctx = AppRenderContext { + code: "postgres".to_string(), + name: "postgres".to_string(), + image: "postgres:16-alpine".to_string(), + environment: HashMap::new(), + ports: vec![], + volumes: vec![ + VolumeMount { + source: "postgres_data".to_string(), + target: "/var/lib/postgresql/data".to_string(), + read_only: false, + }, + VolumeMount { + source: "./config".to_string(), + target: "/etc/postgres".to_string(), + read_only: true, + }, + ], + domain: None, + ssl_enabled: false, + networks: vec!["default_network".to_string()], + depends_on: vec![], + restart_policy: "unless-stopped".to_string(), + resources: ResourceLimits::default(), + labels: HashMap::new(), + healthcheck: None, + runtime: None, + }; + + let compose = renderer.render_compose(&[postgres_ctx], &project).unwrap(); + let doc: serde_yaml::Value = serde_yaml::from_str(&compose).unwrap(); + + let service_volumes: Vec<&str> = doc["services"]["postgres"]["volumes"] + .as_sequence() + .expect("postgres should declare volumes") + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!( + service_volumes + .iter() + .any(|v| v.starts_with("postgres_data:")), + "expected postgres_data mount in {:?}", + service_volumes + ); + + let top_level_volumes = doc + .get("volumes") + .and_then(|v| v.as_mapping()) + .cloned() + .unwrap_or_default(); + assert!( + top_level_volumes.contains_key(serde_yaml::Value::String("postgres_data".to_string())), + "postgres references named volume 'postgres_data' but top-level volumes: \ + never declares it:\n{compose}" + ); + assert!( + !top_level_volumes.contains_key(serde_yaml::Value::String("./config".to_string())), + "bind mounts must not be declared under top-level volumes::\n{compose}" + ); + } + + #[test] + fn render_compose_omits_volumes_block_when_no_named_volumes() { + let renderer = ConfigRenderer::new().unwrap(); + let project = Project { + name: "demo".to_string(), + ..Project::default() + }; + let ctx = AppRenderContext { + code: "web".to_string(), + name: "web".to_string(), + image: "nginx:latest".to_string(), + environment: HashMap::new(), + ports: vec![], + volumes: vec![], + domain: None, + ssl_enabled: false, + networks: vec![], + depends_on: vec![], + restart_policy: "unless-stopped".to_string(), + resources: ResourceLimits::default(), + labels: HashMap::new(), + healthcheck: None, + runtime: None, + }; + + let compose = renderer.render_compose(&[ctx], &project).unwrap(); + let doc: serde_yaml::Value = serde_yaml::from_str(&compose).unwrap(); + assert!( + doc.get("volumes").is_none(), + "no service declares a named volume, so volumes: should be omitted:\n{compose}" + ); + } + #[test] fn render_compose_references_relative_env_file() { let renderer = ConfigRenderer::new().unwrap(); diff --git a/src/services/deploy_plan.rs b/src/services/deploy_plan.rs index cb8db220..875eb8de 100644 --- a/src/services/deploy_plan.rs +++ b/src/services/deploy_plan.rs @@ -462,8 +462,9 @@ mod tests { }, }, runtime: DeploymentRuntimeState { - compose_path: "/home/trydirect/project/docker-compose.yml".to_string(), - env_path: "/home/trydirect/project/.env".to_string(), + compose_path: "/home/trydirect/test-project/docker-compose.yml".to_string(), + env_path: "/home/trydirect/test-project/.env".to_string(), + stack_code: "test-project".to_string(), }, apps: vec![ DeploymentAppState { diff --git a/src/services/deployment_state.rs b/src/services/deployment_state.rs index 79acb663..f9b8c966 100644 --- a/src/services/deployment_state.rs +++ b/src/services/deployment_state.rs @@ -4,10 +4,10 @@ use serde::{Deserialize, Serialize}; use crate::{ db, helpers::{ - extract_capabilities, has_capability, has_capability_value, remote_runtime_compose_path, - remote_runtime_env_path, NPM_CREDENTIAL_SOURCE_KEY, + extract_capabilities, has_capability, has_capability_value, + remote_runtime_compose_path_for, remote_runtime_env_path_for, NPM_CREDENTIAL_SOURCE_KEY, }, - models::{Agent, Command, Deployment, Project, ProjectApp}, + models::{project::sanitize_project_name, Agent, Command, Deployment, Project, ProjectApp}, }; pub const DEPLOYMENT_STATE_SCHEMA_VERSION: &str = "v1alpha1"; @@ -72,6 +72,7 @@ pub struct DeploymentAgentFeatures { pub struct DeploymentRuntimeState { pub compose_path: String, pub env_path: String, + pub stack_code: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -166,8 +167,17 @@ impl DeploymentState { features, }, runtime: DeploymentRuntimeState { - compose_path: remote_runtime_compose_path().to_string(), - env_path: remote_runtime_env_path().to_string(), + stack_code: format!("{}-{}", sanitize_project_name(&project.name), project.id), + compose_path: remote_runtime_compose_path_for(&format!( + "{}-{}", + sanitize_project_name(&project.name), + project.id + )), + env_path: remote_runtime_env_path_for(&format!( + "{}-{}", + sanitize_project_name(&project.name), + project.id + )), }, apps, drift: DeploymentDriftState { diff --git a/src/services/explain.rs b/src/services/explain.rs index 2117ce9e..9ef4fe16 100644 --- a/src/services/explain.rs +++ b/src/services/explain.rs @@ -185,7 +185,7 @@ fn to_layer(name: &str, layer: &HashMap) -> ExplainEnvLayer { #[cfg(test)] mod tests { use super::*; - use crate::helpers::{remote_runtime_compose_path, remote_runtime_env_path}; + use crate::helpers::{remote_runtime_compose_path_for, remote_runtime_env_path_for}; fn sample_input() -> EnvRenderInput { let mut input = EnvRenderInput { @@ -217,14 +217,17 @@ mod tests { "deployment_state_online", "device-api", "docker/prod/.env", - remote_runtime_env_path(), - remote_runtime_compose_path(), + &remote_runtime_env_path_for("test-project"), + &remote_runtime_compose_path_for("test-project"), sample_input(), ) .expect("explain env should build"); assert_eq!(explain.schema_version, EXPLAIN_SCHEMA_VERSION); - assert_eq!(explain.destination.path, remote_runtime_env_path()); + assert_eq!( + explain.destination.path, + "/home/trydirect/test-project/.env" + ); assert!(explain.rendered_env.service_secrets_override_server_secrets); assert!(explain.layers.iter().any(|layer| layer.name == "generated")); assert!(!explain @@ -243,9 +246,9 @@ mod tests { "deployment_state_online", "cloud", "docker/prod/compose.yml", - remote_runtime_compose_path(), + &remote_runtime_compose_path_for("test-project"), "docker/prod/.env", - remote_runtime_env_path(), + &remote_runtime_env_path_for("test-project"), vec![ ExplainTopologyService { code: "device-api".to_string(), @@ -260,8 +263,14 @@ mod tests { ], ); - assert_eq!(topology.runtime_compose_path, remote_runtime_compose_path()); - assert_eq!(topology.runtime_env_path, remote_runtime_env_path()); + assert_eq!( + topology.runtime_compose_path, + "/home/trydirect/test-project/docker-compose.yml" + ); + assert_eq!( + topology.runtime_env_path, + "/home/trydirect/test-project/.env" + ); assert_eq!(topology.services.len(), 2); } } diff --git a/src/services/install_authorization_sweeper.rs b/src/services/install_authorization_sweeper.rs index bc80bb5e..31dd0fca 100644 --- a/src/services/install_authorization_sweeper.rs +++ b/src/services/install_authorization_sweeper.rs @@ -34,6 +34,12 @@ const GRACE_SECS: i64 = 300; /// user_service will rate-limit us if we're too aggressive. const BATCH_LIMIT: i64 = 500; +/// Grace period for failed daily charges before suspending (24 hours). +const DAILY_CHARGE_GRACE_SECS: i64 = 86400; + +/// Time after suspension before deleting the server (3 days). +const SUSPENSION_DELETE_SECS: i64 = 259200; + pub fn spawn( pg_pool: PgPool, user_service: Arc, @@ -55,60 +61,205 @@ pub fn spawn( } async fn tick_once(pool: &PgPool, user_service: &dyn UserServiceConnector) -> Result<(), String> { + // 1. Void expired per_install authorizations let cutoff = Utc::now() - chrono::Duration::seconds(GRACE_SECS); let expired = db::marketplace_billing::list_expired_authorized(pool, cutoff, BATCH_LIMIT).await?; - if expired.is_empty() { - return Ok(()); - } - tracing::info!( - "install_authorization_sweeper: voiding {} expired authorization(s)", - expired.len() - ); - let service_token = std::env::var("STACKER_SERVICE_TOKEN").unwrap_or_default(); - for row in expired { - match user_service - .void_install_charge(&service_token, &row.authorization_id, "expired") - .await - { - Ok(_) => { - if let Err(err) = - db::marketplace_billing::mark_voided(pool, &row.authorization_id, "expired") - .await - { + if !expired.is_empty() { + tracing::info!( + "install_authorization_sweeper: voiding {} expired authorization(s)", + expired.len() + ); + let service_token = std::env::var("STACKER_SERVICE_TOKEN").unwrap_or_default(); + for row in expired { + match user_service + .void_install_charge(&service_token, &row.authorization_id, "expired") + .await + { + Ok(_) => { + if let Err(err) = + db::marketplace_billing::mark_voided(pool, &row.authorization_id, "expired") + .await + { + tracing::warn!( + "sweeper mark_voided DB error for {}: {}", + row.authorization_id, + err + ); + } + } + Err(ConnectorError::Conflict(_)) => { + tracing::info!( + "sweeper reconciling {} as captured (user_service returned 409)", + row.authorization_id + ); + if let Err(err) = + db::marketplace_billing::mark_captured(pool, &row.authorization_id).await + { + tracing::warn!( + "sweeper mark_captured DB error for {}: {}", + row.authorization_id, + err + ); + } + } + Err(err) => { tracing::warn!( - "sweeper mark_voided DB error for {}: {}", + "sweeper void failed for {}: {} (will retry next tick)", row.authorization_id, err ); } } - Err(ConnectorError::Conflict(_)) => { - // user_service says the authorization is not in the - // `authorized` state — most likely already captured out - // of band. Reconcile our local view. - tracing::info!( - "sweeper reconciling {} as captured (user_service returned 409)", - row.authorization_id + } + } + + // 2. Daily billing sweep for deployment_daily authorizations + let daily_candidates = + db::marketplace_billing::list_daily_sweep_candidates(pool, BATCH_LIMIT).await?; + if !daily_candidates.is_empty() { + tracing::info!( + "deployment_daily sweeper: charging {} authorization(s)", + daily_candidates.len() + ); + let service_token = std::env::var("STACKER_SERVICE_TOKEN").unwrap_or_default(); + for row in daily_candidates { + let daily_rate = row.daily_rate.unwrap_or(0.0); + let monthly_cap = row.monthly_cap.unwrap_or(0.0); + let total_charged = row.total_charged_minor.unwrap_or(0) as f64 / 100.0; + + // Check if monthly cap reached + if total_charged >= monthly_cap && monthly_cap > 0.0 { + tracing::debug!( + "deployment_daily: {} reached monthly cap ({}/{}), skipping", + row.authorization_id, + total_charged, + monthly_cap ); - if let Err(err) = - db::marketplace_billing::mark_captured(pool, &row.authorization_id).await + continue; + } + + // Check if server was deleted + if row.server_deleted_at.is_some() { + // Void remaining hold + if let Err(err) = user_service + .void_install_charge(&service_token, &row.authorization_id, "server_deleted") + .await { tracing::warn!( - "sweeper mark_captured DB error for {}: {}", + "deployment_daily void after delete failed for {}: {}", row.authorization_id, err ); } + continue; } - Err(err) => { - tracing::warn!( - "sweeper void failed for {}: {} (will retry next tick)", - row.authorization_id, - err - ); + + // Check if suspended and past deletion threshold + if let Some(suspended_at) = row.suspended_at { + let suspension_age = Utc::now().signed_duration_since(suspended_at); + if suspension_age.num_seconds() > SUSPENSION_DELETE_SECS { + tracing::info!( + "deployment_daily: deleting suspended server for {} (suspended {}s ago)", + row.authorization_id, + suspension_age.num_seconds() + ); + // Void the authorization — server will be cleaned up separately + if let Err(err) = user_service + .void_install_charge( + &service_token, + &row.authorization_id, + "suspension_expired", + ) + .await + { + tracing::warn!( + "deployment_daily void after suspension expired failed for {}: {}", + row.authorization_id, + err + ); + } + if let Err(err) = db::marketplace_billing::mark_voided( + pool, + &row.authorization_id, + "suspension_expired", + ) + .await + { + tracing::warn!("mark_voided error: {}", err); + } + } + continue; + } + + // Attempt daily charge + let charged_minor = (daily_rate * 100.0).round() as i64; + let deployment_hash = row.deployment_hash.clone().unwrap_or_default(); + + match user_service + .daily_capture_install_charge( + &service_token, + &row.authorization_id, + charged_minor, + &deployment_hash, + ) + .await + { + Ok(_) => { + if let Err(err) = db::marketplace_billing::mark_daily_charged( + pool, + &row.authorization_id, + charged_minor, + ) + .await + { + tracing::warn!( + "deployment_daily mark_daily_charged error for {}: {}", + row.authorization_id, + err + ); + } + tracing::info!( + "deployment_daily: charged ${:.2} for {} (total: ${:.2}/${:.2})", + daily_rate, + row.authorization_id, + (row.total_charged_minor.unwrap_or(0) + charged_minor) as f64 / 100.0, + monthly_cap, + ); + } + Err(ConnectorError::Conflict(_)) => { + // Already captured or voided — reconcile + tracing::info!( + "deployment_daily: reconciling {} (user_service returned 409)", + row.authorization_id + ); + } + Err(err) => { + tracing::warn!( + "deployment_daily: daily charge failed for {}: {}", + row.authorization_id, + err + ); + // Check if grace period expired (last charge was 24+ hours ago) + if let Some(last_charge) = row.last_daily_charge_at { + let grace_age = Utc::now().signed_duration_since(last_charge); + if grace_age.num_seconds() > DAILY_CHARGE_GRACE_SECS { + tracing::info!( + "deployment_daily: suspending {} (grace period expired)", + row.authorization_id + ); + if let Err(err) = + db::marketplace_billing::mark_suspended(pool, &row.authorization_id) + .await + { + tracing::warn!("mark_suspended error: {}", err); + } + } + } + } } } } + Ok(()) } diff --git a/src/services/marketplace_access.rs b/src/services/marketplace_access.rs index b269703f..b1b91f61 100644 --- a/src/services/marketplace_access.rs +++ b/src/services/marketplace_access.rs @@ -443,6 +443,29 @@ mod tests { .pop_front() .unwrap_or(Ok(())) } + + async fn daily_capture_install_charge( + &self, + _auth_token: &str, + authorization_id: &str, + amount_minor: i64, + deployment_hash: &str, + ) -> Result { + self.captured_calls + .lock() + .unwrap() + .push(CapturedCall::Capture { + authorization_id: authorization_id.to_string(), + deployment_hash: deployment_hash.to_string(), + }); + Ok(AuthorizationHandle { + authorization_id: authorization_id.to_string(), + amount_minor, + currency: "USD".to_string(), + expires_at: None, + status: "captured".to_string(), + }) + } } fn test_user() -> models::User { diff --git a/tests/cli_config.rs b/tests/cli_config.rs index f64c2dc2..1c330622 100644 --- a/tests/cli_config.rs +++ b/tests/cli_config.rs @@ -109,7 +109,7 @@ deploy: .success() .stdout(predicate::str::contains("local_env_file: docker/prod/.env")) .stdout(predicate::str::contains( - "remote_runtime_env_file: /home/trydirect/project/.env", + "remote_runtime_env_file: /home/trydirect/resolved-test/.env", )) .stdout(predicate::str::contains("compose_env_file: .env")) .stdout(predicate::str::contains( diff --git a/tests/cli_deployment_state.rs b/tests/cli_deployment_state.rs index 9268b65a..0e75f5ff 100644 --- a/tests/cli_deployment_state.rs +++ b/tests/cli_deployment_state.rs @@ -96,8 +96,9 @@ fn deployment_state_json_fetches_canonical_payload() { } }, "runtime": { - "composePath": "/home/trydirect/project/docker-compose.yml", - "envPath": "/home/trydirect/project/.env" + "composePath": "/home/trydirect/remote-project-17/docker-compose.yml", + "envPath": "/home/trydirect/remote-project-17/.env", + "stackCode": "remote-project-17" }, "apps": [], "drift": { @@ -133,7 +134,7 @@ fn deployment_state_json_fetches_canonical_payload() { "\"deploymentHash\": \"deployment_state_online\"", )) .and(predicate::str::contains( - "\"composePath\": \"/home/trydirect/project/docker-compose.yml\"", + "\"composePath\": \"/home/trydirect/remote-project-17/docker-compose.yml\"", )), ); diff --git a/tests/cli_explain.rs b/tests/cli_explain.rs index e923913e..d8c660ec 100644 --- a/tests/cli_explain.rs +++ b/tests/cli_explain.rs @@ -58,7 +58,7 @@ fn explain_env_json_outputs_redacted_provenance() { predicate::str::contains("\"schemaVersion\": \"v1alpha1\"") .and(predicate::str::contains("\"appCode\": \"device-api\"")) .and(predicate::str::contains( - "\"runtimeEnvPath\": \"/home/trydirect/project/.env\"", + "\"runtimeEnvPath\": \"/home/trydirect/local-name/.env\"", )) .and(predicate::str::contains("DATABASE_URL")) .and(predicate::str::contains("secret-value").not()), @@ -79,7 +79,7 @@ fn explain_topology_json_outputs_paths_and_services() { predicate::str::contains("\"schemaVersion\": \"v1alpha1\"") .and(predicate::str::contains("\"target\": \"cloud\"")) .and(predicate::str::contains( - "\"runtimeComposePath\": \"/home/trydirect/project/docker-compose.yml\"", + "\"runtimeComposePath\": \"/home/trydirect/local-name/docker-compose.yml\"", )) .and(predicate::str::contains("\"code\": \"device-api\"")), ); diff --git a/tests/contracts/stacker-deployment-state.v1alpha1.offline.json b/tests/contracts/stacker-deployment-state.v1alpha1.offline.json index 3f107161..3148b8dc 100644 --- a/tests/contracts/stacker-deployment-state.v1alpha1.offline.json +++ b/tests/contracts/stacker-deployment-state.v1alpha1.offline.json @@ -23,8 +23,9 @@ } }, "runtime": { - "composePath": "/home/trydirect/project/docker-compose.yml", - "envPath": "/home/trydirect/project/.env" + "composePath": "/home/trydirect/offline-demo/docker-compose.yml", + "envPath": "/home/trydirect/offline-demo/.env", + "stackCode": "offline-demo" }, "apps": [], "drift": { diff --git a/tests/contracts/stacker-deployment-state.v1alpha1.online.json b/tests/contracts/stacker-deployment-state.v1alpha1.online.json index b59cf9be..0afb619c 100644 --- a/tests/contracts/stacker-deployment-state.v1alpha1.online.json +++ b/tests/contracts/stacker-deployment-state.v1alpha1.online.json @@ -26,8 +26,9 @@ } }, "runtime": { - "composePath": "/home/trydirect/project/docker-compose.yml", - "envPath": "/home/trydirect/project/.env" + "composePath": "/home/trydirect/syncopia/docker-compose.yml", + "envPath": "/home/trydirect/syncopia/.env", + "stackCode": "syncopia" }, "apps": [ { diff --git a/tests/contracts/stacker-explain-env.v1alpha1.json b/tests/contracts/stacker-explain-env.v1alpha1.json index 9d110d31..c31f142e 100644 --- a/tests/contracts/stacker-explain-env.v1alpha1.json +++ b/tests/contracts/stacker-explain-env.v1alpha1.json @@ -3,8 +3,8 @@ "deploymentHash": "deployment_state_online", "appCode": "device-api", "localAuthoringEnvPath": "docker/prod/.env", - "runtimeEnvPath": "/home/trydirect/project/.env", - "runtimeComposePath": "/home/trydirect/project/docker-compose.yml", + "runtimeEnvPath": "/home/trydirect/test-project/.env", + "runtimeComposePath": "/home/trydirect/test-project/docker-compose.yml", "layers": [ { "name": "base", @@ -26,7 +26,7 @@ } ], "destination": { - "path": "/home/trydirect/project/.env", + "path": "/home/trydirect/test-project/.env", "writePolicy": "drift-protected", "driftProtection": true }, diff --git a/tests/contracts/stacker-explain-topology.v1alpha1.json b/tests/contracts/stacker-explain-topology.v1alpha1.json index 6c86924f..62ad9187 100644 --- a/tests/contracts/stacker-explain-topology.v1alpha1.json +++ b/tests/contracts/stacker-explain-topology.v1alpha1.json @@ -3,9 +3,9 @@ "deploymentHash": "deployment_state_online", "target": "cloud", "localComposePath": "docker/prod/compose.yml", - "runtimeComposePath": "/home/trydirect/project/docker-compose.yml", + "runtimeComposePath": "/home/trydirect/test-project/docker-compose.yml", "localAuthoringEnvPath": "docker/prod/.env", - "runtimeEnvPath": "/home/trydirect/project/.env", + "runtimeEnvPath": "/home/trydirect/test-project/.env", "services": [ { "code": "device-api", diff --git a/tests/features/mcp.feature b/tests/features/mcp.feature index 9043f449..d462d8f6 100644 --- a/tests/features/mcp.feature +++ b/tests/features/mcp.feature @@ -68,7 +68,7 @@ Feature: MCP WebSocket Server """ Then the MCP response should have result And the MCP tool response should not be an error - And the MCP tool text response should contain "\"runtimeComposePath\": \"/home/trydirect/project/docker-compose.yml\"" + And the MCP tool text response should contain "\"runtimeComposePath\": \"/home/trydirect/proj-deployment_ai_eval-" When I send an MCP tools/call request for "get_deployment_plan" with arguments: """ {"deployment_hash":"deployment_ai_eval","operation":"deploy"} diff --git a/tests/marketplace_integration.rs b/tests/marketplace_integration.rs index d68e6bb7..50e64e54 100644 --- a/tests/marketplace_integration.rs +++ b/tests/marketplace_integration.rs @@ -33,6 +33,8 @@ async fn test_deployment_free_template_allowed() { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, tags: serde_json::json!(["free"]), tech_stack: serde_json::json!([]), status: "approved".to_string(), @@ -84,6 +86,8 @@ async fn test_deployment_plan_requirement_validated() { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, tags: serde_json::json!(["professional"]), tech_stack: serde_json::json!([]), status: "approved".to_string(), @@ -139,6 +143,8 @@ async fn test_deployment_owned_paid_template_allowed() { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, tags: serde_json::json!(["ai", "agents", "paid"]), tech_stack: serde_json::json!([]), status: "approved".to_string(), @@ -285,6 +291,8 @@ fn test_webhook_payload_for_template_rejection() { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, vendor_user_id: None, vendor_name: None, category: None, @@ -373,6 +381,8 @@ async fn test_deployment_validation_flow_with_connector() { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, tags: serde_json::json!([]), tech_stack: serde_json::json!([]), status: "approved".to_string(), @@ -416,6 +426,8 @@ async fn test_deployment_validation_flow_with_connector() { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, tags: serde_json::json!([]), tech_stack: serde_json::json!([]), status: "approved".to_string(), @@ -540,6 +552,8 @@ async fn test_multiple_deployments_mixed_templates() { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, tags: serde_json::json!([]), tech_stack: serde_json::json!([]), status: "approved".to_string(), @@ -583,6 +597,8 @@ async fn test_multiple_deployments_mixed_templates() { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, tags: serde_json::json!([]), tech_stack: serde_json::json!([]), status: "approved".to_string(), @@ -631,6 +647,8 @@ async fn test_multiple_deployments_mixed_templates() { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, tags: serde_json::json!([]), tech_stack: serde_json::json!([]), status: "approved".to_string(), @@ -696,6 +714,8 @@ fn test_template_status_values() { price: None, billing_cycle: None, currency: None, + daily_rate: None, + monthly_cap: None, tags: serde_json::json!([]), tech_stack: serde_json::json!([]), status: "approved".to_string(),