diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 54bcd757..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(curl --version)", - "WebSearch", - "WebFetch(domain:docs.n8n.io)", - "WebFetch(domain:blog.n8n.io)", - "WebFetch(domain:community.n8n.io)", - "Bash(find /Users/vasilipascal/work/try.direct/stacker -name status_panel.rs -o -name pipe.rs -o -name *pipe* -type f)", - "Bash(SQLX_OFFLINE=true cargo test --lib models::pipe::tests -- 2>&1)", - "Bash(SQLX_OFFLINE=true cargo test --lib forms::status_panel::tests --)", - "Bash(SQLX_OFFLINE=true cargo test --lib console::commands::cli::pipe::tests --)", - "Bash(SQLX_OFFLINE=true cargo test --lib --)", - "Bash(cd:*)", - "Bash(cargo test:*)" - ] - } -} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 18892b37..c332259f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -135,3 +135,11 @@ Unit tests (lib) use `--test-threads=1` (see Makefile) because many share global ### CLI commands `stacker-cli` commands are implemented in `src/cli/`. `console` commands are in `src/console/commands/`. Both use `clap` with `#[derive(Parser, Subcommand)]`. Interactive prompts use `dialoguer`; progress bars use `indicatif`. + +### Service deployment scope + +`stacker service deploy ` is project-scoped by default for services declared in `stacker.yml`. Normal custom services must update `/home/trydirect/project/docker-compose.yml` and must not create `/home/trydirect//docker-compose.yml` unless the user explicitly chooses standalone mode, such as a future `--standalone` or `--scope standalone` flag. + +Only platform-managed services live outside the project directory by default. Current examples are Status Panel (`/home/trydirect/statuspanel`) and Nginx Proxy Manager (`/home/trydirect/nginx_proxy_manager`). Add regression tests for any service/proxy deploy change that could duplicate a project-scoped service as a standalone compose project. + +Stacker-managed compose services use stable runtime labels with the `my.stacker.*` prefix: `my.stacker.project_id`, `my.stacker.target`, `my.stacker.scope`, `my.stacker.service`, and `my.stacker.dns`. Keep logical service codes and Docker DNS names separate; for Nginx Proxy Manager use `my.stacker.service=nginx_proxy_manager` and `my.stacker.dns=nginx-proxy-manager`. diff --git a/.gitignore b/.gitignore index 9218add7..8ff7ad07 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ configuration.yaml.orig docker/local/ docs/*.sql config-to-validate.yaml -*.bak \ No newline at end of file +*.bak +.claude/settings.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a101ad1..46041e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,56 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added — Onboarding setup helpers + +- Added `stacker config setup ai` to enable and update `ai.*` settings from the + CLI, including Ollama-friendly `--provider`, `--endpoint`, `--model`, + `--timeout`, and repeatable `--task` options. +- Cloud/server deploys now bootstrap missing `.env` files from adjacent + `.env.example` files when compose or `stacker.yml` references them, using + restrictive local permissions where supported. +- Cloud deploy `--key` and `--key-id` overrides are resolved through the active + logged-in Stacker API before prompt selection, and non-interactive shells now + receive actionable cloud credential guidance instead of hanging. +- Deploy validation now prints concise private registry credential guidance when + images may require authentication and no registry auth is resolved. +- `stacker config validate` now points users to `stacker config fix` when it + finds empty structural path fields. +- Cloud/server deploys now skip post-deploy server IP polling and local backup + key installation after terminal paused/error statuses, avoiding repeated + "server IP not yet assigned" retries after a failed installer run. +- Hetzner cloud deploys now normalize user-facing location aliases such as + `nbg1` to installer-compatible datacenter values such as `nbg1-dc3` before + publishing install-service payloads. +- `stacker config setup cloud` now suggests Hetzner `cx23` by default instead + of older `cpx*` examples. +- Remote config bundles now keep compose `env_file` and bind-mount references + project-relative so Docker Compose sees copied files under + `/home/trydirect/project`. +- Cloud/server deploy output now lists config-bundle file mappings and rejects + absolute config-bundle destinations before sending a deploy request. +- Deploy-time config files are now mirrored into the installer runtime-file + contract so non-compose files such as `.env` are materialized before Docker + Compose starts. + ## [0.2.8] — 2026-05-15 +### Added — Configuration inventory, diff, check, and promotion planning + +- Added `stacker config inventory --env ` to list effective configuration + keys by app/service target and source without printing secret values. +- Added `stacker config diff --from --to ` to compare local + environment/profile inventories and report missing, target-only, and changed + keys. +- Added optional `config_contract` support in `stacker.yml` and + `stacker config check --env --strict` to fail when required keys are + missing from an environment. +- Added `stacker config contract suggest --env ` to generate a + reviewable `config_contract` snippet from the current inventory. +- Added `--remote` support for `config inventory`, `config diff`, and + `config check`, enriching target inventories with remote service secret + metadata without fetching plaintext Vault values. +- Added `stacker config promote --from --to ` to generate safe + target placeholders for missing keys; secret values are not copied. ### Added — App-only deploy environment selection @@ -14,6 +63,58 @@ All notable changes to this project will be documented in this file. `--env ` / `--environment ` for one-off environment selection during app-only updates. +### Fixed — App-local compose env files for deploy-app + +- `stacker agent deploy-app ` now reads + `/docker//compose.yml` when that app-local compose file exists and + merges that app's service definition into the full project-level compose, + instead of replacing the remote stack compose with a single-service file. +- App-local deploys now bundle only the target app-local config files while + using the project-level compose as topology, so missing env/config files for + unrelated services no longer block `deploy-app `. +- App-local `env_file` references are uploaded in the deploy-app config bundle, + and Vault-rendered service secrets for the same target are merged into the + matching remote `.env` file before the Status agent writes it. +- Deploy-app command creation now fails if Stacker cannot render the target's + runtime env, instead of silently falling back to a stale/raw `.env` that may + omit Vault-backed service secrets. +- `stacker agent deploy-app` and `stacker secrets push` now use the same + server-side deploy-app enrichment path when enqueueing agent commands, so + app-local `.env` files receive Vault-rendered service secrets during direct + agent pushes as well as command-create flows. +- Missing config-bundle file errors now include the resolved path instead of a + bare `No such file or directory` message. +- If an app-local `.env` exists but the selected compose service has no + `env_file` entry, the CLI prints a warning explaining that Docker Compose will + not inject local or remote-rendered env values into that container. + +### Added — Canonical runtime environment rendering + +- Remote runtime environment files now use the canonical host path + `/home/trydirect/project/.env`; generated compose files reference it as + `env_file: .env`. +- `stacker config show --resolved` prints the local env source path, canonical + remote env path, compose env reference, config hash/version metadata, and + contributing layers without printing secret values. +- Runtime env rendering now has deterministic precedence and hashing, rejects + reserved `STACKER_*`, `DOCKER_*`, `VAULT_*`, and `AGENT_*` keys, and provides + drift checks that require `--force` before overwriting changed remote env + content. + +### Fixed — Reuse private registry auth for agent-managed pulls + +- Deploy-time `deploy.registry` credentials are now stored in trusted Stacker + secret storage and reused for later Status-managed pulls such as + `stacker agent deploy-app`. +- The Status agent now performs private-image pulls with a temporary + `DOCKER_CONFIG` auth context and cleans it up immediately after the pull, + instead of relying on host Docker login state. +- When no stored registry auth exists, pull behavior remains backward + compatible: anonymous pull is attempted first and cached local images can + still allow the redeploy to complete with warnings. + +## [0.2.8] — 2026-05-12 + ### Added — Remote service/app target secrets - `stacker secrets set --scope service --service ` now supports real diff --git a/Cargo.lock b/Cargo.lock index 4f5afd2a..07768711 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -583,6 +583,16 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "async-channel" version = "1.9.0" @@ -606,6 +616,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + [[package]] name = "async-executor" version = "1.14.0" @@ -620,6 +642,21 @@ dependencies = [ "slab", ] +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io 2.6.0", + "async-lock 3.4.2", + "blocking", + "futures-lite 2.6.1", + "once_cell", +] + [[package]] name = "async-global-executor" version = "3.1.0" @@ -640,11 +677,34 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9af57045d58eeb1f7060e7025a1631cbc6399e0a1d10ad6735b3d0ea7f8346ce" dependencies = [ - "async-global-executor", + "async-global-executor 3.1.0", "async-trait", "executor-trait", ] +[[package]] +name = "async-imap" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66" +dependencies = [ + "async-channel 2.5.0", + "async-compression", + "async-std", + "base64 0.22.1", + "bytes", + "chrono", + "futures", + "imap-proto", + "log", + "nom 7.1.3", + "pin-project", + "pin-utils", + "self_cell", + "stop-token", + "thiserror 1.0.69", +] + [[package]] name = "async-io" version = "1.13.0" @@ -703,6 +763,52 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-native-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9343dc5acf07e79ff82d0c37899f079db3534d99f189a1837c8e549c99405bec" +dependencies = [ + "futures-util", + "native-tls", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "async-pop" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d4a64316619f24aff5ef0b2c67986bfa2e414fa5aa3f4c86feda8f8f6f326f" +dependencies = [ + "async-native-tls", + "async-std", + "async-trait", + "base64 0.21.7", + "bytes", + "futures", + "log", + "nom 7.1.3", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io 2.6.0", + "async-lock 3.4.2", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite 2.6.1", + "rustix 1.1.4", +] + [[package]] name = "async-reactor-trait" version = "1.1.0" @@ -715,6 +821,52 @@ dependencies = [ "reactor-trait", ] +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io 2.6.0", + "async-lock 3.4.2", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor 2.4.1", + "async-io 2.6.0", + "async-lock 3.4.2", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite 2.6.1", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -1166,6 +1318,16 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64 0.22.1", + "encoding_rs", +] + [[package]] name = "chrono" version = "0.4.44" @@ -1318,6 +1480,22 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2142,6 +2320,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -2664,6 +2858,18 @@ dependencies = [ "walkdir", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "group" version = "0.13.0" @@ -3217,6 +3423,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "imap-proto" +version = "0.16.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f6af35c6a517aea5c72314abe90134980d2ae6a763809b50c208b3e429d71f" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "impl-more" version = "0.1.9" @@ -3438,6 +3653,15 @@ dependencies = [ "serde", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -3482,6 +3706,33 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand 2.4.1", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls 0.23.37", + "socket2 0.6.3", + "tokio", + "tokio-rustls 0.26.4", + "url", + "webpki-roots 1.0.6", +] + [[package]] name = "libc" version = "0.2.184" @@ -3643,6 +3894,20 @@ name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] + +[[package]] +name = "mailparse" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f" +dependencies = [ + "charset", + "data-encoding", + "quoted_printable", +] [[package]] name = "matchers" @@ -4370,6 +4635,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pinky-swear" version = "6.2.1" @@ -4382,6 +4653,35 @@ dependencies = [ "tracing", ] +[[package]] +name = "pipe-adapter-mail" +version = "0.1.0" +dependencies = [ + "async-imap", + "async-native-tls", + "async-pop", + "async-std", + "async-trait", + "futures-util", + "lettre", + "mailparse", + "pipe-adapter-sdk", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "pipe-adapter-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "piper" version = "0.2.5" @@ -4784,6 +5084,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -5347,6 +5653,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -5547,6 +5854,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.28" @@ -6300,6 +6613,8 @@ dependencies = [ "lapin", "lazy_static", "mockito", + "pipe-adapter-mail", + "pipe-adapter-sdk", "predicates", "prometheus", "prost", @@ -6345,6 +6660,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stop-token" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af91f480ee899ab2d9f8435bfdfc14d08a5754bd9d3fef1f1a1c23336aad6c8b" +dependencies = [ + "async-channel 1.9.0", + "cfg-if", + "futures-core", + "pin-project-lite", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -6787,6 +7114,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.37", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -6856,7 +7193,7 @@ dependencies = [ "rustls-pemfile 2.2.0", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.25.0", "tokio-stream", "tower", "tower-layer", @@ -7223,6 +7560,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 87564be0..71795e0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,10 @@ version = "0.2.8" edition = "2021" default-run= "server" +[workspace] +members = ["crates/pipe-adapter-sdk", "crates/pipe-adapter-mail"] +resolver = "2" + [lib] path="src/lib.rs" @@ -93,6 +97,8 @@ tokio-tungstenite = { version = "0.21", features = ["native-tls"] } tonic = { version = "0.11", features = ["tls"] } prost = "0.12" prost-types = "0.12" +pipe-adapter-mail = { path = "crates/pipe-adapter-mail" } +pipe-adapter-sdk = { path = "crates/pipe-adapter-sdk" } [dependencies.sqlx] version = "0.8.2" diff --git a/Dockerfile b/Dockerfile index 230ea085..521b9ee5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,8 @@ COPY ./tests/bdd.rs ./tests/bdd.rs COPY ./src ./src +COPY ./crates ./crates +COPY ./scenarios ./scenarios # for ls output use BUILDKIT_PROGRESS=plain docker build . #RUN ls -la /app/ >&2 diff --git a/README.md b/README.md index c47e8c68..3ff2d87c 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,22 @@ stacker init --with-ai --ai-provider anthropic If the AI provider is unreachable, Stacker falls back to template-based generation automatically. +When the project looks like a simple HTML or Next.js website and the configured +Ollama model is `qwen2.5-code` or `qwen2.5-coder`, `stacker init --with-ai` +can also bootstrap a website deployment scenario. The bootstrap seeds values +from the generated `stacker.yml`, asks only for the missing deploy inputs, and +saves scenario state under `.stacker/scenarios/qwen2.5-code/website-deploy/` +for later continuation with `stacker ai`. + +### AI deployment workflows + +For the canonical AI/MCP deployment flow — inspect state, explain topology or +env provenance, preview a plan, apply it safely, and recover with events or +rollback — see [AI deployment workflows](docs/AI_DEPLOYMENT_WORKFLOWS.md). + +For the qwen-specific website scenario flow, including `--scenario` and `--step` +continuation, see the same guide. + --- ## `stacker.yml` example @@ -150,6 +166,7 @@ The end-user tool. No server required for local deploys. | `stacker config show` | Show resolved configuration | | `stacker config example` | Print a full commented reference | | `stacker config setup cloud` | Guided cloud deployment setup | +| `stacker config setup ai` | Configure AI provider, endpoint, model, and tasks | | `stacker ai ask "question"` | Ask the AI about your stack | | `stacker proxy add` | Add a reverse-proxy domain entry | | `stacker proxy detect` | Auto-detect existing reverse-proxy containers | @@ -168,7 +185,7 @@ The end-user tool. No server required for local deploys. | `stacker agent restart ` | Restart a container via the agent | | `stacker agent deploy-app` | Deploy or update an app container on the target server. `--runtime kata\|runc` selects container runtime; `--env ` selects the deploy environment/profile | | `stacker agent remove-app` | Remove an app container (with optional volume/image cleanup) | -| `stacker agent configure-proxy` | Configure Nginx Proxy Manager via the agent; use `--no-ssl` for plain HTTP hosts (credentials are resolved on the agent from Vault) | +| `stacker agent configure-proxy` | Configure Nginx Proxy Manager via the agent; use `--no-ssl` for plain HTTP hosts (credentials are resolved from Vault and are auto-seeded for managed Status Panel + NPM deploys) | | `stacker agent configure-firewall` | Configure guest OS firewall rules via the Status Panel agent; use `stacker cloud firewall` for provider firewalls | | `stacker agent history` | Show recent command execution history | | `stacker agent exec` | Execute a raw agent command with JSON parameters | @@ -252,6 +269,10 @@ stacker secrets push --service uploader --env prod - Service-scoped secrets are merged only into the matching rendered service/app env at deploy time. - `stacker secrets push --service ` applies stored service secrets to the remote runtime env without changing secret values. Use `--env ` for a one-off environment selection, or `stacker env ` to persist the active environment/profile for future app-only updates. Use `--force` only when the remote env drift check reports an out-of-band change. - Remote `get` and `list` do **not** return plaintext values in v1. +- MCP env inspection now exposes explicit secure metadata for Vault-backed + variables: `get_app_env_vars` keeps the redacted + `environment_variables` object for compatibility and also returns + `environment_entries[]` with `secure`, `redacted`, and `source` fields. Remote deploys render runtime env into one canonical host file: `/home/trydirect/project/.env`. Generated compose uses `env_file: .env`, so the @@ -270,7 +291,9 @@ file before sending it to the agent. This prevents app-only updates from replacing the remote stack compose with a single-service compose file. Any app-local `.env` referenced by that compose file is uploaded in the config bundle, and Stacker appends the Vault-rendered service secrets for the same -target to that file before the agent writes it on the server. +target to that file before the agent writes it on the server. Repeated app-only +updates replace the prior `# stacker-render ...` block in that file instead of +stacking duplicate rendered secret sections. ### Marketplace workflow (for stack developers) diff --git a/TODO.md b/TODO.md index fd9d90a7..3b7755fc 100644 --- a/TODO.md +++ b/TODO.md @@ -1257,3 +1257,10 @@ To verify `is_official` and `is_verified_publisher` status for each image: - [ ] Start with `stacker-vendor-payouts`, `stacker-template-requirements`, and `stacker-duplicate-slug-409`. - [ ] Follow with `stacker-agent-alerts`, `stacker-review-notifications`, and `stacker-rollback` once the marketplace data contract is stable. - [ ] Treat `stacker-team-projects` and `stacker-pipe-execution` as multi-sprint workstreams with cross-project coordination. + + +## MCP safe troubleshooting snapshots + +- Added `request_server_snapshot` MCP tool for Hetzner-first pre-remediation snapshots. +- Snapshot creation requires explicit `confirm_snapshot=true` because it is a provider write operation. +- Follow-up: add a shared risk guard to destructive MCP tools (`get_container_exec`, `restart_container`, `stop_container`, `remove_app`, force `deploy_app`, proxy/firewall writes) so they can require a recent `snapshot_id`/provider action before execution. diff --git a/crates/TODO.md b/crates/TODO.md new file mode 100644 index 00000000..0ffab357 --- /dev/null +++ b/crates/TODO.md @@ -0,0 +1,173 @@ +# Pipe Adapter Wishlist + +This file collects **suggested next adapters** for the `crates/` workspace. + +Current first-party adapters already present: + +- `webhook` +- `smtp` +- `imap` +- `pop3` +- `mailhog` + +The list below focuses on adapters that are likely to be useful for real Stacker +users wiring infrastructure, alerts, workflows, and service integrations. + +## High priority + +### Notifications and chat + +- [ ] **Slack** + - Incoming webhook target + - Bot API target for richer messages, threads, and file uploads +- [ ] **Telegram** + - Bot API target for alerts, approvals, and simple commands +- [ ] **Discord** + - Webhook target for ops notifications and status feeds +- [ ] **Microsoft Teams** + - Incoming webhook target for enterprise alerting + +### Workflow and automation + +- [ ] **Airflow** + - Trigger DAG run target + - Optional DAG status poll source +- [ ] **Zapier** + - Catch Hook / Webhooks target adapter +- [ ] **Make.com** + - Webhook target for low-code automation flows +- [ ] **n8n** + - Webhook target for self-hosted workflow automation + +### Queues and event transport + +- [ ] **RabbitMQ / AMQP** + - Queue publish target + - Queue consume source +- [ ] **Kafka** + - Topic publish target + - Topic consume source +- [ ] **NATS** + - Subject publish target + - Subject subscribe source +- [ ] **Redis Streams** + - Stream append target + - Stream consumer source + +## Medium priority + +### Cloud messaging and serverless triggers + +- [ ] **AWS SQS** + - Queue send target + - Queue poll source +- [ ] **AWS SNS** + - Topic publish target +- [ ] **Google Pub/Sub** + - Publish target + - Subscription pull source +- [ ] **Azure Service Bus** + - Queue/topic publish target + - Queue/topic consume source + +### Incident management + +- [ ] **PagerDuty** + - Events API target for incident creation and resolution +- [ ] **Opsgenie** + - Alert target for escalation workflows +- [ ] **VictorOps / Splunk On-Call** + - Alert target for on-call routing + +### Developer platforms + +- [ ] **GitHub** + - Issue/comment target + - Release/deployment webhook source +- [ ] **GitLab** + - Issue/pipeline target + - Webhook source +- [ ] **Jira** + - Ticket create/update target + +### Storage and documents + +- [ ] **S3 / MinIO** + - Object put target + - Object event source +- [ ] **Google Drive** + - File upload target +- [ ] **Dropbox** + - File sync target + +## Lower priority but highly useful + +### Data platforms + +- [ ] **PostgreSQL** + - Insert/update target + - Logical replication / CDC source +- [ ] **MySQL** + - Insert/update target + - Binlog source +- [ ] **Elasticsearch / OpenSearch** + - Index target for logs, events, and search pipelines +- [ ] **ClickHouse** + - Bulk ingest target for analytics + +### Observability + +- [ ] **Prometheus Alertmanager** + - Alert target +- [ ] **Grafana OnCall** + - Incident/notification target +- [ ] **Loki** + - Log push target +- [ ] **OpenTelemetry** + - Trace/event export target + +### App and commerce services + +- [ ] **Twilio** + - SMS target + - WhatsApp target +- [ ] **Stripe** + - Webhook source + - Event/action target where appropriate +- [ ] **Shopify** + - Webhook source + - Admin API target + +## Platform-oriented adapters for Stacker use cases + +- [ ] **Kubernetes** + - Job target + - CronJob target + - Watch source for workload events +- [ ] **Docker Registry** + - Image publish / tag notification target +- [ ] **HashiCorp Vault** + - Secret read/write adapter beyond current direct product integrations +- [ ] **Terraform Cloud / HCP Terraform** + - Run trigger target + - Run status source + +## Notes for implementation order + +- Prefer adapters with **simple auth + high utility** first: + 1. Slack + 2. Telegram + 3. RabbitMQ + 4. Airflow + 5. Zapier / Make / n8n +- Keep a clean split between: + - **source adapters**: poll, subscribe, receive, watch + - **target adapters**: send, publish, trigger, upload +- Favor adapters that can be configured with: + - URL + - token or secret reference + - retry policy + - timeout + - idempotency key or dedupe field +- Reuse the same normalized payload pattern where possible instead of creating + one-off transport-specific shapes for every service. diff --git a/crates/pipe-adapter-mail/Cargo.toml b/crates/pipe-adapter-mail/Cargo.toml new file mode 100644 index 00000000..523738c3 --- /dev/null +++ b/crates/pipe-adapter-mail/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "pipe-adapter-mail" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-trait = "0.1" +async-std = "1" +async-imap = { version = "0.11.2", default-features = false, features = ["runtime-async-std"] } +async-native-tls = "0.5" +async-pop = { version = "1.1.3", default-features = false, features = ["runtime-async-std", "async-native-tls", "sasl"] } +futures-util = "0.3" +lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] } +mailparse = "0.16.1" +pipe-adapter-sdk = { path = "../pipe-adapter-sdk" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +tokio = { version = "1", features = ["net"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/pipe-adapter-mail/TODO.md b/crates/pipe-adapter-mail/TODO.md new file mode 100644 index 00000000..e978200c --- /dev/null +++ b/crates/pipe-adapter-mail/TODO.md @@ -0,0 +1,12 @@ +# pipe-adapter-mail TODO + +## Future enhancements + +- Add durable POP3/IMAP cursor persistence so mailbox polling survives worker restarts without replaying already-processed messages. +- Add explicit replay/reset semantics for mailbox sources so operators can intentionally reprocess a message range when needed. +- Add bounded polling controls in adapter config, including max messages per poll, max body size, and max attachment metadata extraction. +- Add richer mailbox state handling for IMAP, including configurable search criteria beyond `UNSEEN` and explicit `\Seen`/ack behavior. +- Add safer POP3 progression semantics, including optional delete/keep behavior after successful downstream trigger delivery. +- Add multipart attachment metadata improvements, including content-id and inline attachment handling. +- Add adapter-level metrics and structured diagnostics for connect, login, fetch, parse, and delivery outcomes without logging secrets or message bodies. +- Add fixture-driven tests for live protocol edge cases such as malformed MIME, empty mailboxes, duplicate UIDL/UID values, and partial TLS/auth failures. diff --git a/crates/pipe-adapter-mail/src/lib.rs b/crates/pipe-adapter-mail/src/lib.rs new file mode 100644 index 00000000..9b826f2e --- /dev/null +++ b/crates/pipe-adapter-mail/src/lib.rs @@ -0,0 +1,1235 @@ +use async_native_tls::TlsConnector; +use async_std::net::TcpStream; +use async_trait::async_trait; +use futures_util::{AsyncRead, AsyncWrite, TryStreamExt}; +use lettre::message::{header::ContentType, Mailbox, MultiPart, SinglePart}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; +use mailparse::{addrparse_header, parse_mail, MailAddr, MailHeaderMap, ParsedMail}; +use pipe_adapter_sdk::{ + builtin_registry, NormalizedMailAddress, NormalizedMailAttachment, NormalizedMailBody, + NormalizedMailMessage, PipeAdapterCatalog, PipeAdapterDispatch, PipeAdapterError, + PipeAdapterMetadata, PipeAdapterPayload, PipeAdapterReference, PipeSourceAdapter, + PipeTargetAdapter, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmtpDeliveryRequest { + pub host: String, + pub port: u16, + pub username: Option, + pub password: Option, + pub from: String, + pub to: Vec, + pub reply_to: Option, + pub subject: String, + pub body_text: Option, + pub body_html: Option, + pub tls: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmtpDeliveryReceipt { + pub message_id: Option, + pub accepted_recipients: usize, +} + +#[async_trait] +pub trait SmtpClient: Send + Sync + Clone + 'static { + async fn send( + &self, + request: &SmtpDeliveryRequest, + ) -> Result; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MailSourceRequest { + pub host: String, + pub port: u16, + pub username: String, + pub password: Option, + pub tls: bool, + pub mailbox: Option, +} + +#[async_trait] +pub trait MailSourceClient: Send + Sync + Clone + 'static { + async fn poll_imap( + &self, + request: &MailSourceRequest, + ) -> Result, PipeAdapterError>; + + async fn poll_pop3( + &self, + request: &MailSourceRequest, + ) -> Result, PipeAdapterError>; +} + +#[derive(Debug, Clone, Default)] +pub struct LiveMailSourceClient; + +#[async_trait] +impl MailSourceClient for LiveMailSourceClient { + async fn poll_imap( + &self, + request: &MailSourceRequest, + ) -> Result, PipeAdapterError> { + let stream = TcpStream::connect((request.host.as_str(), request.port)) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to connect to {}:{}: {}", + request.host, request.port, err + )) + })?; + if request.tls { + let tls_stream = TlsConnector::new() + .connect(&request.host, stream) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to negotiate tls with {}:{}: {}", + request.host, request.port, err + )) + })?; + poll_imap_client(async_imap::Client::new(tls_stream), request).await + } else { + poll_imap_client(async_imap::Client::new(stream), request).await + } + } + + async fn poll_pop3( + &self, + request: &MailSourceRequest, + ) -> Result, PipeAdapterError> { + if request.tls { + let tls = TlsConnector::new(); + let mut client = + async_pop::connect((request.host.as_str(), request.port), &request.host, &tls) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter failed to connect to {}:{}: {}", + request.host, request.port, err + )) + })?; + poll_pop3_client(&mut client, request).await + } else { + let mut client = async_pop::connect_plain((request.host.as_str(), request.port)) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter failed to connect to {}:{}: {}", + request.host, request.port, err + )) + })?; + poll_pop3_client(&mut client, request).await + } + } +} + +async fn poll_pop3_client( + client: &mut async_pop::Client, + request: &MailSourceRequest, +) -> Result, PipeAdapterError> +where + S: AsyncRead + AsyncWrite + Unpin + Send, +{ + let password = request.password.as_deref().ok_or_else(|| { + PipeAdapterError::Message( + "pop3 adapter requires a password in the adapter configuration".to_string(), + ) + })?; + + client + .login(request.username.as_str(), password) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter login failed for '{}' on {}:{}: {}", + request.username, request.host, request.port, err + )) + })?; + + let entries = client.uidl(None).await.map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter failed to list mailbox on {}:{}: {}", + request.host, request.port, err + )) + })?; + + let items = match entries { + async_pop::response::uidl::UidlResponse::Multiple(entries) => { + let mut items = Vec::new(); + for entry in entries.items() { + let index = entry.index().to_string().parse::().map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter returned invalid message index '{}': {}", + entry.index(), + err + )) + })?; + items.push((index, entry.id().to_string())); + } + items + } + async_pop::response::uidl::UidlResponse::Single(entry) => { + let index = entry.index().to_string().parse::().map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter returned invalid message index '{}': {}", + entry.index(), + err + )) + })?; + vec![(index, entry.id().to_string())] + } + }; + + let mut messages = Vec::new(); + for (index, uid) in items { + let raw = client.retr(index).await.map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter failed to retrieve message {} from {}:{}: {}", + index, request.host, request.port, err + )) + })?; + messages.push(parse_normalized_mail_message( + raw.as_ref(), + None, + Some(uid), + )?); + } + + let _ = client.quit().await; + Ok(messages) +} + +#[derive(Debug, Clone, Default)] +pub struct LettreSmtpClient; + +#[async_trait] +impl SmtpClient for LettreSmtpClient { + async fn send( + &self, + request: &SmtpDeliveryRequest, + ) -> Result { + let email = build_smtp_message(request)?; + let mut builder = if request.tls { + AsyncSmtpTransport::::relay(&request.host) + .map_err(|err| { + PipeAdapterError::Message(format!( + "invalid smtp host '{}': {}", + request.host, err + )) + })? + .port(request.port) + } else { + AsyncSmtpTransport::::builder_dangerous(&request.host) + .port(request.port) + }; + + match (&request.username, &request.password) { + (Some(username), Some(password)) => { + builder = builder.credentials(Credentials::new(username.clone(), password.clone())); + } + (None, None) => {} + _ => { + return Err(PipeAdapterError::Message( + "smtp adapter requires both username and password when credentials are configured".to_string(), + )); + } + } + + let response = + builder.build().send(email).await.map_err(|err| { + PipeAdapterError::Message(format!("smtp delivery failed: {}", err)) + })?; + + let mut messages = response.message(); + Ok(SmtpDeliveryReceipt { + message_id: messages.next().map(str::to_owned), + accepted_recipients: request.to.len(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct SmtpTargetAdapter { + metadata: PipeAdapterMetadata, + reference: PipeAdapterReference, + config: SmtpTargetConfig, + client: T, +} + +#[derive(Debug, Clone)] +pub struct ImapSourceAdapter { + metadata: PipeAdapterMetadata, + reference: PipeAdapterReference, + config: ImapSourceConfig, + client: T, + seen_ids: Arc>>, +} + +#[derive(Debug, Clone)] +pub struct Pop3SourceAdapter { + metadata: PipeAdapterMetadata, + reference: PipeAdapterReference, + config: Pop3SourceConfig, + client: T, + seen_ids: Arc>>, +} + +impl SmtpTargetAdapter { + pub fn from_reference(reference: PipeAdapterReference) -> Result { + Self::with_client(reference, LettreSmtpClient) + } +} + +impl ImapSourceAdapter { + pub fn from_reference(reference: PipeAdapterReference) -> Result { + Self::with_client(reference, LiveMailSourceClient) + } +} + +impl Pop3SourceAdapter { + pub fn from_reference(reference: PipeAdapterReference) -> Result { + Self::with_client(reference, LiveMailSourceClient) + } +} + +impl SmtpTargetAdapter { + pub fn with_client( + reference: PipeAdapterReference, + client: T, + ) -> Result { + let metadata = builtin_registry().find(&reference.code).ok_or_else(|| { + PipeAdapterError::Message(format!("unknown smtp adapter '{}'", reference.code)) + })?; + let config_value = reference.config.clone().ok_or_else(|| { + PipeAdapterError::Message(format!("adapter '{}' requires config", reference.code)) + })?; + let config: SmtpTargetConfig = serde_json::from_value(config_value).map_err(|err| { + PipeAdapterError::Message(format!( + "invalid smtp adapter config for '{}': {}", + reference.code, err + )) + })?; + + Ok(Self { + metadata, + reference, + config, + client, + }) + } + + fn build_request( + &self, + payload: PipeAdapterPayload, + ) -> Result { + let envelope = match payload { + PipeAdapterPayload::Json(value) => SmtpEnvelope::from_json(value, &self.config)?, + PipeAdapterPayload::MailMessage(message) => { + SmtpEnvelope::from_message(*message, &self.config)? + } + }; + + Ok(SmtpDeliveryRequest { + host: self.config.host.clone(), + port: self.config.port, + username: self.config.username.clone(), + password: self.config.password.clone(), + from: envelope.from, + to: envelope.to, + reply_to: envelope.reply_to, + subject: envelope.subject, + body_text: envelope.body_text, + body_html: envelope.body_html, + tls: self.config.tls, + }) + } +} + +impl ImapSourceAdapter { + pub fn with_client( + reference: PipeAdapterReference, + client: T, + ) -> Result { + let metadata = builtin_registry().find(&reference.code).ok_or_else(|| { + PipeAdapterError::Message(format!("unknown imap adapter '{}'", reference.code)) + })?; + let config_value = reference.config.clone().ok_or_else(|| { + PipeAdapterError::Message(format!("adapter '{}' requires config", reference.code)) + })?; + let config: ImapSourceConfig = serde_json::from_value(config_value).map_err(|err| { + PipeAdapterError::Message(format!( + "invalid imap adapter config for '{}': {}", + reference.code, err + )) + })?; + + Ok(Self { + metadata, + reference, + config, + client, + seen_ids: Arc::new(Mutex::new(HashSet::new())), + }) + } + + fn build_request(&self) -> MailSourceRequest { + MailSourceRequest { + host: self.config.host.clone(), + port: self.config.port, + username: self.config.username.clone(), + password: self.config.password.clone(), + tls: self.config.tls, + mailbox: Some(self.config.mailbox.clone()), + } + } +} + +impl Pop3SourceAdapter { + pub fn with_client( + reference: PipeAdapterReference, + client: T, + ) -> Result { + let metadata = builtin_registry().find(&reference.code).ok_or_else(|| { + PipeAdapterError::Message(format!("unknown pop3 adapter '{}'", reference.code)) + })?; + let config_value = reference.config.clone().ok_or_else(|| { + PipeAdapterError::Message(format!("adapter '{}' requires config", reference.code)) + })?; + let config: Pop3SourceConfig = serde_json::from_value(config_value).map_err(|err| { + PipeAdapterError::Message(format!( + "invalid pop3 adapter config for '{}': {}", + reference.code, err + )) + })?; + + Ok(Self { + metadata, + reference, + config, + client, + seen_ids: Arc::new(Mutex::new(HashSet::new())), + }) + } + + fn build_request(&self) -> MailSourceRequest { + MailSourceRequest { + host: self.config.host.clone(), + port: self.config.port, + username: self.config.username.clone(), + password: self.config.password.clone(), + tls: self.config.tls, + mailbox: None, + } + } +} + +#[async_trait] +impl PipeTargetAdapter for SmtpTargetAdapter { + fn metadata(&self) -> &PipeAdapterMetadata { + &self.metadata + } + + async fn deliver(&self, payload: PipeAdapterPayload) -> Result { + let request = self.build_request(payload)?; + let receipt = self.client.send(&request).await?; + Ok(json!({ + "transport": "smtp", + "adapter": self.reference.code, + "status": Value::Null, + "delivered": true, + "body": { + "host": request.host, + "port": request.port, + "tls": request.tls, + "subject": request.subject, + "to": request.to, + "from": request.from, + "message_id": receipt.message_id, + "accepted_recipients": receipt.accepted_recipients, + } + })) + } +} + +#[async_trait] +impl PipeSourceAdapter for ImapSourceAdapter { + fn metadata(&self) -> &PipeAdapterMetadata { + &self.metadata + } + + async fn poll(&self) -> Result, PipeAdapterError> { + let messages = filter_new_messages( + &self.seen_ids, + self.client.poll_imap(&self.build_request()).await?, + )?; + Ok(messages + .into_iter() + .map(|message| PipeAdapterDispatch { + adapter: self.reference.clone(), + payload: PipeAdapterPayload::MailMessage(Box::new(message)), + }) + .collect()) + } +} + +#[async_trait] +impl PipeSourceAdapter for Pop3SourceAdapter { + fn metadata(&self) -> &PipeAdapterMetadata { + &self.metadata + } + + async fn poll(&self) -> Result, PipeAdapterError> { + let messages = filter_new_messages( + &self.seen_ids, + self.client.poll_pop3(&self.build_request()).await?, + )?; + Ok(messages + .into_iter() + .map(|message| PipeAdapterDispatch { + adapter: self.reference.clone(), + payload: PipeAdapterPayload::MailMessage(Box::new(message)), + }) + .collect()) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct SmtpTargetConfig { + host: String, + #[serde(default = "default_smtp_port")] + port: u16, + #[serde(default)] + username: Option, + #[serde(default)] + password: Option, + #[serde(default)] + from: Option, + #[serde(default, deserialize_with = "deserialize_string_or_vec")] + to: Vec, + #[serde(default = "default_true")] + tls: bool, +} + +#[derive(Debug, Clone, Deserialize)] +struct ImapSourceConfig { + host: String, + #[serde(default = "default_imap_port")] + port: u16, + username: String, + #[serde(default)] + password: Option, + #[serde(default = "default_imap_mailbox")] + mailbox: String, + #[serde(default = "default_true")] + tls: bool, +} + +#[derive(Debug, Clone, Deserialize)] +struct Pop3SourceConfig { + host: String, + #[serde(default = "default_pop3_port")] + port: u16, + username: String, + #[serde(default)] + password: Option, + #[serde(default = "default_true")] + tls: bool, +} + +#[derive(Debug, Clone)] +struct SmtpEnvelope { + from: String, + to: Vec, + reply_to: Option, + subject: String, + body_text: Option, + body_html: Option, +} + +impl SmtpEnvelope { + fn from_json(value: Value, config: &SmtpTargetConfig) -> Result { + let from = json_string_field(&value, "from_email") + .or_else(|| config.from.clone()) + .ok_or_else(|| { + PipeAdapterError::Message("smtp adapter requires a from address".to_string()) + })?; + let to = json_string_list_field(&value, "to_email"); + let to = if to.is_empty() { config.to.clone() } else { to }; + if to.is_empty() { + return Err(PipeAdapterError::Message( + "smtp adapter requires at least one recipient address".to_string(), + )); + } + + let subject = json_string_field(&value, "subject") + .unwrap_or_else(|| "Stacker pipe message".to_string()); + let body_text = json_string_field(&value, "body_text").or_else(|| match &value { + Value::String(text) => Some(text.clone()), + other => serde_json::to_string_pretty(other).ok(), + }); + let body_html = json_string_field(&value, "body_html"); + if body_text.is_none() && body_html.is_none() { + return Err(PipeAdapterError::Message( + "smtp adapter requires body_text or body_html content".to_string(), + )); + } + + Ok(Self { + from, + to, + reply_to: json_string_field(&value, "reply_to_email"), + subject, + body_text, + body_html, + }) + } + + fn from_message( + message: pipe_adapter_sdk::NormalizedMailMessage, + config: &SmtpTargetConfig, + ) -> Result { + let from = message + .from + .first() + .map(|address| address.email.clone()) + .or_else(|| config.from.clone()) + .ok_or_else(|| { + PipeAdapterError::Message("smtp adapter requires a from address".to_string()) + })?; + let to = if message.to.is_empty() { + config.to.clone() + } else { + message + .to + .into_iter() + .map(|address| address.email) + .collect() + }; + if to.is_empty() { + return Err(PipeAdapterError::Message( + "smtp adapter requires at least one recipient address".to_string(), + )); + } + + let subject = message + .subject + .unwrap_or_else(|| "Stacker pipe message".to_string()); + let body_text = message.body.text; + let body_html = message.body.html; + if body_text.is_none() && body_html.is_none() { + return Err(PipeAdapterError::Message( + "smtp adapter requires body_text or body_html content".to_string(), + )); + } + + Ok(Self { + from, + to, + reply_to: None, + subject, + body_text, + body_html, + }) + } +} + +fn default_smtp_port() -> u16 { + 587 +} + +fn default_imap_port() -> u16 { + 993 +} + +fn default_pop3_port() -> u16 { + 995 +} + +fn default_imap_mailbox() -> String { + "INBOX".to_string() +} + +fn default_true() -> bool { + true +} + +fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(Value::String(item)) => vec![item], + Some(Value::Array(items)) => items + .into_iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect(), + _ => Vec::new(), + }) +} + +fn json_string_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn json_string_list_field(value: &Value, key: &str) -> Vec { + match value.get(key) { + Some(Value::String(item)) if !item.trim().is_empty() => vec![item.trim().to_string()], + Some(Value::Array(items)) => items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect(), + _ => Vec::new(), + } +} + +async fn poll_imap_client( + mut client: async_imap::Client, + request: &MailSourceRequest, +) -> Result, PipeAdapterError> +where + S: AsyncRead + AsyncWrite + Unpin + Send + std::fmt::Debug, +{ + let password = request.password.as_deref().ok_or_else(|| { + PipeAdapterError::Message( + "imap adapter requires a password in the adapter configuration".to_string(), + ) + })?; + client.read_response().await.map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to read greeting from {}:{}: {}", + request.host, request.port, err + )) + })?; + let mut session = client + .login(request.username.as_str(), password) + .await + .map_err(|(err, _)| { + PipeAdapterError::Message(format!( + "imap adapter login failed for '{}' on {}:{}: {}", + request.username, request.host, request.port, err + )) + })?; + + let mailbox = request.mailbox.as_deref().unwrap_or("INBOX"); + session.select(mailbox).await.map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to select mailbox '{}': {}", + mailbox, err + )) + })?; + + let mut uids: Vec<_> = session + .uid_search("UNSEEN") + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to search mailbox '{}': {}", + mailbox, err + )) + })? + .into_iter() + .collect(); + uids.sort_unstable(); + + let mut messages = Vec::new(); + for uid in uids { + let fetches: Vec<_> = session + .uid_fetch(uid.to_string(), "RFC822") + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to fetch uid {} from '{}': {}", + uid, mailbox, err + )) + })? + .try_collect() + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to decode uid {} from '{}': {}", + uid, mailbox, err + )) + })?; + + for fetch in fetches { + if let Some(body) = fetch.body() { + messages.push(parse_normalized_mail_message( + body, + Some(mailbox), + Some(uid.to_string()), + )?); + } + } + + let _: Vec<_> = session + .uid_store(uid.to_string(), "+FLAGS (\\Seen)") + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to mark uid {} seen in '{}': {}", + uid, mailbox, err + )) + })? + .try_collect() + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to confirm seen flag for uid {} in '{}': {}", + uid, mailbox, err + )) + })?; + } + + let _ = session.logout().await; + Ok(messages) +} + +fn filter_new_messages( + seen_ids: &Arc>>, + messages: Vec, +) -> Result, PipeAdapterError> { + let mut seen_ids = seen_ids + .lock() + .map_err(|_| PipeAdapterError::Message("mail adapter state lock poisoned".to_string()))?; + let mut fresh = Vec::new(); + + for message in messages { + let dedupe_key = message + .cursor + .clone() + .or_else(|| message.message_id.clone()) + .or_else(|| message.subject.clone()) + .ok_or_else(|| { + PipeAdapterError::Message( + "mail adapter could not derive a stable cursor or message id".to_string(), + ) + })?; + if seen_ids.insert(dedupe_key) { + fresh.push(message); + } + } + + Ok(fresh) +} + +fn parse_normalized_mail_message( + raw: &[u8], + mailbox: Option<&str>, + cursor: Option, +) -> Result { + let parsed = parse_mail(raw).map_err(|err| { + PipeAdapterError::Message(format!("mail adapter failed to parse raw message: {}", err)) + })?; + let body = extract_mail_body(&parsed); + + Ok(NormalizedMailMessage { + cursor, + mailbox: mailbox.map(str::to_string), + message_id: parsed.headers.get_first_value("Message-ID"), + subject: parsed.headers.get_first_value("Subject"), + sent_at: parsed.headers.get_first_value("Date"), + received_at: None, + from: parse_mail_addresses(&parsed, "From")?, + to: parse_mail_addresses(&parsed, "To")?, + cc: parse_mail_addresses(&parsed, "Cc")?, + bcc: parse_mail_addresses(&parsed, "Bcc")?, + headers: parsed + .headers + .iter() + .map(|header| (header.get_key().to_string(), header.get_value())) + .collect(), + body, + attachments: extract_attachments(&parsed)?, + }) +} + +fn extract_mail_body(parsed: &ParsedMail<'_>) -> NormalizedMailBody { + let mut body = NormalizedMailBody { + text: None, + html: None, + }; + + for part in parsed.parts() { + if part.ctype.mimetype.eq_ignore_ascii_case("text/plain") && body.text.is_none() { + if let Ok(text) = part.get_body() { + let text = text.trim().to_string(); + if !text.is_empty() { + body.text = Some(text); + } + } + } + if part.ctype.mimetype.eq_ignore_ascii_case("text/html") && body.html.is_none() { + if let Ok(html) = part.get_body() { + let html = html.trim().to_string(); + if !html.is_empty() { + body.html = Some(html); + } + } + } + } + + if body.text.is_none() && body.html.is_none() && parsed.subparts.is_empty() { + if let Ok(text) = parsed.get_body() { + let text = text.trim().to_string(); + if !text.is_empty() { + body.text = Some(text); + } + } + } + + body +} + +fn extract_attachments( + parsed: &ParsedMail<'_>, +) -> Result, PipeAdapterError> { + let mut attachments = Vec::new(); + + for part in parsed.parts() { + if part.ctype.mimetype.starts_with("multipart/") { + continue; + } + let disposition = part.get_content_disposition(); + let filename = disposition + .params + .get("filename") + .cloned() + .or_else(|| part.ctype.params.get("name").cloned()); + if let Some(filename) = filename { + let raw = part.get_body_raw().map_err(|err| { + PipeAdapterError::Message(format!( + "mail adapter failed to decode attachment '{}': {}", + filename, err + )) + })?; + attachments.push(NormalizedMailAttachment { + file_name: Some(filename), + content_type: Some(part.ctype.mimetype.clone()), + size_bytes: Some(raw.len() as u64), + }); + } + } + + Ok(attachments) +} + +fn parse_mail_addresses( + parsed: &ParsedMail<'_>, + header_name: &str, +) -> Result, PipeAdapterError> { + let Some(header) = parsed + .headers + .iter() + .find(|header| header.get_key_ref().eq_ignore_ascii_case(header_name)) + else { + return Ok(Vec::new()); + }; + + let addresses = addrparse_header(header).map_err(|err| { + PipeAdapterError::Message(format!( + "mail adapter failed to parse '{}' header: {}", + header_name, err + )) + })?; + + Ok(addresses.iter().flat_map(flatten_mail_addr).collect()) +} + +fn flatten_mail_addr(address: &MailAddr) -> Vec { + match address { + MailAddr::Single(info) => vec![NormalizedMailAddress { + name: info + .display_name + .clone() + .filter(|name| !name.trim().is_empty()), + email: info.addr.clone(), + }], + MailAddr::Group(group) => group + .addrs + .iter() + .map(|info| NormalizedMailAddress { + name: info + .display_name + .clone() + .filter(|name| !name.trim().is_empty()), + email: info.addr.clone(), + }) + .collect(), + } +} + +fn build_smtp_message(request: &SmtpDeliveryRequest) -> Result { + let mut builder = Message::builder() + .from(parse_mailbox(&request.from)?) + .subject(request.subject.clone()); + + for recipient in &request.to { + builder = builder.to(parse_mailbox(recipient)?); + } + if let Some(reply_to) = &request.reply_to { + builder = builder.reply_to(parse_mailbox(reply_to)?); + } + + match (&request.body_text, &request.body_html) { + (Some(text), Some(html)) => builder + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text.clone()), + ) + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html.clone()), + ), + ) + .map_err(|err| { + PipeAdapterError::Message(format!("failed to build smtp message: {}", err)) + }), + (Some(text), None) => builder + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text.clone()), + ) + .map_err(|err| { + PipeAdapterError::Message(format!("failed to build smtp message: {}", err)) + }), + (None, Some(html)) => builder + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html.clone()), + ) + .map_err(|err| { + PipeAdapterError::Message(format!("failed to build smtp message: {}", err)) + }), + (None, None) => Err(PipeAdapterError::Message( + "smtp adapter requires body_text or body_html content".to_string(), + )), + } +} + +fn parse_mailbox(raw: &str) -> Result { + raw.parse().map_err(|err| { + PipeAdapterError::Message(format!("invalid email address '{}': {}", raw, err)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[derive(Clone, Default)] + struct FakeSmtpClient { + requests: Arc>>, + } + + #[derive(Clone, Default)] + struct FakeMailSourceClient { + imap_messages: Arc>>, + pop3_messages: Arc>>, + } + + #[async_trait] + impl SmtpClient for FakeSmtpClient { + async fn send( + &self, + request: &SmtpDeliveryRequest, + ) -> Result { + self.requests.lock().unwrap().push(request.clone()); + Ok(SmtpDeliveryReceipt { + message_id: Some("msg-123".to_string()), + accepted_recipients: request.to.len(), + }) + } + } + + #[async_trait] + impl MailSourceClient for FakeMailSourceClient { + async fn poll_imap( + &self, + _request: &MailSourceRequest, + ) -> Result, PipeAdapterError> { + Ok(self + .imap_messages + .lock() + .expect("imap messages lock") + .clone()) + } + + async fn poll_pop3( + &self, + _request: &MailSourceRequest, + ) -> Result, PipeAdapterError> { + Ok(self + .pop3_messages + .lock() + .expect("pop3 messages lock") + .clone()) + } + } + + #[tokio::test] + async fn smtp_target_adapter_delivers_json_payload_with_fake_client() { + let client = FakeSmtpClient::default(); + let adapter = SmtpTargetAdapter::with_client( + PipeAdapterReference::new("smtp").with_config(json!({ + "host": "smtp.example.com", + "port": 2525, + "from": "noreply@example.com", + "to": ["alerts@example.com"], + "tls": false + })), + client.clone(), + ) + .expect("adapter config should parse"); + + let response = adapter + .deliver(PipeAdapterPayload::Json(json!({ + "subject": "Deployment ready", + "body_text": "The deployment completed successfully" + }))) + .await + .expect("smtp delivery should succeed"); + + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].host, "smtp.example.com"); + assert_eq!(requests[0].port, 2525); + assert_eq!(requests[0].to, vec!["alerts@example.com".to_string()]); + assert_eq!(requests[0].from, "noreply@example.com"); + assert_eq!(response["transport"], "smtp"); + assert_eq!(response["adapter"], "smtp"); + assert_eq!(response["delivered"], true); + assert_eq!(response["body"]["accepted_recipients"], 1); + } + + #[tokio::test] + async fn smtp_target_adapter_requires_recipient_before_delivery() { + let adapter = SmtpTargetAdapter::with_client( + PipeAdapterReference::new("smtp").with_config(json!({ + "host": "smtp.example.com", + "from": "noreply@example.com" + })), + FakeSmtpClient::default(), + ) + .expect("adapter config should parse"); + + let error = adapter + .deliver(PipeAdapterPayload::Json(json!({ + "subject": "Deployment ready", + "body_text": "The deployment completed successfully" + }))) + .await + .expect_err("delivery should fail without recipients"); + + assert!(error + .to_string() + .contains("smtp adapter requires at least one recipient address")); + } + + #[tokio::test] + async fn imap_source_adapter_polls_normalized_mail_dispatches() { + let client = FakeMailSourceClient { + imap_messages: Arc::new(Mutex::new(vec![NormalizedMailMessage { + subject: Some("Incident opened".to_string()), + mailbox: Some("INBOX".to_string()), + body: pipe_adapter_sdk::NormalizedMailBody { + text: Some("CPU usage exceeded threshold".to_string()), + html: None, + }, + ..Default::default() + }])), + pop3_messages: Arc::new(Mutex::new(Vec::new())), + }; + let adapter = ImapSourceAdapter::with_client( + PipeAdapterReference::new("imap").with_config(json!({ + "host": "imap.example.com", + "username": "alerts@example.com", + "password": "secret", + "mailbox": "INBOX" + })), + client, + ) + .expect("imap adapter config should parse"); + + let dispatches = adapter.poll().await.expect("imap poll should succeed"); + + assert_eq!(dispatches.len(), 1); + assert_eq!(dispatches[0].adapter.code, "imap"); + assert_eq!( + dispatches[0].payload, + PipeAdapterPayload::MailMessage(Box::new(NormalizedMailMessage { + subject: Some("Incident opened".to_string()), + mailbox: Some("INBOX".to_string()), + body: pipe_adapter_sdk::NormalizedMailBody { + text: Some("CPU usage exceeded threshold".to_string()), + html: None, + }, + ..Default::default() + })) + ); + } + + #[tokio::test] + async fn pop3_source_adapter_polls_normalized_mail_dispatches() { + let client = FakeMailSourceClient { + imap_messages: Arc::new(Mutex::new(Vec::new())), + pop3_messages: Arc::new(Mutex::new(vec![NormalizedMailMessage { + subject: Some("Welcome".to_string()), + body: pipe_adapter_sdk::NormalizedMailBody { + text: Some("hello".to_string()), + html: None, + }, + ..Default::default() + }])), + }; + let adapter = Pop3SourceAdapter::with_client( + PipeAdapterReference::new("pop3").with_config(json!({ + "host": "pop3.example.com", + "username": "alerts@example.com", + "password": "secret" + })), + client, + ) + .expect("pop3 adapter config should parse"); + + let dispatches = adapter.poll().await.expect("pop3 poll should succeed"); + + assert_eq!(dispatches.len(), 1); + assert_eq!(dispatches[0].adapter.code, "pop3"); + assert_eq!( + dispatches[0].payload, + PipeAdapterPayload::MailMessage(Box::new(NormalizedMailMessage { + subject: Some("Welcome".to_string()), + body: pipe_adapter_sdk::NormalizedMailBody { + text: Some("hello".to_string()), + html: None, + }, + ..Default::default() + })) + ); + } +} diff --git a/crates/pipe-adapter-sdk/Cargo.toml b/crates/pipe-adapter-sdk/Cargo.toml new file mode 100644 index 00000000..1c8fd990 --- /dev/null +++ b/crates/pipe-adapter-sdk/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "pipe-adapter-sdk" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" diff --git a/crates/pipe-adapter-sdk/src/lib.rs b/crates/pipe-adapter-sdk/src/lib.rs new file mode 100644 index 00000000..f725a964 --- /dev/null +++ b/crates/pipe-adapter-sdk/src/lib.rs @@ -0,0 +1,298 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum PipeAdapterRole { + Source, + Target, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum PipeAdapterKind { + HttpEndpoint, + HtmlForm, + WebhookBridge, + SmtpTarget, + Pop3Source, + ImapSource, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PipeAdapterReference { + pub code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, +} + +impl PipeAdapterReference { + pub fn new(code: impl Into) -> Self { + Self { + code: normalize_adapter_code(&code.into()), + role: None, + config: None, + } + } + + pub fn with_role(mut self, role: PipeAdapterRole) -> Self { + self.role = Some(role); + self + } + + pub fn with_config(mut self, config: serde_json::Value) -> Self { + self.config = Some(config); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PipeAdapterMetadata { + pub code: String, + pub display_name: String, + pub description: String, + pub kind: PipeAdapterKind, + pub roles: Vec, +} + +impl PipeAdapterMetadata { + pub fn supports_role(&self, role: PipeAdapterRole) -> bool { + self.roles.contains(&role) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct NormalizedMailAddress { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub email: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct NormalizedMailBody { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub html: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct NormalizedMailAttachment { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size_bytes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct NormalizedMailMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mailbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sent_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub received_at: Option, + #[serde(default)] + pub from: Vec, + #[serde(default)] + pub to: Vec, + #[serde(default)] + pub cc: Vec, + #[serde(default)] + pub bcc: Vec, + #[serde(default)] + pub headers: BTreeMap, + #[serde(default)] + pub body: NormalizedMailBody, + #[serde(default)] + pub attachments: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum PipeAdapterPayload { + Json(serde_json::Value), + MailMessage(Box), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PipeAdapterDispatch { + pub adapter: PipeAdapterReference, + pub payload: PipeAdapterPayload, +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum PipeAdapterError { + #[error("{0}")] + Message(String), +} + +#[async_trait] +pub trait PipeSourceAdapter: Send + Sync { + fn metadata(&self) -> &PipeAdapterMetadata; + + async fn poll(&self) -> Result, PipeAdapterError>; +} + +#[async_trait] +pub trait PipeTargetAdapter: Send + Sync { + fn metadata(&self) -> &PipeAdapterMetadata; + + async fn deliver( + &self, + payload: PipeAdapterPayload, + ) -> Result; +} + +pub trait PipeAdapterCatalog: Send + Sync { + fn adapters(&self) -> Vec; + fn find(&self, code: &str) -> Option; +} + +#[derive(Debug, Clone, Default)] +pub struct InMemoryPipeAdapterRegistry { + adapters: BTreeMap, +} + +impl InMemoryPipeAdapterRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, metadata: PipeAdapterMetadata) { + self.adapters + .insert(normalize_adapter_code(&metadata.code), metadata); + } +} + +impl PipeAdapterCatalog for InMemoryPipeAdapterRegistry { + fn adapters(&self) -> Vec { + self.adapters.values().cloned().collect() + } + + fn find(&self, code: &str) -> Option { + self.adapters.get(&normalize_adapter_code(code)).cloned() + } +} + +pub fn normalize_adapter_code(code: &str) -> String { + code.trim().to_ascii_lowercase() +} + +pub fn builtin_registry() -> InMemoryPipeAdapterRegistry { + let mut registry = InMemoryPipeAdapterRegistry::new(); + for metadata in [ + PipeAdapterMetadata { + code: "webhook".to_string(), + display_name: "Webhook bridge".to_string(), + description: "Generic HTTP webhook target adapter".to_string(), + kind: PipeAdapterKind::WebhookBridge, + roles: vec![PipeAdapterRole::Target], + }, + PipeAdapterMetadata { + code: "smtp".to_string(), + display_name: "SMTP target".to_string(), + description: "Outbound SMTP delivery target adapter".to_string(), + kind: PipeAdapterKind::SmtpTarget, + roles: vec![PipeAdapterRole::Target], + }, + PipeAdapterMetadata { + code: "pop3".to_string(), + display_name: "POP3 source".to_string(), + description: "Inbound POP3 mailbox polling source adapter".to_string(), + kind: PipeAdapterKind::Pop3Source, + roles: vec![PipeAdapterRole::Source], + }, + PipeAdapterMetadata { + code: "imap".to_string(), + display_name: "IMAP source".to_string(), + description: "Inbound IMAP mailbox polling source adapter".to_string(), + kind: PipeAdapterKind::ImapSource, + roles: vec![PipeAdapterRole::Source], + }, + PipeAdapterMetadata { + code: "mailhog".to_string(), + display_name: "MailHog SMTP target".to_string(), + description: "SMTP-compatible target alias for MailHog-style services".to_string(), + kind: PipeAdapterKind::SmtpTarget, + roles: vec![PipeAdapterRole::Target], + }, + ] { + registry.register(metadata); + } + registry +} + +pub fn builtin_adapter_kind(code: &str) -> Option { + builtin_registry().find(code).map(|metadata| metadata.kind) +} + +pub fn selector_matches_builtin_kind(selector: &str, kind: PipeAdapterKind) -> bool { + let canonical = normalize_adapter_code(selector); + if builtin_adapter_kind(&canonical) == Some(kind) { + return true; + } + + selector + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) + .map(normalize_adapter_code) + .any(|token| builtin_adapter_kind(&token) == Some(kind)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_registry_exposes_first_party_adapters() { + let registry = builtin_registry(); + + assert_eq!( + registry.find("smtp").map(|metadata| metadata.kind), + Some(PipeAdapterKind::SmtpTarget) + ); + assert_eq!( + registry.find("imap").map(|metadata| metadata.kind), + Some(PipeAdapterKind::ImapSource) + ); + } + + #[test] + fn selector_matching_detects_mail_aliases() { + assert!(selector_matches_builtin_kind( + "smtp", + PipeAdapterKind::SmtpTarget + )); + assert!(selector_matches_builtin_kind( + "mailhog", + PipeAdapterKind::SmtpTarget + )); + assert!(selector_matches_builtin_kind( + "status-mailhog-1", + PipeAdapterKind::SmtpTarget + )); + assert!(!selector_matches_builtin_kind( + "status-panel-web", + PipeAdapterKind::SmtpTarget + )); + } + + #[test] + fn adapter_reference_normalizes_codes() { + let reference = PipeAdapterReference::new(" SMTP "); + + assert_eq!(reference.code, "smtp"); + } +} diff --git a/docs/AI_DEPLOYMENT_WORKFLOWS.md b/docs/AI_DEPLOYMENT_WORKFLOWS.md new file mode 100644 index 00000000..3a1d96f7 --- /dev/null +++ b/docs/AI_DEPLOYMENT_WORKFLOWS.md @@ -0,0 +1,204 @@ +# AI Deployment Workflows + +This guide documents the canonical AI-facing deployment workflow for Stacker. +It is intended for MCP clients, frontend chat integrations, and evaluation +fixtures that need a stable inspect -> explain -> plan -> apply -> recover +sequence. + +## Canonical tools + +| Tool | Purpose | Notes | +| --- | --- | --- | +| `get_deployment_state` | Inspect canonical machine-readable deployment state | Prefer this over parsing `get_deployment_status` | +| `explain_topology` | Explain runtime compose and env paths without secret values | Safe for path and service reasoning | +| `explain_env` | Explain env provenance for one app without disclosing secret values | Returns layer names, key names, hashes, and destination metadata | +| `get_deployment_plan` | Preview deploy or rollback actions and produce a stable fingerprint | Use before any mutation | +| `apply_deployment_plan` | Apply a previously previewed plan | Requires `confirm=true`, `expected_fingerprint`, and MFA | +| `get_deployment_events` | Observe progress, failure, and remediation signals | Use during apply and recovery loops | +| `get_app_env_vars` | Inspect app env values with explicit secure metadata | Prefer `environment_entries` for `secure`/`source` flags | + +## Compatibility rules + +1. Prefer `get_deployment_state`, `get_deployment_plan`, and + `get_deployment_events` over `get_deployment_status` when an AI client needs + stable structured fields. +2. Treat MCP tool payloads as explicit allow-list responses. Do not depend on + internal model fields that are not present in the documented response. +3. For tool failures, read `result.isError` and parse the JSON string in + `result.content[0].text` as a typed error envelope. +4. `apply_deployment_plan` is intentionally narrower than local CLI deploy: + server-side MCP supports `deploy_app` and `rollback_deploy`, but rejects full + `deploy` apply because that still requires local workspace context. + +## Recommended workflow + +### 1. Inspect current state + +Call `get_deployment_state` first to inspect status, drift, last command, agent +health, and app inventory. + +```json +{ + "name": "get_deployment_state", + "arguments": { + "deployment_hash": "deployment_state_online" + } +} +``` + +### 2. Explain topology or env provenance + +Use `explain_topology` when the AI needs runtime paths and service inventory. +Use `explain_env` when it needs to reason about env provenance for one app. +Use `get_app_env_vars` when it needs the redacted env payload itself together +with explicit `secure` and `source` metadata for each variable. + +```json +{ + "name": "explain_topology", + "arguments": { + "deployment_hash": "deployment_state_online" + } +} +``` + +```json +{ + "name": "explain_env", + "arguments": { + "deployment_hash": "deployment_state_online", + "app_code": "device-api" + } +} +``` + +```json +{ + "name": "get_app_env_vars", + "arguments": { + "project_id": 42, + "app_code": "device-api" + } +} +``` + +The response preserves the legacy redacted object in `environment_variables`, +but new clients should prefer `environment_entries` because Vault-backed +service-secret keys are marked with `secure=true` and `source="vault"` even +when their names are not obviously secret-like. + +### 3. Preview a plan and capture its fingerprint + +Always preview with `get_deployment_plan` before a mutation. The returned +`fingerprint` is the stale-plan guard that must be echoed into +`apply_deployment_plan`. + +```json +{ + "name": "get_deployment_plan", + "arguments": { + "deployment_hash": "deployment_state_online", + "operation": "deploy_app", + "app_code": "device-api" + } +} +``` + +For rollback preview: + +```json +{ + "name": "get_deployment_plan", + "arguments": { + "deployment_hash": "deployment_state_online", + "operation": "rollback_deploy", + "rollback_target": "previous" + } +} +``` + +### 4. Apply with confirmation + +Mutations require an explicit human confirmation signal. Frontends should gate +this tool behind a confirmation prompt and step-up auth/MFA check. + +```json +{ + "name": "apply_deployment_plan", + "arguments": { + "deployment_hash": "deployment_state_online", + "operation": "deploy_app", + "app_code": "device-api", + "expected_fingerprint": "plan_fingerprint_from_preview", + "confirm": true + } +} +``` + +### 5. Recover using events and rollback + +If an apply fails or the deployment enters an unhealthy state: + +1. Call `get_deployment_events` to read remediation signals. +2. Preview a rollback with `get_deployment_plan` and + `operation=rollback_deploy`. +3. Apply that rollback with `apply_deployment_plan`. +4. Re-read `get_deployment_events` and `get_deployment_state` until the state is + healthy or a typed error indicates the next remediation step. + +## Frontend integration requirements + +- Add `apply_deployment_plan` to the frontend's confirmation-required tool list. +- Preserve the exact `expected_fingerprint` returned by preview. +- Surface typed MCP errors directly instead of flattening them into generic + failure text. +- Do not request or display raw secret values from explain or state payloads; + those surfaces are intentionally redaction-first. + +## Evaluation fixtures + +The versioned evaluation scenarios live in +`tests/contracts/stacker-ai-workflows.v1alpha1.json`. +The stable response schemas and samples live alongside them in +`tests/contracts/`. + +## Qwen website scenario bootstrap + +Stacker also includes a model-targeted convenience layer for simple website +projects. This is separate from the canonical MCP workflow above. + +### Trigger + +- `stacker init --with-ai` generates `stacker.yml` and `.stacker/` artifacts as + usual. +- If the project looks like a simple HTML/static site or a Next.js app, and the + configured Ollama model contains `qwen2.5-code` or `qwen2.5-coder`, Stacker + offers to bootstrap the built-in `website-deploy` scenario. + +### What the bootstrap does + +1. Reads the generated `stacker.yml` and local project hints. +2. Seeds known scenario variables such as project name, app type, proxy shape, + cloud settings, and AI provider/model settings. +3. Prompts only for missing deploy-critical values such as public domain, image + repository/tag, and cloud target details. +4. Saves state under `.stacker/scenarios/qwen2.5-code/website-deploy/state.json`. +5. Starts the scenario at the `init-validate` step and prints the next exact + commands to run. + +### Continue a scenario later + +```bash +stacker ai ask "continue" --scenario website-deploy --step init-validate +stacker ai ask "continue" --scenario website-deploy --step image-publish +stacker ai ask "continue" --scenario website-deploy --step cloud-deploy +stacker ai --scenario website-deploy --step runtime-ops +``` + +### Scenario content layout + +- Built-in files live under `scenarios/qwen2.5-code/website-deploy/`. +- Project-local overrides can be placed under + `.stacker/scenarios/qwen2.5-code/website-deploy/`. +- The saved state file lives beside those overrides in the same `.stacker` + directory tree. diff --git a/docs/APP_DEPLOYMENT.md b/docs/APP_DEPLOYMENT.md index d1bd480e..d0a6ecb5 100644 --- a/docs/APP_DEPLOYMENT.md +++ b/docs/APP_DEPLOYMENT.md @@ -151,6 +151,54 @@ path "{prefix}/*" { ## Stacker Components +### Service Deployment Scope Convention + +Default service deployments are project-scoped. + +When a service is declared in `stacker.yml`, `stacker service deploy ` and +related non-platform service deploy flows must update the main project compose +deployment: + +```text +/home/trydirect/project/docker-compose.yml +``` + +Do not create a separate compose project such as +`/home/trydirect//docker-compose.yml` for a normal custom service unless +the user explicitly opts into standalone mode, for example with a future +`--standalone` or `--scope standalone` flag. + +Only platform-managed services are allowed to live outside the project directory +by default. Current examples: + +```text +/home/trydirect/statuspanel +/home/trydirect/nginx_proxy_manager +``` + +This convention prevents duplicate runtime ownership, where the same service +exists both inside `/home/trydirect/project/docker-compose.yml` and as a separate +standalone compose project. Before adding or changing service deployment code, +verify whether the service is project-scoped or platform-managed and add +regression tests for the chosen scope. + +Stacker-managed compose services must include stable runtime identity labels +under the owned `stacker.my` reverse-DNS prefix: + +```yaml +labels: + my.stacker.project_id: "123" + my.stacker.target: "cloud" + my.stacker.scope: "project" + my.stacker.service: "smtp" + my.stacker.dns: "smtp" +``` + +Use `my.stacker.service` for the logical Stacker service code and +`my.stacker.dns` for the Docker network name that agents should use at runtime. +For Nginx Proxy Manager, this means `my.stacker.service=nginx_proxy_manager` and +`my.stacker.dns=nginx-proxy-manager`. + ### 1. ConfigRenderer Service **Location**: `src/services/config_renderer.rs` @@ -253,9 +301,11 @@ Stacker also keeps the bundled config files and appends the Vault-rendered service secrets to the `.env` file referenced by the matching compose service. This lets `device-api/docker/prod/compose.yml` with `env_file: .env` receive both local `.env` content and Vault-backed service secrets without truncating -the remote project compose file. If the server cannot render the runtime env -for a registered target, the enqueue request fails so Status does not deploy a -partial app-local `.env`. +the remote project compose file. On later resyncs, the previously appended +`# stacker-render ...` block is replaced with the freshly rendered one so +remote app-local `.env` files do not accumulate duplicate secret sections. If +the server cannot render the runtime env for a registered target, the enqueue +request fails so Status does not deploy a partial app-local `.env`. ### 5. ProjectAppService diff --git a/docs/MCP_SERVER_FRONTEND_INTEGRATION.md b/docs/MCP_SERVER_FRONTEND_INTEGRATION.md index 824f1fb3..2cbdcb59 100644 --- a/docs/MCP_SERVER_FRONTEND_INTEGRATION.md +++ b/docs/MCP_SERVER_FRONTEND_INTEGRATION.md @@ -54,6 +54,10 @@ secret tools mirror the CLI/API target model: Remote secret reads are metadata-only; plaintext values are written to Vault but never returned to MCP clients. +`get_remote_service_secret` and `list_remote_service_secrets` now include +`secure: true` in their metadata payloads because Vault-backed service secrets +are explicitly classified as secure inputs, not merely inferred by name. + Every MCP tool call is checked against Casbin before its handler executes. Clients must have a `CALL` policy for `/mcp/tools/`. Marketplace admin tools are granted only to `group_admin`; regular project, deployment, cloud, @@ -69,6 +73,64 @@ write operations. They also require: (`mfa_verified`, `two_factor_verified`, `amr` containing `totp`, `otp`, `webauthn`, etc.). +## Canonical deployment AI workflow + +For deployment troubleshooting and safe automation, frontend clients should +prefer the newer structured deployment tools over older summary payloads: + +- `get_deployment_state` for canonical deployment state. +- `explain_topology` and `explain_env` for path and env provenance reasoning. +- `get_deployment_plan` for preview plus stale-plan fingerprint generation. +- `apply_deployment_plan` for confirmed deploy-app and rollback execution. +- `get_deployment_events` for progress, failure, and remediation signals. + +### Compatibility and safety rules + +1. Do not depend on `get_deployment_status` returning the raw internal + deployment row. Use `get_deployment_state`, `get_deployment_plan`, and + `get_deployment_events` when the client needs stable machine-readable fields. +2. Add `apply_deployment_plan` to the frontend confirmation-required tool list. + The tool requires: + - `confirm=true` + - `expected_fingerprint` from the immediately preceding preview + - a step-up/MFA-capable user session +3. MCP tool failures are returned as successful JSON-RPC envelopes with + `result.isError=true` and a typed error JSON string in + `result.content[0].text`. Frontends should parse and surface that typed error + envelope instead of collapsing it into generic text. +4. Server-side MCP intentionally supports `deploy_app` and `rollback_deploy` + applies only. Full `deploy` apply still requires local CLI workspace context + and is rejected with a typed `invalid_request` error. + +See [AI deployment workflows](AI_DEPLOYMENT_WORKFLOWS.md) for the documented +tool sequence and evaluation fixture reference. + +## Environment inspection contract + +`get_app_env_vars` now returns two complementary shapes: + +- `environment_variables` — the legacy redacted key/value object for existing + clients. +- `environment_entries` — the canonical per-variable list for newer clients. + +Each `environment_entries` item contains: + +- `name` +- `value` +- `secure` +- `redacted` +- `source` (`project` or `vault`) + +Frontend clients should prefer `environment_entries` when they need to +distinguish between: + +- a value redacted because it is explicitly Vault-backed (`secure=true`) +- a value redacted by legacy heuristic name matching +- a regular project-defined env value + +This allows names such as `MYSECURE_PASSPHRASE` to remain safely redacted even +when the key name itself would not match an older secret heuristic. + ## Technology Stack ### Core Dependencies diff --git a/docs/STACKER_YML_REFERENCE.md b/docs/STACKER_YML_REFERENCE.md index a1b28b83..44108249 100644 --- a/docs/STACKER_YML_REFERENCE.md +++ b/docs/STACKER_YML_REFERENCE.md @@ -143,7 +143,7 @@ deploy: cloud: provider: hetzner region: fsn1 - size: cpx21 + size: cx23 ssh_key: ~/.ssh/id_ed25519 ai: @@ -532,7 +532,7 @@ Cloud infrastructure provisioning settings. Stacker uses Terraform/Ansible under | Value | Provider | Example Regions | Example Sizes | |-------|----------|----------------|---------------| -| `hetzner` | Hetzner Cloud | `fsn1`, `nbg1`, `hel1` | `cpx21`, `cpx31`, `cpx41` | +| `hetzner` | Hetzner Cloud | `fsn1`, `nbg1`, `hel1` | `cx23`, `cx33`, `cx43` | | `digitalocean` | DigitalOcean | `nyc1`, `sfo3`, `ams3` | `s-1vcpu-1gb`, `s-2vcpu-4gb` | | `aws` | Amazon Web Services | `us-east-1`, `eu-west-1` | `t3.micro`, `t3.small` | | `linode` | Linode (Akamai) | `us-east`, `eu-west` | `g6-nanode-1`, `g6-standard-2` | @@ -544,7 +544,7 @@ deploy: cloud: provider: hetzner region: fsn1 - size: cpx21 + size: cx23 ssh_key: ~/.ssh/id_ed25519 ``` @@ -610,7 +610,7 @@ deploy: cloud: provider: hetzner region: fsn1 - size: cpx21 + size: cx23 registry: username: "${DOCKER_USERNAME}" password: "${DOCKER_PASSWORD}" @@ -699,6 +699,10 @@ monitoring: status_panel: true ``` +If you install the agent later with `stacker agent install`, the CLI does **not** modify local +`stacker.yml` by default. Pass `--persist-config` to also write +`monitoring.status_panel: true` back into the local config file. + ### `monitoring.healthcheck` *Optional* · `object` · Default: none @@ -811,9 +815,11 @@ other services in the remote `docker-compose.yml` intact without requiring env/config files referenced only by unrelated project-level services. A service-local file such as `/docker/prod/.env` is uploaded to the remote config bundle, and Vault-rendered service secrets for that app are appended to -that same remote `.env` before the Status agent writes it. If Stacker cannot -render the target runtime env, command creation fails instead of deploying a -raw app-local `.env` without the remote secrets. +that same remote `.env` before the Status agent writes it. When the same target +is updated again, Stacker refreshes the existing `# stacker-render ...` block +instead of duplicating prior rendered secret sections. If Stacker cannot render +the target runtime env, command creation fails instead of deploying a raw +app-local `.env` without the remote secrets. The rendered runtime env is built from these layers, lowest to highest: @@ -1004,6 +1010,7 @@ Configuration issues: | `stacker config validate` | Validate `stacker.yml` | | `stacker config show` | Display resolved configuration | | `stacker config fix` | Interactively fix missing required config fields | +| `stacker config setup ai` | Configure `ai.*` settings without hand-editing YAML | | `stacker env` | Show or switch the active deploy environment/profile | | `stacker login` | Authenticate with TryDirect | | `stacker ai ask` | Ask the AI assistant a question | @@ -1045,6 +1052,7 @@ Configuration issues: - `stacker.yml` — project configuration - `.stacker/Dockerfile` — generated Dockerfile (skipped if `app.image` or `app.dockerfile` is set) - `.stacker/docker-compose.yml` — generated compose definition (skipped if `deploy.compose_file` is set) +- `.stacker/scenarios/qwen2.5-code/website-deploy/state.json` — saved only when the qwen website scenario bootstrap is accepted ```bash # Init @@ -1067,6 +1075,12 @@ stacker init --with-ai --ai-provider anthropic --ai-model claude-sonnet-4-202505 STACKER_AI_TIMEOUT=900 stacker init --with-ai # 15 min timeout for slow models ``` +If the project is a simple HTML or Next.js website and the Ollama model is +`qwen2.5-code` or `qwen2.5-coder`, Stacker can offer a website deployment +scenario immediately after `stacker init --with-ai`. That bootstrap reads the +generated config first, asks only for missing deploy inputs, and stores the +scenario state under `.stacker/scenarios/qwen2.5-code/website-deploy/`. + ### `stacker deploy` flags ```bash @@ -1130,6 +1144,12 @@ Status agent; it does not create, update, or reveal secret values. Use environment/profile for later `stacker agent deploy-app` and `stacker secrets push` commands. +MCP config inspection uses the same classification model. `get_app_env_vars` +retains the legacy redacted object response but also emits +`environment_entries[]`, where Vault-backed keys are marked with +`secure=true` and `source="vault"` even if the variable name itself would not +match older secret-name heuristics. + ### Other commands ```bash @@ -1153,10 +1173,13 @@ stacker destroy --confirm --volumes # Also remove volumes stacker config validate # Check stacker.yml stacker config validate --file prod.yml stacker config show # Display resolved config +stacker config setup ai --provider ollama --endpoint http://localhost:11434 --model llama3 --task dockerfile --task troubleshoot # AI stacker ai ask "How can I optimise this Dockerfile?" stacker ai ask "Why is my container crashing?" --context ./logs.txt +stacker ai ask "continue" --scenario website-deploy --step image-publish +stacker ai --scenario website-deploy --step runtime-ops # Proxy stacker proxy add example.com --upstream http://app:3000 --ssl auto @@ -1169,6 +1192,7 @@ stacker update --channel beta # Check beta channel # Config stacker config fix # Interactively fix missing fields stacker config fix --file prod.yml # Fix a specific config file +stacker config setup ai # Configure ai.* interactively ``` ### `stacker ssh-key` — SSH Key Management @@ -1269,7 +1293,9 @@ stacker agent remove-app --app my-app # Remove container stacker agent remove-app --app my-app --remove-volumes --remove-images # Reverse proxy -# The agent resolves Nginx Proxy Manager credentials from Vault using STACKER_SERVER_ID. +# Managed Status Panel + Nginx Proxy Manager deploys auto-seed default Vault credentials. +# Update or repair those credentials with: +# stacker secrets set npm_credentials --scope server --server-id --body-file ./npm_credentials.json stacker agent configure-proxy --app my-app --domain app.example.com --ssl stacker agent configure-proxy --app my-app --domain app.local --no-ssl @@ -1278,6 +1304,10 @@ stacker agent history # Recent command history stacker agent exec --command-type health # Raw command stacker agent exec --command-type stacker.exec --params '{"container":"app","command":"ls -la"}' +# Install Status Panel on an existing deployed server +stacker agent install # Remote install only; leaves local stacker.yml unchanged +stacker agent install --persist-config # Also write monitoring.status_panel=true to local stacker.yml + # Target a specific deployment stacker agent status --deployment abc123def ``` @@ -1489,7 +1519,7 @@ deploy: cloud: provider: hetzner region: fsn1 - size: cpx21 + size: cx23 ssh_key: ~/.ssh/id_ed25519 ``` diff --git a/install.sh b/install.sh index 6f0b486a..982e629e 100755 --- a/install.sh +++ b/install.sh @@ -105,6 +105,38 @@ download_and_install() { fi ok "Installed stacker v${version} to ${INSTALL_DIR}/${BINARY_NAME}" + + write_user_config +} + +# ── Write user config ──────────────────────────────── + +write_user_config() { + local config_dir config_file + + config_dir="${XDG_CONFIG_HOME:-${HOME}/.config}/stacker" + config_file="${config_dir}/config.yml" + + mkdir -p "$config_dir" + + if [ -f "$config_file" ]; then + info "User config already exists at ${config_file} — skipping" + return + fi + + cat > "$config_file" <<'EOF' +# Stacker CLI user configuration +# Priority: CLI flag > environment variable > this file > built-in default + +auth_url: https://try.direct/server/user +server_url: https://try.direct/stacker + +login: + browser: true + provider: gc # gc = Google, gh = GitHub +EOF + + ok "Wrote default config to ${config_file}" } # ── Verify install ─────────────────────────────────── diff --git a/migrations/20260717120019_casbin_mcp_ai_deployment_tools.down.sql b/migrations/20260717120019_casbin_mcp_ai_deployment_tools.down.sql new file mode 100644 index 00000000..05e2a249 --- /dev/null +++ b/migrations/20260717120019_casbin_mcp_ai_deployment_tools.down.sql @@ -0,0 +1,34 @@ +WITH tool_policy(subject, tool) AS ( + VALUES + ('group_user', 'get_deployment_state'), + ('group_user', 'get_deployment_plan'), + ('group_user', 'get_deployment_events'), + ('group_user', 'apply_deployment_plan'), + ('group_user', 'explain_env'), + ('group_user', 'explain_topology') +) +DELETE FROM public.casbin_rule cr +USING tool_policy tp +WHERE cr.ptype = 'p' + AND cr.v0 = tp.subject + AND cr.v1 = '/mcp/tools/' || tp.tool + AND cr.v2 = 'CALL' + AND cr.v3 = '' + AND cr.v4 = '' + AND cr.v5 = ''; + +WITH route_policy(subject, route, action) AS ( + VALUES + ('group_user', '/api/v1/deployments/:deployment_hash/state', 'GET'), + ('group_user', '/api/v1/deployments/:deployment_hash/plan', 'GET'), + ('group_user', '/api/v1/deployments/:deployment_hash/events', 'GET') +) +DELETE FROM public.casbin_rule cr +USING route_policy rp +WHERE cr.ptype = 'p' + AND cr.v0 = rp.subject + AND cr.v1 = rp.route + AND cr.v2 = rp.action + AND cr.v3 = '' + AND cr.v4 = '' + AND cr.v5 = ''; diff --git a/migrations/20260717120019_casbin_mcp_ai_deployment_tools.up.sql b/migrations/20260717120019_casbin_mcp_ai_deployment_tools.up.sql new file mode 100644 index 00000000..3b50c7a3 --- /dev/null +++ b/migrations/20260717120019_casbin_mcp_ai_deployment_tools.up.sql @@ -0,0 +1,27 @@ +-- Add Casbin ACL for AI deployment/explain MCP tools introduced after the +-- initial per-tool MCP policy migration. + +WITH tool_policy(subject, tool) AS ( + VALUES + ('group_user', 'get_deployment_state'), + ('group_user', 'get_deployment_plan'), + ('group_user', 'get_deployment_events'), + ('group_user', 'apply_deployment_plan'), + ('group_user', 'explain_env'), + ('group_user', 'explain_topology') +) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'p', subject, '/mcp/tools/' || tool, 'CALL', '', '', '' +FROM tool_policy +ON CONFLICT DO NOTHING; + +WITH route_policy(subject, route, action) AS ( + VALUES + ('group_user', '/api/v1/deployments/:deployment_hash/state', 'GET'), + ('group_user', '/api/v1/deployments/:deployment_hash/plan', 'GET'), + ('group_user', '/api/v1/deployments/:deployment_hash/events', 'GET') +) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'p', subject, route, action, '', '', '' +FROM route_policy +ON CONFLICT DO NOTHING; diff --git a/migrations/20260717120020_pipe_instance_adapters.down.sql b/migrations/20260717120020_pipe_instance_adapters.down.sql new file mode 100644 index 00000000..69c2e3bc --- /dev/null +++ b/migrations/20260717120020_pipe_instance_adapters.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE pipe_instances + DROP COLUMN IF EXISTS target_adapter, + DROP COLUMN IF EXISTS source_adapter; diff --git a/migrations/20260717120020_pipe_instance_adapters.up.sql b/migrations/20260717120020_pipe_instance_adapters.up.sql new file mode 100644 index 00000000..4d618d5e --- /dev/null +++ b/migrations/20260717120020_pipe_instance_adapters.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE pipe_instances + ADD COLUMN source_adapter JSONB, + ADD COLUMN target_adapter JSONB; diff --git a/migrations/20260718120000_remove_anonymous_deployment_capabilities.down.sql b/migrations/20260718120000_remove_anonymous_deployment_capabilities.down.sql new file mode 100644 index 00000000..9813ec73 --- /dev/null +++ b/migrations/20260718120000_remove_anonymous_deployment_capabilities.down.sql @@ -0,0 +1,16 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'p', + 'group_anonymous', + '/api/v1/deployments/:deployment_hash/capabilities', + 'GET', + NULL, + NULL, + NULL +WHERE NOT EXISTS ( + SELECT 1 + FROM public.casbin_rule + WHERE ptype = 'p' + AND v0 = 'group_anonymous' + AND v1 = '/api/v1/deployments/:deployment_hash/capabilities' + AND v2 = 'GET' +); diff --git a/migrations/20260718120000_remove_anonymous_deployment_capabilities.up.sql b/migrations/20260718120000_remove_anonymous_deployment_capabilities.up.sql new file mode 100644 index 00000000..3d1e3112 --- /dev/null +++ b/migrations/20260718120000_remove_anonymous_deployment_capabilities.up.sql @@ -0,0 +1,6 @@ +-- Deployment capabilities expose agent state and must require authentication. +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_anonymous' + AND v1 = '/api/v1/deployments/:deployment_hash/capabilities' + AND v2 = 'GET'; diff --git a/scenarios/qwen2.5-code/website-deploy/scenario.yaml b/scenarios/qwen2.5-code/website-deploy/scenario.yaml new file mode 100644 index 00000000..f15767f6 --- /dev/null +++ b/scenarios/qwen2.5-code/website-deploy/scenario.yaml @@ -0,0 +1,47 @@ +name: website-deploy +description: Repeatable website deployment workflow for simple HTML and Next.js projects using conservative Stacker commands. +model_match: + provider: ollama + name_contains: + - qwen2.5-code + - qwen2.5-coder +trigger_conditions: + app_types: + - static + - node + website_kinds: + - html + - nextjs +default_step: init-validate +required_vars: + - public_domain + - image_repository + - image_tag + - cloud_provider + - cloud_region + - cloud_size +transcript_rules: + default_path: docs/deployment-history.md + update_existing: true +safety_rules: + - Never invent Stacker commands or flags. + - Inspect before mutate and dry-run before deploy. + - Ask for missing secrets only when the current step truly needs them. + - Do not assume the image exists remotely until it has been pushed successfully. + - Remember that `stacker agent install` does not persist local config unless explicitly requested. +steps: + - id: init-validate + title: Validate generated stacker config and local artifacts + file: steps/01-init-validate.md + - id: image-publish + title: Build and publish the application image + file: steps/02-image-publish.md + - id: cloud-deploy + title: Deploy the stack to the cloud target + file: steps/03-cloud-deploy.md + - id: agent-firewall-dns-proxy + title: Install the agent and wire firewall, DNS, and proxy + file: steps/04-agent-firewall-dns-proxy.md + - id: runtime-ops + title: Verify runtime behavior and record the deployment story + file: steps/05-runtime-ops.md diff --git a/scenarios/qwen2.5-code/website-deploy/steps/01-init-validate.md b/scenarios/qwen2.5-code/website-deploy/steps/01-init-validate.md new file mode 100644 index 00000000..3859b50a --- /dev/null +++ b/scenarios/qwen2.5-code/website-deploy/steps/01-init-validate.md @@ -0,0 +1,12 @@ +You are continuing a website deployment workflow immediately after `stacker init --with-ai`. + +Focus only on these actions: +1. Inspect the generated `stacker.yml`, `.stacker/Dockerfile`, and `.stacker/docker-compose.yml`. +2. Confirm the detected app type and upstream port make sense for the project kind. +3. Point out only concrete fixes that are needed before publishing an image. +4. Tell the user the exact next Stacker or Docker commands to run. + +Guardrails: +- Do not jump straight to cloud deploy from this step. +- If a value is missing, ask for it explicitly instead of inventing it. +- Keep the answer procedural and command-focused. diff --git a/scenarios/qwen2.5-code/website-deploy/steps/02-image-publish.md b/scenarios/qwen2.5-code/website-deploy/steps/02-image-publish.md new file mode 100644 index 00000000..ee16f898 --- /dev/null +++ b/scenarios/qwen2.5-code/website-deploy/steps/02-image-publish.md @@ -0,0 +1,12 @@ +This step is about turning the local project into a remotely deployable image. + +Required behavior: +1. Verify the image repository and tag that should be produced. +2. Explain the exact build, login, and push commands needed for the chosen registry. +3. Refuse to proceed to remote deploy until the image push has completed successfully. +4. Mention any project-specific checks that should happen before push, such as build or local smoke tests. + +Guardrails: +- Do not assume the registry already contains the image. +- If the repository or tag is missing, ask for it. +- Keep the answer conservative and sequential. diff --git a/scenarios/qwen2.5-code/website-deploy/steps/03-cloud-deploy.md b/scenarios/qwen2.5-code/website-deploy/steps/03-cloud-deploy.md new file mode 100644 index 00000000..8b1e6f2c --- /dev/null +++ b/scenarios/qwen2.5-code/website-deploy/steps/03-cloud-deploy.md @@ -0,0 +1,12 @@ +This step is about deploying the already published image to the target cloud server. + +Required behavior: +1. Confirm that the image has already been pushed. +2. Use `stacker deploy --target cloud --dry-run` before any real deploy. +3. Use the configured cloud provider, region, and size from the scenario variables. +4. If SSL validation or provider setup causes a known temporary issue, explain the safest retry path rather than inventing a workaround. + +Guardrails: +- Do not skip the dry run. +- Do not silently rewrite local config for unrelated convenience. +- If deploy inputs are incomplete, stop and ask for them explicitly. diff --git a/scenarios/qwen2.5-code/website-deploy/steps/04-agent-firewall-dns-proxy.md b/scenarios/qwen2.5-code/website-deploy/steps/04-agent-firewall-dns-proxy.md new file mode 100644 index 00000000..b1747de7 --- /dev/null +++ b/scenarios/qwen2.5-code/website-deploy/steps/04-agent-firewall-dns-proxy.md @@ -0,0 +1,12 @@ +This step is about making the remote deployment reachable and operable. + +Required behavior: +1. Guide the user through `stacker agent install` without implying that local config is persisted by default. +2. Explain firewall openings and DNS records required for the application and proxy. +3. When reverse proxy configuration is needed, keep Nginx Proxy Manager runtime targeting in mind. +4. Prefer service DNS names for container-to-container traffic on the server instead of loopback addresses. + +Guardrails: +- Do not claim that `stacker agent install` changes `stacker.yml` unless `--persist-config` is explicitly chosen. +- Nginx Proxy Manager runtime access should use `http://nginx-proxy-manager:81`. +- For remote service traffic, prefer names like `smtp:25` rather than `127.0.0.1`. diff --git a/scenarios/qwen2.5-code/website-deploy/steps/05-runtime-ops.md b/scenarios/qwen2.5-code/website-deploy/steps/05-runtime-ops.md new file mode 100644 index 00000000..2fe6cc92 --- /dev/null +++ b/scenarios/qwen2.5-code/website-deploy/steps/05-runtime-ops.md @@ -0,0 +1,12 @@ +This step is about post-deploy inspection, troubleshooting, and recording the workflow. + +Required behavior: +1. Use read-only Stacker inspection commands before suggesting any change. +2. Check runtime status, logs, DNS, and proxy health in a disciplined order. +3. Update an existing deployment-history-style document when present, or create `docs/deployment-history.md` when the project has no transcript yet. +4. End with the next smallest safe action instead of a broad checklist. + +Guardrails: +- Avoid speculative fixes. +- If the issue is unclear, ask for the specific command output that is missing. +- Keep the transcript factual and tied to commands that were actually run. diff --git a/src/bin/agent_executor.rs b/src/bin/agent_executor.rs index 24749764..4953053c 100644 --- a/src/bin/agent_executor.rs +++ b/src/bin/agent_executor.rs @@ -19,9 +19,7 @@ use tokio::signal; use tokio::sync::Notify; use tracing::{error, info}; -use stacker::models::agent_protocol::{ - routing, RetryPolicy, StepCommand, StepResultMsg, StepStatus, -}; +use stacker::models::agent_protocol::{routing, StepCommand, StepResultMsg, StepStatus}; use stacker::services::resilience_engine::{ execute_with_resilience, CircuitBreakerConfig, InMemoryCircuitBreaker, }; @@ -227,7 +225,7 @@ async fn process_step( ); // Execute with resilience (retry + backoff + circuit breaker) - let retry_policy = cmd.retry_policy.clone().unwrap_or(RetryPolicy::default()); + let retry_policy = cmd.retry_policy.clone().unwrap_or_default(); let mut cb = circuit_breaker.lock().await; let result = execute_with_resilience( diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index afd9ce21..f404d882 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -67,6 +67,15 @@ enum StackerCommands { /// Stacker API base URL (or set STACKER_URL) #[arg(long = "server-url", visible_alias = "api-url")] server_url: Option, + /// Authenticate via browser OAuth2 flow (opens a sign-in URL) + #[arg(long)] + browser: bool, + /// OAuth provider code for browser login: gc (Google), gh (GitHub), … (default: gc) + #[arg(long, value_name = "PROVIDER")] + provider: Option, + /// Log in with username/password instead of browser OAuth (skips browser flow) + #[arg(short = 'u', long, value_name = "EMAIL")] + user: Option, }, /// Show the saved login and current project's recorded deploy identity Whoami {}, @@ -99,6 +108,11 @@ enum StackerCommands { }, /// Build & deploy the stack Deploy { + /// Service name for surgical single-service deploy (e.g. `stacker deploy stacker-website`). + /// Reads local docker-compose.yml, injects the service into the remote compose, and + /// starts only that container — other running services are not touched. + #[arg(value_name = "SERVICE")] + service: Option, /// Deployment target: local, cloud, server #[arg(long, value_name = "TARGET")] target: Option, @@ -141,6 +155,12 @@ enum StackerCommands { /// Container runtime: "runc" (default) or "kata" for hardware-isolated containers #[arg(long, value_name = "RUNTIME", default_value = "runc")] runtime: String, + /// Print a read-only deployment plan instead of applying changes + #[arg(long)] + plan: bool, + /// Revalidate and apply a previously generated deployment plan fingerprint + #[arg(long, value_name = "FINGERPRINT", conflicts_with = "plan")] + apply_plan: Option, }, /// Attach this directory to an existing deployment from the dashboard Connect { @@ -193,6 +213,16 @@ enum StackerCommands { #[arg(long)] watch: bool, }, + /// Deployment inspection commands + Deployment { + #[command(subcommand)] + command: DeploymentCommands, + }, + /// Explain path and topology decisions + Explain { + #[command(subcommand)] + command: ExplainCommands, + }, /// Tear down the deployed stack Destroy { /// Also remove named volumes @@ -536,6 +566,64 @@ enum ServiceCommands { #[arg(long, value_name = "FILE")] file: Option, }, + /// Import custom services from a local Docker Compose file after a safety review + Import { + /// Target custom service name for a single selected service + name: String, + /// Local Docker Compose file to review and import + #[arg(long, value_name = "PATH")] + from_compose: Option, + /// Planned future source; currently returns a safe not-yet-implemented error + #[arg(long, value_name = "OWNER/REPO")] + from_github: Option, + /// Planned future source; currently returns a safe not-yet-implemented error + #[arg(long, value_name = "URL")] + from_url: Option, + /// Compose service name to import. Omit to import all image-backed services. + #[arg(long, value_name = "COMPOSE_SERVICE")] + service: Option, + /// Rename imported services as old=new. Repeat for multiple services. + #[arg(long, value_name = "OLD=NEW")] + rename: Vec, + /// Path to stacker.yml (default: ./stacker.yml) + #[arg(long, value_name = "FILE")] + file: Option, + /// Review only; do not write stacker.yml + #[arg(long)] + review: bool, + /// Skip confirmation prompt and write after review + #[arg(long, short = 'y')] + yes: bool, + /// Output structured JSON with secret-like environment values redacted + #[arg(long)] + json: bool, + }, + /// Deploy/update a configured service through the remote app deploy path + Deploy { + /// Service name from stacker.yml to deploy + name: String, + /// Force recreate the remote container + #[arg(long)] + force: bool, + /// Container runtime: "runc" (default) or "kata" + #[arg(long, default_value = "runc")] + runtime: String, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + /// Deploy environment/profile, e.g. local, dev, prod + #[arg(long = "env", alias = "environment", value_name = "ENVIRONMENT")] + environment: Option, + /// Print a read-only deploy-app plan instead of applying changes + #[arg(long)] + plan: bool, + /// Revalidate and apply a previously generated deploy-app plan fingerprint + #[arg(long, value_name = "FINGERPRINT", conflicts_with = "plan")] + apply_plan: Option, + }, /// Remove a service from stacker.yml Remove { /// Service name to remove @@ -552,6 +640,64 @@ enum ServiceCommands { }, } +#[derive(Debug, Subcommand)] +enum DeploymentCommands { + /// Show canonical deployment state + State { + /// Output in JSON format + #[arg(long)] + json: bool, + /// Override deployment hash instead of using stacker.yml + #[arg(long)] + deployment: Option, + }, + /// Show structured deployment events + Events { + /// Output in JSON format + #[arg(long)] + json: bool, + /// Override deployment hash instead of using stacker.yml + #[arg(long)] + deployment: Option, + }, + /// Preview or apply a deployment rollback + Rollback { + /// Roll back to `previous` or a specific marketplace template version + #[arg(long, value_name = "TARGET")] + to: String, + /// Print a read-only rollback plan instead of applying it + #[arg(long, conflicts_with = "apply_plan")] + plan: bool, + /// Revalidate and apply a previously generated rollback plan fingerprint + #[arg(long, value_name = "FINGERPRINT", conflicts_with = "plan")] + apply_plan: Option, + /// Override deployment hash instead of using stacker.yml + #[arg(long)] + deployment: Option, + /// Confirm rollback apply + #[arg(long, short = 'y')] + confirm: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum ExplainCommands { + /// Explain env provenance for an app or service + Env { + /// App code or service name + app: String, + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// Explain compose/env topology for the current target + Topology { + /// Output in JSON format + #[arg(long)] + json: bool, + }, +} + #[derive(Debug, Subcommand)] enum ConfigCommands { /// Validate stacker.yml syntax and semantics @@ -601,6 +747,26 @@ enum ConfigSetupCommands { #[arg(long, value_name = "FILE")] file: Option, }, + /// Configure AI defaults in stacker.yml + Ai { + #[arg(long, value_name = "FILE")] + file: Option, + /// AI provider: openai, anthropic, ollama, custom + #[arg(long, value_name = "PROVIDER")] + provider: Option, + /// AI endpoint, e.g. http://localhost:11434 for Ollama + #[arg(long, value_name = "URL")] + endpoint: Option, + /// AI model name, e.g. llama3.1 + #[arg(long, value_name = "MODEL")] + model: Option, + /// AI request timeout in seconds + #[arg(long, value_name = "SECONDS")] + timeout: Option, + /// AI task name. Repeat or use comma-separated values. + #[arg(long = "task", value_name = "TASK")] + tasks: Vec, + }, /// Advanced/debug: generate remote orchestrator payload and wire stacker.yml RemotePayload { #[arg(long, value_name = "FILE")] @@ -623,7 +789,10 @@ enum SecretsCommands { Use the target code listed by `stacker secrets apps` for --service.\n\ \n\ Remote server secret from stdin:\n\ - cat token.txt | stacker secrets set NPM_TOKEN --scope server --server-id 42")] + cat token.txt | stacker secrets set NPM_TOKEN --scope server --server-id 42\n\ +\n\ + Status Panel Nginx Proxy Manager credentials from a JSON file:\n\ + stacker secrets set npm_credentials --scope server --server-id 42 --body-file ./npm_credentials.json")] Set { /// Local mode: KEY=VALUE. Remote mode: secret name. input: String, @@ -898,7 +1067,7 @@ enum PipeCommands { /// Narrow the remote app scan to a specific container #[arg(long, requires = "app")] container: Option, - /// Protocols to probe (default: openapi,rest) + /// Protocols to probe (default: openapi,html_forms,rest) #[arg(long, value_delimiter = ',')] protocols: Vec, /// Capture sample responses from discovered endpoints @@ -1093,6 +1262,12 @@ enum AgentCommands { /// Deploy environment/profile, e.g. local, dev, prod #[arg(long = "env", alias = "environment", value_name = "ENVIRONMENT")] environment: Option, + /// Print a read-only deploy-app plan instead of applying changes + #[arg(long)] + plan: bool, + /// Revalidate and apply a previously generated deploy-app plan fingerprint + #[arg(long, value_name = "FINGERPRINT", conflicts_with = "plan")] + apply_plan: Option, }, /// Remove an app container from the remote deployment #[command(name = "remove-app")] @@ -1157,10 +1332,10 @@ enum AgentCommands { /// Port to forward to #[arg(long)] port: u16, - /// Enable SSL/Let's Encrypt certificate issuance - #[arg(long, default_value_t = true)] + /// Enable SSL/Let's Encrypt certificate issuance (default: off; use --ssl to enable) + #[arg(long, default_value_t = false)] ssl: bool, - /// Disable SSL/Let's Encrypt and create a plain HTTP proxy host + /// Disable SSL/Let's Encrypt (no-op; SSL is off by default) #[arg(long = "no-ssl")] no_ssl: bool, /// Action: create, update, delete @@ -1222,6 +1397,9 @@ enum AgentCommands { /// Path to stacker.yml (default: ./stacker.yml) #[arg(long, value_name = "FILE")] file: Option, + /// Persist monitoring.status_panel=true back to the local stacker.yml + #[arg(long)] + persist_config: bool, /// Output in JSON format #[arg(long)] json: bool, @@ -1262,6 +1440,12 @@ struct AiArgs { /// Requires a tool-capable model (Ollama: llama3.1/qwen2.5-coder, OpenAI: any). #[arg(long)] write: bool, + /// Activate a built-in AI scenario such as `website-deploy`. + #[arg(long, global = true)] + scenario: Option, + /// Select the active scenario step such as `init-validate` or `cloud-deploy`. + #[arg(long, global = true)] + step: Option, } #[derive(Debug, Subcommand)] @@ -1534,8 +1718,11 @@ fn get_command( domain, auth_url, server_url, + browser, + provider, + user, } => Box::new(stacker::console::commands::cli::login::LoginCommand::new( - org, domain, auth_url, server_url, + org, domain, auth_url, server_url, browser, provider, user, )), StackerCommands::Whoami {} => { Box::new(stacker::console::commands::cli::whoami::WhoamiCommand::new()) @@ -1565,6 +1752,7 @@ fn get_command( ) } StackerCommands::Deploy { + service, target, environment, file, @@ -1579,6 +1767,8 @@ fn get_command( lock, force_new, runtime, + plan, + apply_plan, } => Box::new( stacker::console::commands::cli::deploy::DeployCommand::new( target, @@ -1586,13 +1776,16 @@ fn get_command( dry_run, force_rebuild, ) + .with_service(service) .with_environment(environment) .with_remote_overrides(project, key, server) .with_key_id(key_id) .with_watch(watch, no_watch) .with_lock(lock) .with_force_new(force_new) - .with_runtime(runtime), + .with_runtime(runtime) + .with_plan(plan) + .with_apply_plan(apply_plan), ), StackerCommands::Connect { handoff } => { Box::new(stacker::console::commands::cli::connect::ConnectCommand::new(handoff)) @@ -1608,6 +1801,37 @@ fn get_command( StackerCommands::Status { json, watch } => Box::new( stacker::console::commands::cli::status::StatusCommand::new(json, watch), ), + StackerCommands::Deployment { command } => match command { + DeploymentCommands::State { json, deployment } => Box::new( + stacker::console::commands::cli::deployment::DeploymentStateCommand::new( + json, deployment, + ), + ), + DeploymentCommands::Events { json, deployment } => Box::new( + stacker::console::commands::cli::deployment::DeploymentEventsCommand::new( + json, deployment, + ), + ), + DeploymentCommands::Rollback { + to, + plan, + apply_plan, + deployment, + confirm, + } => Box::new( + stacker::console::commands::cli::deployment::DeploymentRollbackCommand::new( + to, plan, apply_plan, confirm, deployment, + ), + ), + }, + StackerCommands::Explain { command } => match command { + ExplainCommands::Env { app, json } => Box::new( + stacker::console::commands::cli::explain::ExplainEnvCommand::new(app, json), + ), + ExplainCommands::Topology { json } => Box::new( + stacker::console::commands::cli::explain::ExplainTopologyCommand::new(json), + ), + }, StackerCommands::Destroy { volumes, confirm } => Box::new( stacker::console::commands::cli::destroy::DestroyCommand::new(volumes, confirm), ), @@ -1637,6 +1861,18 @@ fn get_command( ConfigSetupCommands::Cloud { file } => Box::new( stacker::console::commands::cli::config::ConfigSetupCloudCommand::new(file), ), + ConfigSetupCommands::Ai { + file, + provider, + endpoint, + model, + timeout, + tasks, + } => Box::new( + stacker::console::commands::cli::config::ConfigSetupAiCommand::new( + file, provider, endpoint, model, timeout, tasks, + ), + ), ConfigSetupCommands::RemotePayload { file, out } => Box::new( stacker::console::commands::cli::config::ConfigSetupRemotePayloadCommand::new( file, out, @@ -1647,6 +1883,8 @@ fn get_command( StackerCommands::Ai(ai_args) => match ai_args.command { None => Box::new(stacker::console::commands::cli::ai::AiChatCommand::new( ai_args.write, + ai_args.scenario, + ai_args.step, )), Some(AiCommands::Ask { question, @@ -1656,6 +1894,7 @@ fn get_command( }) => Box::new( stacker::console::commands::cli::ai::AiAskCommand::new(question, context) .with_configure(configure) + .with_scenario(ai_args.scenario, ai_args.step) .with_write(ai_args.write || write), ), }, @@ -1665,7 +1904,9 @@ fn get_command( upstream, ssl, } => Box::new( - stacker::console::commands::cli::proxy::ProxyAddCommand::new(domain, upstream, ssl), + stacker::console::commands::cli::proxy::ProxyAddCommand::new( + domain, upstream, ssl, false, false, None, + ), ), ProxyCommands::Detect { json, deployment } => Box::new( stacker::console::commands::cli::proxy::ProxyDetectCommand::new(json, deployment), @@ -1729,6 +1970,52 @@ fn get_command( ServiceCommands::Add { name, file } => Box::new( stacker::console::commands::cli::service::ServiceAddCommand::new(name, file), ), + ServiceCommands::Import { + name, + from_compose, + from_github, + from_url, + service, + rename, + file, + review, + yes, + json, + } => Box::new( + stacker::console::commands::cli::service::ServiceImportCommand::new( + name, + from_compose, + from_github, + from_url, + service, + rename, + file, + review, + yes, + json, + ), + ), + ServiceCommands::Deploy { + name, + force, + runtime, + json, + deployment, + environment, + plan, + apply_plan, + } => Box::new( + stacker::console::commands::cli::service::ServiceDeployCommand::new( + name, + force, + runtime, + json, + deployment, + environment, + plan, + apply_plan, + ), + ), ServiceCommands::Remove { name, file } => Box::new( stacker::console::commands::cli::service::ServiceRemoveCommand::new(name, file), ), @@ -2046,15 +2333,21 @@ fn get_command( json, deployment, environment, - } => Box::new(agent::AgentDeployAppCommand::new( - app, - image, - force, - runtime, - json, - deployment, - environment, - )), + plan, + apply_plan, + } => Box::new( + agent::AgentDeployAppCommand::new( + app, + image, + force, + runtime, + json, + deployment, + environment, + ) + .with_plan(plan) + .with_apply_plan(apply_plan), + ), AgentCommands::RemoveApp { app, volumes, @@ -2133,9 +2426,11 @@ fn get_command( json, deployment, )), - AgentCommands::Install { file, json } => { - Box::new(agent::AgentInstallCommand::new(file, json)) - } + AgentCommands::Install { + file, + persist_config, + json, + } => Box::new(agent::AgentInstallCommand::new(file, persist_config, json)), } } StackerCommands::Cloud { command } => match command { @@ -2304,6 +2599,50 @@ mod tests { } } + #[test] + fn test_ai_ask_parses_scenario_flags() { + let cli = Cli::try_parse_from([ + "stacker", + "ai", + "ask", + "continue", + "--scenario", + "website-deploy", + "--step", + "cloud-deploy", + ]) + .unwrap(); + + match cli.command.unwrap() { + StackerCommands::Ai(ai_args) => { + assert_eq!(ai_args.scenario.as_deref(), Some("website-deploy")); + assert_eq!(ai_args.step.as_deref(), Some("cloud-deploy")); + } + _ => panic!("expected ai command"), + } + } + + #[test] + fn test_ai_chat_parses_scenario_flags() { + let cli = Cli::try_parse_from([ + "stacker", + "ai", + "--scenario", + "website-deploy", + "--step", + "init-validate", + ]) + .unwrap(); + + match cli.command.unwrap() { + StackerCommands::Ai(ai_args) => { + assert_eq!(ai_args.scenario.as_deref(), Some("website-deploy")); + assert_eq!(ai_args.step.as_deref(), Some("init-validate")); + } + _ => panic!("expected ai command"), + } + } + #[test] fn test_pipe_scan_parses_without_selector() { let cli = Cli::try_parse_from(["stacker", "pipe", "scan"]).unwrap(); diff --git a/src/cli/ai_scenarios.rs b/src/cli/ai_scenarios.rs new file mode 100644 index 00000000..81ff1983 --- /dev/null +++ b/src/cli/ai_scenarios.rs @@ -0,0 +1,813 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde::{Deserialize, Serialize}; + +use crate::cli::config_parser::{AiConfig, AiProviderType, AppType, StackerConfig}; +use crate::cli::error::CliError; + +pub const WEBSITE_DEPLOY_SCENARIO: &str = "website-deploy"; +const SCENARIO_PROVIDER_DIR: &str = "qwen2.5-code"; + +const WEBSITE_DEPLOY_MANIFEST: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scenarios/qwen2.5-code/website-deploy/scenario.yaml" +)); +const WEBSITE_DEPLOY_STEP_INIT_VALIDATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scenarios/qwen2.5-code/website-deploy/steps/01-init-validate.md" +)); +const WEBSITE_DEPLOY_STEP_IMAGE_PUBLISH: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scenarios/qwen2.5-code/website-deploy/steps/02-image-publish.md" +)); +const WEBSITE_DEPLOY_STEP_CLOUD_DEPLOY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scenarios/qwen2.5-code/website-deploy/steps/03-cloud-deploy.md" +)); +const WEBSITE_DEPLOY_STEP_AGENT_PROXY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scenarios/qwen2.5-code/website-deploy/steps/04-agent-firewall-dns-proxy.md" +)); +const WEBSITE_DEPLOY_STEP_RUNTIME_OPS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scenarios/qwen2.5-code/website-deploy/steps/05-runtime-ops.md" +)); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScenarioSelection { + pub name: String, + pub step: Option, +} + +impl ScenarioSelection { + pub fn new(name: impl Into, step: Option) -> Self { + Self { + name: name.into(), + step, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScenarioManifest { + pub name: String, + pub description: String, + pub model_match: ScenarioModelMatch, + pub trigger_conditions: ScenarioTriggerConditions, + pub default_step: String, + pub required_vars: Vec, + pub transcript_rules: ScenarioTranscriptRules, + pub safety_rules: Vec, + pub steps: Vec, +} + +impl ScenarioManifest { + pub fn step(&self, step_id: &str) -> Option<&ScenarioStep> { + self.steps.iter().find(|step| step.id == step_id) + } + + pub fn next_step_after(&self, step_id: &str) -> Option { + let index = self.steps.iter().position(|step| step.id == step_id)?; + self.steps.get(index + 1).map(|step| step.id.clone()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScenarioModelMatch { + pub provider: String, + pub name_contains: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScenarioTriggerConditions { + pub app_types: Vec, + pub website_kinds: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScenarioTranscriptRules { + pub default_path: String, + pub update_existing: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScenarioStep { + pub id: String, + pub title: String, + pub file: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScenarioState { + pub scenario_name: String, + pub current_step: String, + #[serde(default)] + pub vars: BTreeMap, +} + +impl ScenarioState { + pub fn new(scenario_name: impl Into, current_step: impl Into) -> Self { + Self { + scenario_name: scenario_name.into(), + current_step: current_step.into(), + vars: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WebsiteProjectKind { + Html, + NextJs, +} + +impl WebsiteProjectKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Html => "html", + Self::NextJs => "nextjs", + } + } + + pub fn display_name(&self) -> &'static str { + match self { + Self::Html => "HTML/static website", + Self::NextJs => "Next.js website", + } + } +} + +#[derive(Debug, Clone)] +pub struct ScenarioPromptContext { + pub manifest: ScenarioManifest, + pub step_id: String, + pub step_title: String, + pub next_step_id: Option, + pub rendered_prompt: String, + pub state: Option, +} + +pub fn is_qwen_website_scenario_model(ai_config: &AiConfig) -> bool { + if ai_config.provider != AiProviderType::Ollama { + return false; + } + + ai_config + .model + .as_deref() + .map(|model| { + let normalized = model.to_ascii_lowercase(); + normalized.contains("qwen2.5-code") || normalized.contains("qwen2.5-coder") + }) + .unwrap_or(false) +} + +pub fn detect_website_project_kind( + project_dir: &Path, + config: &StackerConfig, +) -> Option { + match config.app.app_type { + AppType::Static => Some(WebsiteProjectKind::Html), + AppType::Node => { + if has_nextjs_markers(project_dir) { + Some(WebsiteProjectKind::NextJs) + } else { + None + } + } + _ => None, + } +} + +pub fn load_scenario_manifest( + project_dir: &Path, + scenario_name: &str, +) -> Result { + let local_manifest_path = local_scenario_dir(project_dir, scenario_name).join("scenario.yaml"); + let manifest_text = if local_manifest_path.exists() { + std::fs::read_to_string(&local_manifest_path)? + } else { + builtin_manifest_text(scenario_name)?.to_string() + }; + + serde_yaml::from_str(&manifest_text).map_err(|error| { + CliError::ConfigValidation(format!( + "Failed to parse AI scenario manifest '{}': {}", + scenario_name, error + )) + }) +} + +pub fn missing_required_vars(manifest: &ScenarioManifest, state: &ScenarioState) -> Vec { + manifest + .required_vars + .iter() + .filter(|key| { + state + .vars + .get((*key).as_str()) + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + }) + .cloned() + .collect() +} + +pub fn scenario_state_path(project_dir: &Path, scenario_name: &str) -> PathBuf { + local_scenario_dir(project_dir, scenario_name).join("state.json") +} + +pub fn load_scenario_state( + project_dir: &Path, + scenario_name: &str, +) -> Result, CliError> { + let state_path = scenario_state_path(project_dir, scenario_name); + if !state_path.exists() { + return Ok(None); + } + + let contents = std::fs::read_to_string(&state_path)?; + let state = serde_json::from_str(&contents).map_err(|error| { + CliError::ConfigValidation(format!( + "Failed to parse AI scenario state '{}': {}", + state_path.display(), + error + )) + })?; + + Ok(Some(state)) +} + +pub fn save_scenario_state(project_dir: &Path, state: &ScenarioState) -> Result { + let state_path = scenario_state_path(project_dir, &state.scenario_name); + if let Some(parent) = state_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = serde_json::to_string_pretty(state).map_err(|error| { + CliError::ConfigValidation(format!("Failed to serialize AI scenario state: {}", error)) + })?; + std::fs::write(&state_path, content)?; + + Ok(state_path) +} + +pub fn seed_website_scenario_state( + project_dir: &Path, + config_path: &Path, + config: &StackerConfig, + ai_config: &AiConfig, + project_kind: &WebsiteProjectKind, +) -> ScenarioState { + let mut state = ScenarioState::new(WEBSITE_DEPLOY_SCENARIO, "init-validate"); + + insert_var(&mut state.vars, "project_name", Some(config.name.clone())); + insert_var( + &mut state.vars, + "project_identity", + config.project.identity.clone(), + ); + insert_var( + &mut state.vars, + "project_kind", + Some(project_kind.display_name().to_string()), + ); + insert_var( + &mut state.vars, + "app_type", + Some(config.app.app_type.to_string()), + ); + insert_var( + &mut state.vars, + "app_path", + Some(config.app.path.to_string_lossy().to_string()), + ); + insert_var( + &mut state.vars, + "config_path", + Some( + config_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "stacker.yml".to_string()), + ), + ); + insert_var( + &mut state.vars, + "compose_file", + config + .deploy + .compose_file + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + ); + insert_var( + &mut state.vars, + "proxy_type", + (config.proxy.proxy_type != crate::cli::config_parser::ProxyType::None) + .then(|| config.proxy.proxy_type.to_string()), + ); + insert_var( + &mut state.vars, + "status_panel_enabled", + Some(config.monitoring.status_panel.to_string()), + ); + insert_var( + &mut state.vars, + "ai_provider", + Some(ai_config.provider.to_string()), + ); + insert_var(&mut state.vars, "ai_model", ai_config.model.clone()); + insert_var(&mut state.vars, "ai_endpoint", ai_config.endpoint.clone()); + insert_var( + &mut state.vars, + "repo_url", + detect_git_remote_url(project_dir), + ); + + if let Some(domain) = primary_public_domain(config) { + insert_var(&mut state.vars, "public_domain", Some(domain)); + } + + if let Some(image) = &config.app.image { + let (repository, tag) = split_image_reference(image); + insert_var(&mut state.vars, "image_repository", Some(repository)); + insert_var(&mut state.vars, "image_tag", tag); + } else if let Some(repo_url) = state.vars.get("repo_url").cloned() { + insert_var( + &mut state.vars, + "image_repository", + derive_image_repository_from_repo_url(&repo_url), + ); + } + + if let Some(cloud) = &config.deploy.cloud { + insert_var( + &mut state.vars, + "cloud_provider", + Some(cloud.provider.to_string()), + ); + insert_var(&mut state.vars, "cloud_region", cloud.region.clone()); + insert_var(&mut state.vars, "cloud_size", cloud.size.clone()); + } + + state +} + +pub fn load_scenario_prompt_context( + project_dir: &Path, + ai_config: &AiConfig, + selection: &ScenarioSelection, +) -> Result { + let manifest = load_scenario_manifest(project_dir, &selection.name)?; + ensure_model_matches(ai_config, &manifest)?; + + let state = load_scenario_state(project_dir, &selection.name)?; + let step_id = selection + .step + .clone() + .or_else(|| { + state + .as_ref() + .map(|saved| saved.current_step.clone()) + .filter(|step| !step.trim().is_empty()) + }) + .unwrap_or_else(|| manifest.default_step.clone()); + let step = manifest.step(&step_id).cloned().ok_or_else(|| { + CliError::ConfigValidation(format!( + "Unknown AI scenario step '{}' for scenario '{}'", + step_id, manifest.name + )) + })?; + let step_markdown = load_step_markdown(project_dir, &manifest, &step)?; + let vars_yaml = state + .as_ref() + .map(|saved| scenario_vars_yaml(&saved.vars)) + .unwrap_or_else(|| "(none saved yet)".to_string()); + let next_step_id = manifest.next_step_after(&step_id); + let safety_rules = manifest + .safety_rules + .iter() + .map(|rule| format!("- {}", rule)) + .collect::>() + .join("\n"); + let rendered_prompt = format!( + "## Active deployment scenario\n\ +Scenario: {scenario}\n\ +Description: {description}\n\ +Current step: {step_id} — {step_title}\n\ +Next step hint: {next_step}\n\ +Transcript path: {transcript}\n\ +\n\ +Scenario variables:\n\ +```yaml\n\ +{vars_yaml}\n\ +```\n\ +\n\ +Safety rules:\n\ +{safety_rules}\n\ +\n\ +Step instructions:\n\ +{step_markdown}", + scenario = manifest.name, + description = manifest.description, + step_id = step.id, + step_title = step.title, + next_step = next_step_id + .clone() + .unwrap_or_else(|| "(this is the final built-in step)".to_string()), + transcript = manifest.transcript_rules.default_path, + ); + + Ok(ScenarioPromptContext { + manifest, + step_id, + step_title: step.title.clone(), + next_step_id, + rendered_prompt, + state, + }) +} + +pub fn next_step_id( + project_dir: &Path, + scenario_name: &str, + current_step: &str, +) -> Result, CliError> { + let manifest = load_scenario_manifest(project_dir, scenario_name)?; + Ok(manifest.next_step_after(current_step)) +} + +fn ensure_model_matches(ai_config: &AiConfig, manifest: &ScenarioManifest) -> Result<(), CliError> { + let provider_matches = ai_config.provider.to_string() == manifest.model_match.provider; + let model_matches = ai_config + .model + .as_deref() + .map(|model| { + let normalized = model.to_ascii_lowercase(); + manifest + .model_match + .name_contains + .iter() + .any(|needle| normalized.contains(&needle.to_ascii_lowercase())) + }) + .unwrap_or(false); + + if provider_matches && model_matches { + Ok(()) + } else { + Err(CliError::ConfigValidation(format!( + "Scenario '{}' requires {} models containing one of: {}", + manifest.name, + manifest.model_match.provider, + manifest.model_match.name_contains.join(", ") + ))) + } +} + +fn has_nextjs_markers(project_dir: &Path) -> bool { + let direct_markers = [ + "next.config.js", + "next.config.mjs", + "next.config.ts", + "src/app/page.tsx", + "src/app/page.jsx", + "src/pages/index.tsx", + "src/pages/index.jsx", + "pages/index.tsx", + "pages/index.jsx", + ]; + if direct_markers + .iter() + .any(|path| project_dir.join(path).exists()) + { + return true; + } + + let package_json_path = project_dir.join("package.json"); + let package_json = match std::fs::read_to_string(package_json_path) { + Ok(content) => content, + Err(_) => return false, + }; + let parsed: serde_json::Value = match serde_json::from_str(&package_json) { + Ok(value) => value, + Err(_) => return false, + }; + + let has_next_dependency = parsed["dependencies"].get("next").is_some() + || parsed["devDependencies"].get("next").is_some(); + let has_next_script = parsed["scripts"] + .as_object() + .map(|scripts| { + scripts.values().any(|value| { + value + .as_str() + .map(|script| script.contains("next ")) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + has_next_dependency || has_next_script +} + +fn local_scenario_dir(project_dir: &Path, scenario_name: &str) -> PathBuf { + project_dir + .join(".stacker") + .join("scenarios") + .join(SCENARIO_PROVIDER_DIR) + .join(scenario_name) +} + +fn builtin_manifest_text(scenario_name: &str) -> Result<&'static str, CliError> { + match scenario_name { + WEBSITE_DEPLOY_SCENARIO => Ok(WEBSITE_DEPLOY_MANIFEST), + other => Err(CliError::ConfigValidation(format!( + "Unknown built-in AI scenario '{}'", + other + ))), + } +} + +fn builtin_step_markdown(scenario_name: &str, file: &str) -> Result<&'static str, CliError> { + match (scenario_name, file) { + (WEBSITE_DEPLOY_SCENARIO, "steps/01-init-validate.md") => { + Ok(WEBSITE_DEPLOY_STEP_INIT_VALIDATE) + } + (WEBSITE_DEPLOY_SCENARIO, "steps/02-image-publish.md") => { + Ok(WEBSITE_DEPLOY_STEP_IMAGE_PUBLISH) + } + (WEBSITE_DEPLOY_SCENARIO, "steps/03-cloud-deploy.md") => { + Ok(WEBSITE_DEPLOY_STEP_CLOUD_DEPLOY) + } + (WEBSITE_DEPLOY_SCENARIO, "steps/04-agent-firewall-dns-proxy.md") => { + Ok(WEBSITE_DEPLOY_STEP_AGENT_PROXY) + } + (WEBSITE_DEPLOY_SCENARIO, "steps/05-runtime-ops.md") => Ok(WEBSITE_DEPLOY_STEP_RUNTIME_OPS), + _ => Err(CliError::ConfigValidation(format!( + "Unknown built-in AI scenario step file '{}'", + file + ))), + } +} + +fn load_step_markdown( + project_dir: &Path, + manifest: &ScenarioManifest, + step: &ScenarioStep, +) -> Result { + let local_path = local_scenario_dir(project_dir, &manifest.name).join(&step.file); + if local_path.exists() { + return Ok(std::fs::read_to_string(local_path)?); + } + + Ok(builtin_step_markdown(&manifest.name, &step.file)?.to_string()) +} + +fn insert_var(vars: &mut BTreeMap, key: &str, value: Option) { + if let Some(value) = value.map(|value| value.trim().to_string()) { + if !value.is_empty() { + vars.insert(key.to_string(), value); + } + } +} + +fn scenario_vars_yaml(vars: &BTreeMap) -> String { + if vars.is_empty() { + return "(none saved yet)".to_string(); + } + + serde_yaml::to_string(vars) + .unwrap_or_else(|_| "(failed to render variables)".to_string()) + .trim() + .to_string() +} + +fn detect_git_remote_url(project_dir: &Path) -> Option { + let output = Command::new("git") + .args(["config", "--get", "remote.origin.url"]) + .current_dir(project_dir) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let remote = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!remote.is_empty()).then_some(remote) +} + +fn primary_public_domain(config: &StackerConfig) -> Option { + config + .proxy + .domains + .iter() + .map(|domain| domain.domain.trim()) + .find(|domain| !domain.is_empty() && !is_placeholder_domain(domain)) + .map(ToOwned::to_owned) +} + +fn is_placeholder_domain(domain: &str) -> bool { + domain.ends_with(".localhost") || domain.contains("example.com") +} + +fn derive_image_repository_from_repo_url(repo_url: &str) -> Option { + let remote = repo_url.trim().trim_end_matches(".git"); + let path = if let Some(path) = remote.strip_prefix("git@github.com:") { + path + } else if let Some(path) = remote.strip_prefix("https://github.com/") { + path + } else if let Some(path) = remote.strip_prefix("ssh://git@github.com/") { + path + } else { + return None; + }; + + let mut segments = path.split('/'); + let owner = segments.next()?.trim(); + let repo = segments.next()?.trim(); + if owner.is_empty() || repo.is_empty() { + return None; + } + + Some(format!("ghcr.io/{owner}/{repo}")) +} + +fn split_image_reference(image: &str) -> (String, Option) { + let last_slash = image.rfind('/'); + let last_colon = image.rfind(':'); + if let Some(colon_index) = last_colon { + if last_slash.map(|slash| colon_index > slash).unwrap_or(true) { + let repository = image[..colon_index].to_string(); + let tag = image[colon_index + 1..].trim().to_string(); + return (repository, (!tag.is_empty()).then_some(tag)); + } + } + + (image.to_string(), None) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::config_parser::{AiProviderType, ProxyType}; + + fn website_ai_config(model: &str) -> AiConfig { + AiConfig { + enabled: true, + provider: AiProviderType::Ollama, + model: Some(model.to_string()), + api_key: None, + endpoint: Some("http://localhost:11434".to_string()), + timeout: 300, + tasks: vec![], + } + } + + #[test] + fn test_detect_html_website_candidate() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("index.html"), "").unwrap(); + + let mut config = StackerConfig::default(); + config.app.app_type = AppType::Static; + + assert_eq!( + detect_website_project_kind(dir.path(), &config), + Some(WebsiteProjectKind::Html) + ); + } + + #[test] + fn test_detect_nextjs_website_candidate() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{"dependencies":{"next":"15.0.0"}}"#, + ) + .unwrap(); + + let mut config = StackerConfig::default(); + config.app.app_type = AppType::Node; + + assert_eq!( + detect_website_project_kind(dir.path(), &config), + Some(WebsiteProjectKind::NextJs) + ); + } + + #[test] + fn test_seed_website_scenario_state_derives_repo_image_and_cloud_values() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); + + let mut config = StackerConfig::default(); + config.name = "status-web".to_string(); + config.app.app_type = AppType::Static; + config.proxy.proxy_type = ProxyType::Nginx; + config + .proxy + .domains + .push(crate::cli::config_parser::DomainConfig { + domain: "status.try.direct".to_string(), + ssl: crate::cli::config_parser::SslMode::Auto, + upstream: "app:80".to_string(), + }); + config.deploy.cloud = Some(crate::cli::config_parser::CloudConfig { + provider: crate::cli::config_parser::CloudProvider::Hetzner, + orchestrator: crate::cli::config_parser::CloudOrchestrator::Remote, + region: Some("nbg1".to_string()), + size: Some("cpx11".to_string()), + install_image: None, + remote_payload_file: None, + ssh_key: None, + key: None, + server: None, + }); + + // Simulate a git remote through an existing state seed instead of shelling out in tests. + let ai_config = website_ai_config("qwen2.5-coder:latest"); + let mut state = seed_website_scenario_state( + dir.path(), + &dir.path().join("stacker.yml"), + &config, + &ai_config, + &WebsiteProjectKind::Html, + ); + state.vars.insert( + "repo_url".to_string(), + "https://github.com/trydirect/status-web.git".to_string(), + ); + if !state.vars.contains_key("image_repository") { + state.vars.insert( + "image_repository".to_string(), + derive_image_repository_from_repo_url(state.vars.get("repo_url").unwrap()).unwrap(), + ); + } + + assert_eq!( + state.vars.get("project_name").map(String::as_str), + Some("status-web") + ); + assert_eq!( + state.vars.get("public_domain").map(String::as_str), + Some("status.try.direct") + ); + assert_eq!( + state.vars.get("cloud_provider").map(String::as_str), + Some("hetzner") + ); + assert_eq!( + state.vars.get("cloud_region").map(String::as_str), + Some("nbg1") + ); + assert_eq!( + state.vars.get("image_repository").map(String::as_str), + Some("ghcr.io/trydirect/status-web") + ); + } + + #[test] + fn test_load_scenario_prompt_context_uses_local_override_step() { + let dir = tempfile::TempDir::new().unwrap(); + let scenario_dir = local_scenario_dir(dir.path(), WEBSITE_DEPLOY_SCENARIO); + std::fs::create_dir_all(scenario_dir.join("steps")).unwrap(); + std::fs::write( + scenario_dir.join("steps/01-init-validate.md"), + "Local override step content", + ) + .unwrap(); + save_scenario_state( + dir.path(), + &ScenarioState::new(WEBSITE_DEPLOY_SCENARIO, "init-validate"), + ) + .unwrap(); + + let context = load_scenario_prompt_context( + dir.path(), + &website_ai_config("qwen2.5-code:latest"), + &ScenarioSelection::new(WEBSITE_DEPLOY_SCENARIO, Some("init-validate".to_string())), + ) + .unwrap(); + + assert!(context + .rendered_prompt + .contains("Local override step content")); + assert_eq!(context.step_id, "init-validate"); + } + + #[test] + fn test_missing_required_vars_reports_absent_values() { + let manifest = load_scenario_manifest(Path::new("."), WEBSITE_DEPLOY_SCENARIO).unwrap(); + let mut state = ScenarioState::new(WEBSITE_DEPLOY_SCENARIO, "init-validate"); + state + .vars + .insert("image_tag".to_string(), "latest".to_string()); + + let missing = missing_required_vars(&manifest, &state); + assert!(missing.contains(&"public_domain".to_string())); + assert!(!missing.contains(&"image_tag".to_string())); + } +} diff --git a/src/cli/compose_service_sync.rs b/src/cli/compose_service_sync.rs new file mode 100644 index 00000000..0daaa2d4 --- /dev/null +++ b/src/cli/compose_service_sync.rs @@ -0,0 +1,656 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::cli::config_parser::{ProxyConfig, ProxyType, ServiceDefinition, StackerConfig}; +use crate::cli::error::CliError; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ComposeServiceSyncResult { + pub compose_path: Option, + pub backup_path: Option, + pub updated_services: Vec, +} + +/// Extract the service name from an upstream string like `svc:3000` or `http://svc:3000`. +pub fn upstream_service_name(upstream: &str) -> Option { + let s = upstream + .trim_start_matches("https://") + .trim_start_matches("http://"); + let host = s.split('/').next()?; + let name = host.split(':').next()?; + if name.is_empty() { + None + } else { + Some(name.to_string()) + } +} + +/// Inject `default_network` into `service_name` inside `compose_doc` when the service is +/// listed as an NginxProxyManager upstream. Declares the network as `external: true` at +/// the top level. Returns `true` if the document was modified. +pub fn inject_npm_proxy_network( + compose_doc: &mut serde_yaml::Value, + service_name: &str, + proxy: &ProxyConfig, +) -> bool { + if proxy.proxy_type != ProxyType::NginxProxyManager { + return false; + } + let is_proxied = proxy.domains.iter().any(|d| { + upstream_service_name(&d.upstream) + .map(|n| n == service_name) + .unwrap_or(false) + }); + if !is_proxied { + return false; + } + inject_external_network(compose_doc, service_name, "default_network") +} + +fn inject_external_network( + compose_doc: &mut serde_yaml::Value, + service_name: &str, + network: &str, +) -> bool { + let mut changed = false; + let network_val = serde_yaml::Value::String(network.to_string()); + + if let Some(svc) = compose_doc + .get_mut("services") + .and_then(|s| s.get_mut(service_name)) + .and_then(serde_yaml::Value::as_mapping_mut) + { + let networks_key = serde_yaml::Value::String("networks".to_string()); + match svc.get_mut(&networks_key) { + Some(serde_yaml::Value::Sequence(seq)) => { + if !seq.contains(&network_val) { + seq.push(network_val); + changed = true; + } + } + None => { + svc.insert( + networks_key, + serde_yaml::Value::Sequence(vec![network_val]), + ); + changed = true; + } + _ => {} + } + } + + if changed { + upsert_external_network(compose_doc, network); + } + changed +} + +fn upsert_external_network(compose_doc: &mut serde_yaml::Value, network: &str) { + let Some(root) = compose_doc.as_mapping_mut() else { + return; + }; + let networks_key = serde_yaml::Value::String("networks".to_string()); + if !root.contains_key(&networks_key) { + root.insert( + networks_key.clone(), + serde_yaml::Value::Mapping(Default::default()), + ); + } + if let Some(top_networks) = root + .get_mut(&networks_key) + .and_then(serde_yaml::Value::as_mapping_mut) + { + let net_key = serde_yaml::Value::String(network.to_string()); + if !top_networks.contains_key(&net_key) { + let mut net_config = serde_yaml::Mapping::new(); + net_config.insert( + serde_yaml::Value::String("external".to_string()), + serde_yaml::Value::Bool(true), + ); + top_networks.insert(net_key, serde_yaml::Value::Mapping(net_config)); + } + } +} + +pub fn sync_configured_compose_services( + project_dir: &Path, + config: &StackerConfig, + service_names: &[String], +) -> Result { + let Some(compose_file) = config.deploy.compose_file.as_ref() else { + return Ok(ComposeServiceSyncResult::default()); + }; + if service_names.is_empty() { + return Ok(ComposeServiceSyncResult { + compose_path: Some(resolve_path(project_dir, compose_file)), + ..Default::default() + }); + } + + let compose_path = resolve_path(project_dir, compose_file); + if !compose_path.exists() { + return Err(CliError::ConfigValidation(format!( + "Configured compose file does not exist: {}", + compose_path.display() + ))); + } + + let original = std::fs::read_to_string(&compose_path)?; + let mut compose_doc: serde_yaml::Value = serde_yaml::from_str(&original)?; + let project_networks = project_service_networks(&compose_doc); + let mut updated_services = Vec::new(); + + for service_name in service_names { + let service = config + .services + .iter() + .find(|service| service.name == *service_name) + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "Service '{}' was not found in stacker.yml", + service_name + )) + })?; + + let mut svc_networks = project_networks.clone(); + if config.proxy.proxy_type == ProxyType::NginxProxyManager + && !svc_networks.contains(&"default_network".to_string()) + && config.proxy.domains.iter().any(|d| { + upstream_service_name(&d.upstream) + .map(|n| n == *service_name) + .unwrap_or(false) + }) + { + svc_networks.push("default_network".to_string()); + upsert_external_network(&mut compose_doc, "default_network"); + } + + upsert_compose_service(&mut compose_doc, service, &svc_networks)?; + updated_services.push(service.name.clone()); + } + + let updated = serde_yaml::to_string(&compose_doc) + .map_err(|err| CliError::ConfigValidation(format!("failed to serialize compose: {err}")))?; + if updated == original { + return Ok(ComposeServiceSyncResult { + compose_path: Some(compose_path), + backup_path: None, + updated_services: Vec::new(), + }); + } + + let backup_path = backup_path(&compose_path); + std::fs::copy(&compose_path, &backup_path)?; + std::fs::write(&compose_path, updated)?; + + Ok(ComposeServiceSyncResult { + compose_path: Some(compose_path), + backup_path: Some(backup_path), + updated_services, + }) +} + +fn resolve_path(project_dir: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + project_dir.join(path) + } +} + +fn backup_path(path: &Path) -> PathBuf { + PathBuf::from(format!("{}.bak", path.to_string_lossy())) +} + +fn upsert_compose_service( + compose_doc: &mut serde_yaml::Value, + service: &ServiceDefinition, + project_networks: &[String], +) -> Result<(), CliError> { + let services_key = serde_yaml::Value::String("services".to_string()); + let root = compose_doc.as_mapping_mut().ok_or_else(|| { + CliError::ConfigValidation("docker compose file must be a YAML mapping".to_string()) + })?; + if !root.contains_key(&services_key) { + root.insert( + services_key.clone(), + serde_yaml::Value::Mapping(Default::default()), + ); + } + let services = root + .get_mut(&services_key) + .and_then(serde_yaml::Value::as_mapping_mut) + .ok_or_else(|| { + CliError::ConfigValidation("docker compose file services must be a mapping".to_string()) + })?; + + services.insert( + serde_yaml::Value::String(service.name.clone()), + service_to_compose_value(service, project_networks), + ); + upsert_named_volumes(root, &service.volumes); + Ok(()) +} + +fn service_to_compose_value( + service: &ServiceDefinition, + project_networks: &[String], +) -> serde_yaml::Value { + let mut map = serde_yaml::Mapping::new(); + map.insert( + serde_yaml::Value::String("image".to_string()), + serde_yaml::Value::String(service.image.clone()), + ); + insert_string_sequence(&mut map, "ports", &service.ports); + insert_environment(&mut map, &service.environment); + insert_string_sequence(&mut map, "volumes", &service.volumes); + insert_string_sequence(&mut map, "depends_on", &service.depends_on); + if !project_networks.is_empty() { + insert_string_sequence(&mut map, "networks", project_networks); + } + map.insert( + serde_yaml::Value::String("restart".to_string()), + serde_yaml::Value::String("unless-stopped".to_string()), + ); + serde_yaml::Value::Mapping(map) +} + +fn insert_string_sequence(map: &mut serde_yaml::Mapping, key: &str, values: &[String]) { + if values.is_empty() { + return; + } + map.insert( + serde_yaml::Value::String(key.to_string()), + serde_yaml::Value::Sequence( + values + .iter() + .map(|value| serde_yaml::Value::String(value.clone())) + .collect(), + ), + ); +} + +fn insert_environment( + map: &mut serde_yaml::Mapping, + environment: &std::collections::HashMap, +) { + if environment.is_empty() { + return; + } + let sorted: BTreeMap<_, _> = environment.iter().collect(); + let mut env_map = serde_yaml::Mapping::new(); + for (key, value) in sorted { + env_map.insert( + serde_yaml::Value::String(key.clone()), + serde_yaml::Value::String(value.clone()), + ); + } + map.insert( + serde_yaml::Value::String("environment".to_string()), + serde_yaml::Value::Mapping(env_map), + ); +} + +fn upsert_named_volumes(root: &mut serde_yaml::Mapping, volumes: &[String]) { + let named_volumes: Vec = volumes + .iter() + .filter_map(|volume| named_volume_source(volume)) + .collect(); + if named_volumes.is_empty() { + return; + } + + let volumes_key = serde_yaml::Value::String("volumes".to_string()); + if !root.contains_key(&volumes_key) { + root.insert( + volumes_key.clone(), + serde_yaml::Value::Mapping(Default::default()), + ); + } + let Some(volume_map) = root + .get_mut(&volumes_key) + .and_then(serde_yaml::Value::as_mapping_mut) + else { + return; + }; + for volume in named_volumes { + let key = serde_yaml::Value::String(volume.clone()); + if volume_map.contains_key(&key) { + continue; + } + let mut value = serde_yaml::Mapping::new(); + value.insert( + serde_yaml::Value::String("name".to_string()), + serde_yaml::Value::String(volume), + ); + volume_map.insert(key, serde_yaml::Value::Mapping(value)); + } +} + +fn named_volume_source(volume: &str) -> Option { + let (source, _) = volume.split_once(':')?; + if source.starts_with('.') || source.starts_with('/') || source.starts_with('$') { + return None; + } + Some(source.to_string()) +} + +fn project_service_networks(project_doc: &serde_yaml::Value) -> Vec { + let Some(project_services) = project_doc + .as_mapping() + .and_then(|root| root.get(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping) + else { + return Vec::new(); + }; + + let mut networks = Vec::new(); + for service in project_services.values() { + let Some(networks_value) = service + .as_mapping() + .and_then(|service| service.get(serde_yaml::Value::String("networks".to_string()))) + else { + continue; + }; + collect_network_names(networks_value, &mut networks); + } + networks +} + +fn collect_network_names(value: &serde_yaml::Value, networks: &mut Vec) { + match value { + serde_yaml::Value::String(name) => push_unique_network(networks, name), + serde_yaml::Value::Sequence(items) => { + for item in items { + if let Some(name) = item.as_str() { + push_unique_network(networks, name); + } + } + } + serde_yaml::Value::Mapping(map) => { + for key in map.keys() { + if let Some(name) = key.as_str() { + push_unique_network(networks, name); + } + } + } + _ => {} + } +} + +fn push_unique_network(networks: &mut Vec, name: &str) { + if !networks.iter().any(|existing| existing == name) { + networks.push(name.to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::config_parser::{AppSource, DeployConfig, DomainConfig, ProjectConfig, SslMode}; + use std::collections::HashMap; + use tempfile::TempDir; + + // ── inject_npm_proxy_network unit tests ────────────────────────────────── + + fn npm_proxy_config(upstream: &str) -> ProxyConfig { + ProxyConfig { + proxy_type: ProxyType::NginxProxyManager, + auto_detect: false, + domains: vec![DomainConfig { + domain: "app.example.com".into(), + ssl: SslMode::Auto, + upstream: upstream.to_string(), + }], + config: None, + } + } + + fn compose_doc_with_service(service: &str) -> serde_yaml::Value { + serde_yaml::from_str(&format!( + "services:\n {service}:\n image: myapp:latest\n" + )) + .unwrap() + } + + #[test] + fn inject_npm_proxy_network_adds_to_proxied_service() { + let mut doc = compose_doc_with_service("web"); + let changed = inject_npm_proxy_network(&mut doc, "web", &npm_proxy_config("web:3000")); + assert!(changed); + let networks = doc["services"]["web"]["networks"] + .as_sequence() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect::>(); + assert!(networks.contains(&"default_network")); + // top-level declares it external + assert_eq!( + doc["networks"]["default_network"]["external"].as_bool(), + Some(true) + ); + } + + #[test] + fn inject_npm_proxy_network_returns_false_for_non_proxied_service() { + let mut doc = compose_doc_with_service("smtp"); + let changed = inject_npm_proxy_network(&mut doc, "smtp", &npm_proxy_config("web:3000")); + assert!(!changed); + assert!(doc["services"]["smtp"].get("networks").is_none()); + } + + #[test] + fn inject_npm_proxy_network_returns_false_for_non_npm_proxy() { + let mut doc = compose_doc_with_service("web"); + let proxy = ProxyConfig { + proxy_type: ProxyType::Traefik, + auto_detect: false, + domains: vec![DomainConfig { + domain: "app.example.com".into(), + ssl: SslMode::Auto, + upstream: "web:3000".into(), + }], + config: None, + }; + let changed = inject_npm_proxy_network(&mut doc, "web", &proxy); + assert!(!changed); + } + + #[test] + fn inject_npm_proxy_network_is_idempotent() { + let mut doc: serde_yaml::Value = serde_yaml::from_str( + "services:\n web:\n image: myapp:latest\n networks:\n - default_network\n", + ) + .unwrap(); + let changed = inject_npm_proxy_network(&mut doc, "web", &npm_proxy_config("web:3000")); + assert!(!changed, "already has default_network — should be a no-op"); + let seq = doc["services"]["web"]["networks"].as_sequence().unwrap(); + let count = seq + .iter() + .filter(|v| v.as_str() == Some("default_network")) + .count(); + assert_eq!(count, 1, "no duplicate entries"); + } + + #[test] + fn inject_npm_proxy_network_parses_http_prefix_upstream() { + let proxy = ProxyConfig { + proxy_type: ProxyType::NginxProxyManager, + auto_detect: false, + domains: vec![DomainConfig { + domain: "app.example.com".into(), + ssl: SslMode::Off, + upstream: "http://api:8080".into(), + }], + config: None, + }; + let mut doc = compose_doc_with_service("api"); + let changed = inject_npm_proxy_network(&mut doc, "api", &proxy); + assert!(changed); + let networks = doc["services"]["api"]["networks"] + .as_sequence() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect::>(); + assert!(networks.contains(&"default_network")); + } + + // ── sync_configured_compose_services proxy-inject tests ────────────────── + + fn npm_stacker_config(dir: &std::path::Path, service_name: &str) -> StackerConfig { + StackerConfig { + project: ProjectConfig::default(), + app: AppSource::default(), + deploy: DeployConfig { + compose_file: Some(PathBuf::from("docker-compose.yml")), + ..Default::default() + }, + proxy: ProxyConfig { + proxy_type: ProxyType::NginxProxyManager, + auto_detect: false, + domains: vec![DomainConfig { + domain: "app.example.com".into(), + ssl: SslMode::Auto, + upstream: format!("{service_name}:3000"), + }], + config: None, + }, + services: vec![ServiceDefinition { + name: service_name.to_string(), + image: "myapp:latest".to_string(), + ports: vec!["3000:3000".to_string()], + environment: HashMap::new(), + volumes: vec![], + depends_on: vec![], + }], + ..Default::default() + } + } + + #[test] + fn sync_injects_default_network_for_npm_proxied_service() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("docker-compose.yml"), + "services:\n existing:\n image: nginx:latest\n", + ) + .unwrap(); + + let config = npm_stacker_config(dir.path(), "api"); + let result = + sync_configured_compose_services(dir.path(), &config, &["api".to_string()]).unwrap(); + + assert_eq!(result.updated_services, vec!["api"]); + let updated = std::fs::read_to_string(dir.path().join("docker-compose.yml")).unwrap(); + assert!( + updated.contains("default_network"), + "proxied service should have default_network injected:\n{updated}" + ); + assert!( + updated.contains("external: true") || updated.contains("external: 'true'"), + "default_network should be declared external:\n{updated}" + ); + } + + #[test] + fn sync_does_not_inject_default_network_for_non_proxied_service() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("docker-compose.yml"), + "services:\n existing:\n image: nginx:latest\n", + ) + .unwrap(); + + // proxy points to "api" but we are syncing "smtp" + let mut config = npm_stacker_config(dir.path(), "api"); + config.services = vec![ServiceDefinition { + name: "smtp".to_string(), + image: "trydirect/smtp".to_string(), + ports: vec![], + environment: HashMap::new(), + volumes: vec![], + depends_on: vec![], + }]; + + let result = + sync_configured_compose_services(dir.path(), &config, &["smtp".to_string()]).unwrap(); + + assert_eq!(result.updated_services, vec!["smtp"]); + let updated = std::fs::read_to_string(dir.path().join("docker-compose.yml")).unwrap(); + let smtp_section_start = updated.find("smtp:").unwrap(); + let smtp_section = &updated[smtp_section_start..]; + // "smtp" block should not list default_network + let next_service = smtp_section[5..].find('\n').map(|i| &smtp_section[..i + 5]); + let _ = next_service; // just ensure smtp block doesn't have it + assert!( + !smtp_section + .lines() + .take(10) + .any(|l| l.contains("default_network")), + "non-proxied service should not get default_network:\n{updated}" + ); + } + + #[test] + fn sync_configured_compose_services_upserts_service_networks_and_volumes() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("docker-compose.yml"), + r#" +version: '3.8' +networks: + default_network: + external: true + name: default_network +services: + status-panel-web: + image: trydirect/status-panel-web:latest + networks: + - default_network +volumes: + npm_data: + name: npm_data +"#, + ) + .unwrap(); + + let config = StackerConfig { + project: ProjectConfig::default(), + app: AppSource::default(), + deploy: DeployConfig { + compose_file: Some(PathBuf::from("docker-compose.yml")), + ..Default::default() + }, + services: vec![ServiceDefinition { + name: "smtp".to_string(), + image: "trydirect/smtp".to_string(), + ports: vec!["1025:25".to_string()], + environment: HashMap::from([ + ( + "RELAY_NETWORKS".to_string(), + ":127.0.0.0/8:10.0.0.0/8:172.16.0.0/12:192.168.0.0/16".to_string(), + ), + ("PORT".to_string(), "25".to_string()), + ]), + volumes: vec!["smtp_data:/data".to_string()], + depends_on: Vec::new(), + }], + ..Default::default() + }; + + let result = + sync_configured_compose_services(dir.path(), &config, &[String::from("smtp")]).unwrap(); + + assert_eq!(result.updated_services, vec!["smtp"]); + assert!(result.backup_path.unwrap().exists()); + let updated = std::fs::read_to_string(dir.path().join("docker-compose.yml")).unwrap(); + assert!(updated.contains("smtp:")); + assert!(updated.contains("image: trydirect/smtp")); + assert!(updated.contains("\"1025:25\"") || updated.contains("1025:25")); + assert!(updated.contains("RELAY_NETWORKS")); + assert!(updated.contains("default_network")); + assert!(updated.contains("smtp_data:")); + } +} diff --git a/src/cli/compose_targets.rs b/src/cli/compose_targets.rs index 33d167aa..7796573f 100644 --- a/src/cli/compose_targets.rs +++ b/src/cli/compose_targets.rs @@ -36,6 +36,64 @@ pub fn extract_compose_secret_target_services( Ok(services) } +pub fn compose_defines_nginx_proxy_manager_service(compose_path: &Path) -> Result { + let mut visited = HashSet::new(); + compose_file_defines_nginx_proxy_manager_service(compose_path, &mut visited) +} + +fn compose_file_defines_nginx_proxy_manager_service( + compose_path: &Path, + visited: &mut HashSet, +) -> Result { + let canonical = compose_path + .canonicalize() + .unwrap_or_else(|_| compose_path.to_path_buf()); + if !visited.insert(canonical) { + return Ok(false); + } + + let content = std::fs::read_to_string(compose_path).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to read compose file for proxy discovery '{}': {}", + compose_path.display(), + err + )) + })?; + let document: Value = serde_yaml::from_str(&content).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to parse compose file for proxy discovery '{}': {}", + compose_path.display(), + err + )) + })?; + + if let Some(service_map) = document + .get(Value::String("services".to_string())) + .and_then(Value::as_mapping) + { + for (name, definition) in service_map { + let Some(service_name) = name.as_str() else { + continue; + }; + let Some(definition) = definition.as_mapping() else { + continue; + }; + if is_nginx_proxy_manager_compose_service(service_name, definition) { + return Ok(true); + } + } + } + + let base_dir = compose_path.parent().unwrap_or_else(|| Path::new(".")); + for include_path in compose_include_paths(&document, base_dir) { + if compose_file_defines_nginx_proxy_manager_service(&include_path, visited)? { + return Ok(true); + } + } + + Ok(false) +} + fn collect_compose_services( compose_path: &Path, config: &StackerConfig, @@ -295,15 +353,77 @@ fn collect_include_value(value: &Value, base_dir: &Path, paths: &mut Vec bool { - let service_name = service_name.to_ascii_lowercase().replace('-', "_"); - let image = mapping_string(definition, "image") - .unwrap_or_default() - .to_ascii_lowercase(); - - service_name == "nginx_proxy_manager" - || service_name == "npm" - || service_name == "statuspanel" - || service_name == "status_panel" - || image.contains("nginx-proxy-manager") - || image.contains("statuspanel") + let image = mapping_string(definition, "image"); + crate::project_app::is_platform_managed_app_identity(service_name, image.as_deref()) +} + +fn is_nginx_proxy_manager_compose_service(service_name: &str, definition: &Mapping) -> bool { + let image = mapping_string(definition, "image"); + crate::project_app::is_nginx_proxy_manager_identity(service_name, image.as_deref()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn detects_nginx_proxy_manager_service_in_compose_file() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("compose.yml"); + std::fs::write( + &compose_path, + r#" +services: + proxy: + image: jc21/nginx-proxy-manager:latest +"#, + ) + .unwrap(); + + assert!(compose_defines_nginx_proxy_manager_service(&compose_path).unwrap()); + } + + #[test] + fn detects_nginx_proxy_manager_service_from_included_compose_file() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("compose.yml"); + let included_path = dir.path().join("proxy.yml"); + std::fs::write( + &compose_path, + r#" +include: + - proxy.yml +"#, + ) + .unwrap(); + std::fs::write( + &included_path, + r#" +services: + npm: + image: example/custom-proxy:latest +"#, + ) + .unwrap(); + + assert!(compose_defines_nginx_proxy_manager_service(&compose_path).unwrap()); + } + + #[test] + fn ignores_compose_files_without_nginx_proxy_manager() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("compose.yml"); + std::fs::write( + &compose_path, + r#" +services: + web: + image: nginx:latest +"#, + ) + .unwrap(); + + assert!(!compose_defines_nginx_proxy_manager_service(&compose_path).unwrap()); + } } diff --git a/src/cli/config_bundle.rs b/src/cli/config_bundle.rs index bf1d29b5..9cc8a410 100644 --- a/src/cli/config_bundle.rs +++ b/src/cli/config_bundle.rs @@ -120,6 +120,7 @@ pub fn build_config_bundle( }) .collect(); files.sort_by(|left, right| left.source_path.cmp(&right.source_path)); + validate_relative_destinations(&files)?; let manifest = ConfigBundleManifest { version: 1, @@ -331,7 +332,7 @@ fn collect_reference( fn collect_file<'a>( project_root: &Path, - environment: &str, + _environment: &str, path: PathBuf, collected: &'a mut BTreeMap, ) -> Result<&'a CollectedFile, CliError> { @@ -350,6 +351,7 @@ fn collect_file<'a>( display_project_path(project_root, &canonical) ))); } + if !canonical.is_file() { return Err(validation_error(format!( "config bundle path is not a file: {}", @@ -359,10 +361,7 @@ fn collect_file<'a>( if !collected.contains_key(&canonical) { let source_path = display_project_path(project_root, &canonical); - let destination_path = format!( - "/opt/stacker/deployments/{environment}/files/{}", - source_path.replace('\\', "/") - ); + let destination_path = source_path.replace('\\', "/"); collected.insert( canonical.clone(), CollectedFile { @@ -385,6 +384,19 @@ fn collect_file<'a>( .expect("collected file was inserted")) } +fn validate_relative_destinations(files: &[ConfigBundleFile]) -> Result<(), CliError> { + for file in files { + if Path::new(&file.destination_path).is_absolute() { + return Err(validation_error(format!( + "config bundle destination must be project-relative: {} -> {}", + file.source_path, file.destination_path + ))); + } + } + + Ok(()) +} + fn write_archive<'a>( archive_path: &Path, files: impl IntoIterator, @@ -575,9 +587,8 @@ services: assert!(sources.contains(&"docker/production/nginx.conf")); let remote_compose = std::fs::read_to_string(&artifacts.remote_compose_path).unwrap(); - assert!(remote_compose - .contains("/opt/stacker/deployments/production/files/docker/production/.env")); - assert!(remote_compose.contains("/opt/stacker/deployments/production/files/docker/production/nginx.conf:/etc/nginx/nginx.conf:ro")); + assert!(remote_compose.contains("docker/production/.env")); + assert!(remote_compose.contains("docker/production/nginx.conf:/etc/nginx/nginx.conf:ro")); let names: Vec<&str> = artifacts .config_files @@ -600,6 +611,57 @@ services: assert_eq!(root_env["content"], "RUST_LOG=warning\n"); } + #[test] + fn build_config_bundle_keeps_root_compose_env_file_project_relative() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join(".env"), "APP_ENV=production\n").unwrap(); + std::fs::write( + dir.path().join("docker-compose.yml"), + r#" +services: + web: + image: nginx:latest + env_file: + - .env +"#, + ) + .unwrap(); + + let artifacts = build_config_bundle( + dir.path(), + "production", + &dir.path().join("docker-compose.yml"), + None, + ) + .expect("bundle should be built"); + + let remote_compose = std::fs::read_to_string(&artifacts.remote_compose_path).unwrap(); + assert!(remote_compose.contains(".env")); + assert!(!remote_compose.contains("/opt/stacker/deployments")); + + assert!(artifacts.config_files.iter().any(|file| { + file.get("destination_path") + .and_then(|value| value.as_str()) + == Some(".env") + })); + } + + #[test] + fn validate_relative_destinations_rejects_absolute_paths() { + let err = validate_relative_destinations(&[ConfigBundleFile { + source_path: ".env".to_string(), + destination_path: "/opt/stacker/deployments/production/files/.env".to_string(), + mode: "0644".to_string(), + size: 12, + sha256: "abc".to_string(), + }]) + .unwrap_err(); + + assert!(err + .to_string() + .contains("config bundle destination must be project-relative")); + } + #[test] fn build_config_bundle_rejects_directory_mounts() { let dir = TempDir::new().unwrap(); @@ -672,18 +734,14 @@ services: files: vec![ ConfigBundleFile { source_path: "docker/production/.env".to_string(), - destination_path: - "/opt/stacker/deployments/production/files/docker/production/.env" - .to_string(), + destination_path: "docker/production/.env".to_string(), mode: "0644".to_string(), size: 12, sha256: "abc".to_string(), }, ConfigBundleFile { source_path: "docker/production/nginx.conf".to_string(), - destination_path: - "/opt/stacker/deployments/production/files/docker/production/nginx.conf" - .to_string(), + destination_path: "docker/production/nginx.conf".to_string(), mode: "0644".to_string(), size: 10, sha256: "def".to_string(), diff --git a/src/cli/config_check.rs b/src/cli/config_check.rs new file mode 100644 index 00000000..9ae5331d --- /dev/null +++ b/src/cli/config_check.rs @@ -0,0 +1,254 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use serde::Serialize; + +use crate::cli::config_inventory::{load_inventory, ConfigInventory, InventoryOptions}; +use crate::cli::config_parser::StackerConfig; +use crate::cli::error::CliError; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigCheckResult { + pub environment: String, + pub service: Option, + pub missing_required: Vec, + pub missing_optional: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigCheckItem { + pub target: String, + pub key: String, + pub secret: bool, +} + +pub fn load_check( + config_path: &Path, + environment: &str, + service: Option, +) -> Result { + let config = StackerConfig::from_file(config_path)?; + let inventory = load_inventory( + config_path, + &InventoryOptions { + environment: environment.to_string(), + service: service.clone(), + show_values: false, + }, + )?; + + Ok(check_inventory(config, inventory, service)) +} + +pub fn check_inventory( + config: StackerConfig, + inventory: ConfigInventory, + service: Option, +) -> ConfigCheckResult { + let mut present_keys = BTreeMap::new(); + for target in &inventory.targets { + let keys = target + .keys + .iter() + .map(|key| key.key.clone()) + .collect::>(); + present_keys.insert(target.target_code.clone(), keys); + } + + let mut missing_required = Vec::new(); + let mut missing_optional = Vec::new(); + + for (target, contract) in config.config_contract.services { + if service.as_deref().is_some_and(|filter| filter != target) { + continue; + } + + let present = present_keys.get(&target).cloned().unwrap_or_default(); + let secret_keys = contract.secret.into_iter().collect::>(); + + for key in contract.required { + if !present.contains(&key) { + missing_required.push(ConfigCheckItem { + secret: secret_keys.contains(&key), + target: target.clone(), + key, + }); + } + } + + for key in contract.optional { + if !present.contains(&key) { + missing_optional.push(ConfigCheckItem { + secret: secret_keys.contains(&key), + target: target.clone(), + key, + }); + } + } + } + + ConfigCheckResult { + environment: inventory.environment, + service, + missing_required, + missing_optional, + warnings: inventory.warnings, + } +} + +impl ConfigCheckResult { + pub fn has_required_failures(&self) -> bool { + !self.missing_required.is_empty() + } + + pub fn has_warnings(&self) -> bool { + !self.missing_optional.is_empty() || !self.warnings.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + fn check(root: &Path, service: Option<&str>) -> ConfigCheckResult { + load_check( + &root.join("stacker.yml"), + "prod", + service.map(str::to_string), + ) + .unwrap() + } + + #[test] + fn config_check_reports_required_key_missing_from_target() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + env_file: docker/prod/.env +config_contract: + services: + device-api: + required: + - DATABASE_URL +"#, + ); + write(&temp.path().join("docker/prod/.env"), "RUST_LOG=debug\n"); + + let result = check(temp.path(), None); + + assert!(result.has_required_failures()); + assert_eq!(result.missing_required[0].target, "device-api"); + assert_eq!(result.missing_required[0].key, "DATABASE_URL"); + } + + #[test] + fn config_check_treats_optional_key_as_warning_only() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +config_contract: + services: + device-api: + optional: + - SENTRY_DSN +"#, + ); + + let result = check(temp.path(), None); + + assert!(!result.has_required_failures()); + assert!(result.has_warnings()); + assert_eq!(result.missing_optional[0].key, "SENTRY_DSN"); + } + + #[test] + fn config_check_secret_contract_redacts_missing_key_marker() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +config_contract: + services: + device-api: + required: + - CUSTOM_API_KEY + secret: + - CUSTOM_API_KEY +"#, + ); + + let result = check(temp.path(), None); + + assert!(result.missing_required[0].secret); + assert_eq!(result.missing_required[0].key, "CUSTOM_API_KEY"); + } + + #[test] + fn config_check_passes_when_required_key_exists() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + env_file: docker/prod/.env +config_contract: + services: + device-api: + required: + - DATABASE_URL +"#, + ); + write( + &temp.path().join("docker/prod/.env"), + "DATABASE_URL=postgres://db\n", + ); + + let result = check(temp.path(), None); + + assert!(!result.has_required_failures()); + assert!(result.missing_required.is_empty()); + } + + #[test] + fn config_check_respects_service_filter() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +config_contract: + services: + device-api: + required: + - DATABASE_URL + upload: + required: + - S3_BUCKET +"#, + ); + + let result = check(temp.path(), Some("upload")); + + assert_eq!(result.missing_required.len(), 1); + assert_eq!(result.missing_required[0].target, "upload"); + assert_eq!(result.missing_required[0].key, "S3_BUCKET"); + } +} diff --git a/src/cli/config_contract.rs b/src/cli/config_contract.rs new file mode 100644 index 00000000..7fd57640 --- /dev/null +++ b/src/cli/config_contract.rs @@ -0,0 +1,173 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use serde::Serialize; + +use crate::cli::config_inventory::{load_inventory, ConfigInventory, InventoryOptions}; +use crate::cli::config_parser::{ConfigContract, TargetConfigContract}; +use crate::cli::error::CliError; + +#[derive(Debug, Clone)] +pub struct ContractSuggestOptions { + pub environment: String, + pub service: Option, +} + +#[derive(Debug, Serialize)] +struct ContractSuggestion { + config_contract: ConfigContract, +} + +pub fn suggest_contract_yaml( + config_path: &Path, + options: &ContractSuggestOptions, +) -> Result { + let inventory = load_inventory( + config_path, + &InventoryOptions { + environment: options.environment.clone(), + service: options.service.clone(), + show_values: false, + }, + )?; + + contract_suggestion_yaml(suggest_contract(inventory)) +} + +pub fn suggest_contract(inventory: ConfigInventory) -> ConfigContract { + let mut services = BTreeMap::new(); + + for target in inventory.targets { + let mut required = Vec::new(); + let mut secret = Vec::new(); + + for key in target.keys { + required.push(key.key.clone()); + if key.secret { + secret.push(key.key); + } + } + + services.insert( + target.target_code, + TargetConfigContract { + required, + optional: Vec::new(), + secret, + }, + ); + } + + ConfigContract { services } +} + +pub fn contract_suggestion_yaml(contract: ConfigContract) -> Result { + serde_yaml::to_string(&ContractSuggestion { + config_contract: contract, + }) + .map_err(CliError::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + #[test] + fn config_contract_suggest_generates_required_keys_from_inventory() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + env_file: docker/prod/.env +"#, + ); + write( + &temp.path().join("docker/prod/.env"), + "DATABASE_URL=postgres://db\nRUST_LOG=debug\n", + ); + + let yaml = suggest_contract_yaml( + &temp.path().join("stacker.yml"), + &ContractSuggestOptions { + environment: "prod".to_string(), + service: None, + }, + ) + .unwrap(); + + assert!(yaml.contains("config_contract:")); + assert!(yaml.contains("device-api:")); + assert!(yaml.contains("- DATABASE_URL")); + assert!(yaml.contains("- RUST_LOG")); + } + + #[test] + fn config_contract_suggest_classifies_secret_like_keys() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +env: + S3_SECRET_KEY: secret +"#, + ); + + let yaml = suggest_contract_yaml( + &temp.path().join("stacker.yml"), + &ContractSuggestOptions { + environment: "prod".to_string(), + service: None, + }, + ) + .unwrap(); + + assert!(yaml.contains("secret:")); + assert!(yaml.contains("- S3_SECRET_KEY")); + } + + #[test] + fn config_contract_suggest_respects_service_filter() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +services: + upload: + image: upload:latest + environment: + S3_BUCKET: bucket + worker: + image: worker:latest + environment: + QUEUE: default +"#, + ); + + let yaml = suggest_contract_yaml( + &temp.path().join("stacker.yml"), + &ContractSuggestOptions { + environment: "prod".to_string(), + service: Some("upload".to_string()), + }, + ) + .unwrap(); + + assert!(yaml.contains("upload:")); + assert!(yaml.contains("- S3_BUCKET")); + assert!(!yaml.contains("worker:")); + assert!(!yaml.contains("- QUEUE")); + } +} diff --git a/src/cli/config_diff.rs b/src/cli/config_diff.rs new file mode 100644 index 00000000..4ae0d26e --- /dev/null +++ b/src/cli/config_diff.rs @@ -0,0 +1,322 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use serde::Serialize; + +use crate::cli::config_inventory::{ + load_inventory, ConfigInventory, ConfigKeyInventory, InventoryOptions, +}; +use crate::cli::error::CliError; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigDiff { + pub from_environment: String, + pub to_environment: String, + pub service: Option, + pub missing_in_to: Vec, + pub only_in_to: Vec, + pub different: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct DiffItem { + pub target: String, + pub key: String, + pub secret: bool, + pub from_source: Option, + pub to_source: Option, + pub from_hash: Option, + pub to_hash: Option, +} + +pub fn load_diff( + config_path: &Path, + from_environment: &str, + to_environment: &str, + service: Option, +) -> Result { + let from_inventory = load_inventory( + config_path, + &InventoryOptions { + environment: from_environment.to_string(), + service: service.clone(), + show_values: false, + }, + )?; + let to_inventory = load_inventory( + config_path, + &InventoryOptions { + environment: to_environment.to_string(), + service: service.clone(), + show_values: false, + }, + )?; + + Ok(diff_inventories(from_inventory, to_inventory, service)) +} + +pub fn diff_inventories( + from_inventory: ConfigInventory, + to_inventory: ConfigInventory, + service: Option, +) -> ConfigDiff { + let from_environment = from_inventory.environment.clone(); + let to_environment = to_inventory.environment.clone(); + let mut warnings = from_inventory.warnings.clone(); + warnings.extend(to_inventory.warnings.clone()); + + let from_keys = flatten_inventory(from_inventory); + let to_keys = flatten_inventory(to_inventory); + let mut identities = BTreeSet::new(); + identities.extend(from_keys.keys().cloned()); + identities.extend(to_keys.keys().cloned()); + + let mut missing_in_to = Vec::new(); + let mut only_in_to = Vec::new(); + let mut different = Vec::new(); + + for identity in identities { + match (from_keys.get(&identity), to_keys.get(&identity)) { + (Some(from_key), None) => { + missing_in_to.push(diff_item(&identity, Some(from_key), None)) + } + (None, Some(to_key)) => only_in_to.push(diff_item(&identity, None, Some(to_key))), + (Some(from_key), Some(to_key)) if from_key.value_hash != to_key.value_hash => { + different.push(diff_item(&identity, Some(from_key), Some(to_key))); + } + _ => {} + } + } + + ConfigDiff { + from_environment, + to_environment, + service, + missing_in_to, + only_in_to, + different, + warnings, + } +} + +impl ConfigDiff { + pub fn has_differences(&self) -> bool { + !self.missing_in_to.is_empty() || !self.only_in_to.is_empty() || !self.different.is_empty() + } +} + +fn flatten_inventory(inventory: ConfigInventory) -> BTreeMap<(String, String), ConfigKeyInventory> { + let mut flattened = BTreeMap::new(); + + for target in inventory.targets { + for key in target.keys { + flattened.insert((target.target_code.clone(), key.key.clone()), key); + } + } + + flattened +} + +fn diff_item( + identity: &(String, String), + from_key: Option<&ConfigKeyInventory>, + to_key: Option<&ConfigKeyInventory>, +) -> DiffItem { + DiffItem { + target: identity.0.clone(), + key: identity.1.clone(), + secret: from_key + .map(|key| key.secret) + .or_else(|| to_key.map(|key| key.secret)) + .unwrap_or(false), + from_source: from_key.map(|key| key.source.clone()), + to_source: to_key.map(|key| key.source.clone()), + from_hash: from_key.and_then(|key| key.value_hash.clone()), + to_hash: to_key.and_then(|key| key.value_hash.clone()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + fn diff(root: &Path, service: Option<&str>) -> ConfigDiff { + load_diff( + &root.join("stacker.yml"), + "dev", + "prod", + service.map(str::to_string), + ) + .unwrap() + } + + fn write_env_diff_project(root: &Path, dev_env: &str, prod_env: &str) { + write( + &root.join("stacker.yml"), + r#" +name: device-api +environments: + dev: + env_file: docker/dev/.env + prod: + env_file: docker/prod/.env +"#, + ); + write(&root.join("docker/dev/.env"), dev_env); + write(&root.join("docker/prod/.env"), prod_env); + } + + #[test] + fn config_diff_reports_key_missing_in_target_environment() { + let temp = TempDir::new().unwrap(); + write_env_diff_project( + temp.path(), + "RUST_LOG=debug\nS3_BUCKET=dev-bucket\n", + "RUST_LOG=debug\n", + ); + + let diff = diff(temp.path(), None); + + assert_eq!(diff.missing_in_to.len(), 1); + assert_eq!(diff.missing_in_to[0].target, "device-api"); + assert_eq!(diff.missing_in_to[0].key, "S3_BUCKET"); + } + + #[test] + fn config_diff_reports_key_only_in_target_environment() { + let temp = TempDir::new().unwrap(); + write_env_diff_project( + temp.path(), + "RUST_LOG=debug\n", + "RUST_LOG=debug\nNODE_ENV=production\n", + ); + + let diff = diff(temp.path(), None); + + assert_eq!(diff.only_in_to.len(), 1); + assert_eq!(diff.only_in_to[0].key, "NODE_ENV"); + } + + #[test] + fn config_diff_reports_hash_difference_without_plaintext_values() { + let temp = TempDir::new().unwrap(); + write_env_diff_project(temp.path(), "RUST_LOG=debug\n", "RUST_LOG=info\n"); + + let diff = diff(temp.path(), None); + + assert_eq!(diff.different.len(), 1); + assert_eq!(diff.different[0].key, "RUST_LOG"); + assert_ne!(diff.different[0].from_hash, diff.different[0].to_hash); + } + + #[test] + fn config_diff_redacts_secret_like_differences() { + let temp = TempDir::new().unwrap(); + write_env_diff_project( + temp.path(), + "API_TOKEN=dev-secret\n", + "API_TOKEN=prod-secret\n", + ); + + let diff = diff(temp.path(), None); + + assert_eq!(diff.different.len(), 1); + assert!(diff.different[0].secret); + assert_ne!(diff.different[0].from_hash.as_deref(), Some("dev-secret")); + assert_ne!(diff.different[0].to_hash.as_deref(), Some("prod-secret")); + } + + #[test] + fn config_diff_respects_service_filter() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + dev: + compose_file: docker/dev/compose.yml + prod: + compose_file: docker/prod/compose.yml +"#, + ); + write( + &temp.path().join("docker/dev/compose.yml"), + r#" +services: + upload: + image: upload:latest + environment: + S3_BUCKET: dev-bucket + worker: + image: worker:latest + environment: + QUEUE: dev +"#, + ); + write( + &temp.path().join("docker/prod/compose.yml"), + r#" +services: + upload: + image: upload:latest + environment: + S3_BUCKET: prod-bucket +"#, + ); + + let diff = diff(temp.path(), Some("upload")); + + assert_eq!(diff.different.len(), 1); + assert!(diff.missing_in_to.is_empty()); + assert_eq!(diff.different[0].target, "upload"); + } + + #[test] + fn config_diff_treats_remote_secret_metadata_as_present_in_target() { + let from_inventory = ConfigInventory { + environment: "local".to_string(), + targets: vec![crate::cli::config_inventory::TargetConfigInventory { + target_code: "upload".to_string(), + keys: vec![ConfigKeyInventory { + key: "S3_SECRET_KEY".to_string(), + source: "stacker.yml service environment".to_string(), + present: true, + secret: true, + value_hash: Some("local-hash".to_string()), + value_preview: None, + }], + }], + warnings: Vec::new(), + }; + let mut to_inventory = ConfigInventory { + environment: "prod".to_string(), + targets: vec![crate::cli::config_inventory::TargetConfigInventory { + target_code: "upload".to_string(), + keys: Vec::new(), + }], + warnings: Vec::new(), + }; + crate::cli::config_inventory::merge_remote_secret_names( + &mut to_inventory, + "upload", + vec!["S3_SECRET_KEY".to_string()], + ); + + let diff = diff_inventories(from_inventory, to_inventory, Some("upload".to_string())); + + assert!(diff.missing_in_to.is_empty()); + assert_eq!(diff.different.len(), 1); + assert_eq!(diff.different[0].key, "S3_SECRET_KEY"); + assert_eq!(diff.different[0].to_hash, None); + } +} diff --git a/src/cli/config_inventory.rs b/src/cli/config_inventory.rs new file mode 100644 index 00000000..e6562df7 --- /dev/null +++ b/src/cli/config_inventory.rs @@ -0,0 +1,903 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::cli::config_parser::StackerConfig; +use crate::cli::error::CliError; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigInventory { + pub environment: String, + pub targets: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct TargetConfigInventory { + pub target_code: String, + pub keys: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigKeyInventory { + pub key: String, + pub source: String, + pub present: bool, + pub secret: bool, + pub value_hash: Option, + pub value_preview: Option, +} + +#[derive(Debug, Clone)] +pub struct InventoryOptions { + pub environment: String, + pub service: Option, + pub show_values: bool, +} + +#[derive(Debug, Clone)] +struct KeyEntry { + value: String, + source: String, +} + +#[derive(Debug, Default)] +struct InventoryBuilder { + targets: BTreeMap>, + warnings: Vec, +} + +impl InventoryBuilder { + fn add_target(&mut self, target: &str) { + self.targets.entry(target.to_string()).or_default(); + } + + fn add_env_map(&mut self, target: &str, source: &str, values: BTreeMap) { + let target_keys = self.targets.entry(target.to_string()).or_default(); + for (key, value) in values { + target_keys.insert( + key, + KeyEntry { + value, + source: source.to_string(), + }, + ); + } + } + + fn add_entries(&mut self, target: &str, entries: BTreeMap) { + self.targets + .entry(target.to_string()) + .or_default() + .extend(entries); + } + + fn warn(&mut self, message: impl Into) { + self.warnings.push(message.into()); + } +} + +pub fn load_inventory( + config_path: &Path, + options: &InventoryOptions, +) -> Result { + let project_dir = config_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let config = StackerConfig::from_file(config_path)?; + let (_, env_config) = config + .resolve_environment_config(Some(&options.environment))? + .ok_or_else(|| { + CliError::ConfigValidation("environment could not be resolved".to_string()) + })?; + + let mut builder = InventoryBuilder::default(); + let mut global_entries = BTreeMap::new(); + + if let Some(env_file) = env_config.env_file.as_ref() { + let path = resolve_relative(project_dir, env_file); + match parse_env_file(&path) { + Ok(values) => { + global_entries.extend(values.into_iter().map(|(key, value)| { + ( + key, + KeyEntry { + value, + source: "stacker env_file".to_string(), + }, + ) + })); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + builder.warn(format!("Missing env file: {}", path.display())); + } + Err(error) => return Err(error.into()), + } + } + global_entries.extend(config.env.clone().into_iter().map(|(key, value)| { + ( + key, + KeyEntry { + value, + source: "stacker.yml env".to_string(), + }, + ) + })); + + let main_target = if config.name.trim().is_empty() { + "app".to_string() + } else { + config.name.clone() + }; + if target_matches(&main_target, options.service.as_deref()) { + builder.add_target(&main_target); + builder.add_entries(&main_target, global_entries.clone()); + builder.add_env_map( + &main_target, + "stacker.yml app environment", + config.app.environment.clone().into_iter().collect(), + ); + } + + for service in &config.services { + if !target_matches(&service.name, options.service.as_deref()) { + continue; + } + builder.add_target(&service.name); + builder.add_entries(&service.name, global_entries.clone()); + builder.add_env_map( + &service.name, + "stacker.yml service environment", + service.environment.clone().into_iter().collect(), + ); + } + + if let Some(compose_file) = env_config.compose_file.as_ref() { + let compose_path = resolve_relative(project_dir, compose_file); + load_compose_file( + &compose_path, + "compose", + &global_entries, + None, + options.service.as_deref(), + &mut builder, + )?; + } + + load_app_local_files( + project_dir, + &options.environment, + options.service.as_deref(), + &mut builder, + )?; + + let mut targets = Vec::new(); + for (target_code, keys) in builder.targets { + let key_entries = keys + .into_iter() + .map(|(key, entry)| { + let secret = is_secret_key(&key); + ConfigKeyInventory { + key, + source: entry.source, + present: true, + secret, + value_hash: Some(hash_value(&entry.value)), + value_preview: if secret || !options.show_values { + None + } else { + Some(entry.value) + }, + } + }) + .collect(); + + targets.push(TargetConfigInventory { + target_code, + keys: key_entries, + }); + } + + Ok(ConfigInventory { + environment: options.environment.clone(), + targets, + warnings: builder.warnings, + }) +} + +pub fn merge_remote_secret_names( + inventory: &mut ConfigInventory, + target_code: &str, + names: impl IntoIterator, +) { + if let Some(target) = inventory + .targets + .iter_mut() + .find(|target| target.target_code == target_code) + { + for name in names { + if target.keys.iter().any(|key| key.key == name) { + continue; + } + target.keys.push(ConfigKeyInventory { + key: name, + source: "remote secret metadata".to_string(), + present: true, + secret: true, + value_hash: None, + value_preview: None, + }); + } + target.keys.sort_by(|left, right| left.key.cmp(&right.key)); + return; + } + + let mut keys = names + .into_iter() + .map(|name| ConfigKeyInventory { + key: name, + source: "remote secret metadata".to_string(), + present: true, + secret: true, + value_hash: None, + value_preview: None, + }) + .collect::>(); + keys.sort_by(|left, right| left.key.cmp(&right.key)); + + inventory.targets.push(TargetConfigInventory { + target_code: target_code.to_string(), + keys, + }); + inventory + .targets + .sort_by(|left, right| left.target_code.cmp(&right.target_code)); +} + +fn load_compose_file( + compose_path: &Path, + source_prefix: &str, + global_entries: &BTreeMap, + target_override: Option<&str>, + service_filter: Option<&str>, + builder: &mut InventoryBuilder, +) -> Result<(), CliError> { + let content = match std::fs::read_to_string(compose_path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + let parsed: serde_yaml::Value = serde_yaml::from_str(&content)?; + let Some(services) = parsed + .get("services") + .and_then(serde_yaml::Value::as_mapping) + else { + return Ok(()); + }; + + let compose_dir = compose_path.parent().unwrap_or_else(|| Path::new(".")); + for (service_name, service_config) in services { + let target = if let Some(target_override) = target_override { + target_override + } else { + let Some(target) = service_name.as_str() else { + continue; + }; + target + }; + if !target_matches(target, service_filter) { + continue; + } + builder.add_target(target); + builder.add_entries(target, global_entries.clone()); + + if let Some(env_file) = service_config.get("env_file") { + for env_path in compose_env_file_paths(env_file) { + let resolved = resolve_relative(compose_dir, &env_path); + match parse_env_file(&resolved) { + Ok(values) => { + builder.add_env_map(target, &format!("{source_prefix} env_file"), values) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + builder.warn(format!( + "Missing env file for {target}: {}", + resolved.display() + )); + } + Err(error) => return Err(error.into()), + } + } + } + + if let Some(environment) = service_config.get("environment") { + builder.add_env_map( + target, + &format!("{source_prefix} environment"), + compose_environment_values(environment), + ); + } + } + + Ok(()) +} + +fn load_app_local_files( + project_dir: &Path, + environment: &str, + service_filter: Option<&str>, + builder: &mut InventoryBuilder, +) -> Result<(), CliError> { + let mut app_dirs = BTreeSet::new(); + discover_app_local_dirs(project_dir, environment, &mut app_dirs)?; + + for app_dir in app_dirs { + let Some(target) = app_dir.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !target_matches(target, service_filter) { + continue; + } + let env_dir = app_dir.join("docker").join(environment); + let env_file = env_dir.join(".env"); + if env_file.exists() { + let values = parse_env_file(&env_file)?; + builder.add_env_map(target, "app-local .env", values); + } + + let compose_file = env_dir.join("compose.yml"); + load_compose_file( + &compose_file, + "app-local compose", + &BTreeMap::new(), + Some(target), + service_filter, + builder, + )?; + } + + Ok(()) +} + +fn target_matches(target: &str, service_filter: Option<&str>) -> bool { + match service_filter { + Some(service) => service == target, + None => true, + } +} + +fn discover_app_local_dirs( + dir: &Path, + environment: &str, + app_dirs: &mut BTreeSet, +) -> Result<(), CliError> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if name.starts_with('.') || name == "target" { + continue; + } + + let app_env_dir = path.join("docker").join(environment); + if app_env_dir.join("compose.yml").exists() || app_env_dir.join(".env").exists() { + app_dirs.insert(path.clone()); + } + } + + Ok(()) +} + +fn compose_environment_values(value: &serde_yaml::Value) -> BTreeMap { + let mut values = BTreeMap::new(); + + match value { + serde_yaml::Value::Mapping(map) => { + for (key, value) in map { + if let Some(key) = key.as_str() { + values.insert(key.to_string(), yaml_scalar_to_string(value)); + } + } + } + serde_yaml::Value::Sequence(items) => { + for item in items { + let Some(item) = item.as_str() else { + continue; + }; + if let Some((key, value)) = item.split_once('=') { + values.insert(key.to_string(), value.to_string()); + } + } + } + _ => {} + } + + values +} + +fn compose_env_file_paths(value: &serde_yaml::Value) -> Vec { + match value { + serde_yaml::Value::String(path) => vec![PathBuf::from(path)], + serde_yaml::Value::Sequence(items) => items + .iter() + .filter_map(|item| match item { + serde_yaml::Value::String(path) => Some(PathBuf::from(path)), + serde_yaml::Value::Mapping(map) => map + .get("path") + .and_then(serde_yaml::Value::as_str) + .map(PathBuf::from), + _ => None, + }) + .collect(), + serde_yaml::Value::Mapping(map) => map + .get("path") + .and_then(serde_yaml::Value::as_str) + .map(PathBuf::from) + .into_iter() + .collect(), + _ => Vec::new(), + } +} + +fn parse_env_file(path: &Path) -> Result, std::io::Error> { + let content = std::fs::read_to_string(path)?; + let mut values = BTreeMap::new(); + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line.strip_prefix("export ").unwrap_or(line); + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + if key.is_empty() { + continue; + } + values.insert(key.to_string(), unquote_env_value(value.trim()).to_string()); + } + + Ok(values) +} + +fn resolve_relative(base: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + } +} + +fn unquote_env_value(value: &str) -> &str { + value + .strip_prefix('"') + .and_then(|inner| inner.strip_suffix('"')) + .or_else(|| { + value + .strip_prefix('\'') + .and_then(|inner| inner.strip_suffix('\'')) + }) + .unwrap_or(value) +} + +fn yaml_scalar_to_string(value: &serde_yaml::Value) -> String { + match value { + serde_yaml::Value::Null => String::new(), + serde_yaml::Value::Bool(value) => value.to_string(), + serde_yaml::Value::Number(value) => value.to_string(), + serde_yaml::Value::String(value) => value.clone(), + _ => serde_yaml::to_string(value) + .unwrap_or_default() + .trim() + .to_string(), + } +} + +fn is_secret_key(key: &str) -> bool { + let normalized = key.to_ascii_uppercase(); + [ + "SECRET", + "PASSWORD", + "TOKEN", + "PRIVATE_KEY", + "CREDENTIAL", + "DATABASE_URL", + "DB_URL", + ] + .iter() + .any(|marker| normalized.contains(marker)) +} + +fn hash_value(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + fn inventory( + root: &Path, + environment: &str, + service: Option<&str>, + show_values: bool, + ) -> ConfigInventory { + load_inventory( + &root.join("stacker.yml"), + &InventoryOptions { + environment: environment.to_string(), + service: service.map(str::to_string), + show_values, + }, + ) + .unwrap() + } + + fn key<'a>(inventory: &'a ConfigInventory, target: &str, name: &str) -> &'a ConfigKeyInventory { + inventory + .targets + .iter() + .find(|target_inventory| target_inventory.target_code == target) + .and_then(|target_inventory| target_inventory.keys.iter().find(|key| key.key == name)) + .unwrap() + } + + #[test] + fn config_inventory_collects_stackeryml_env_and_service_overrides() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +env: + RUST_LOG: info +app: + environment: + RUST_LOG: debug +services: + upload: + image: upload:latest + environment: + S3_BUCKET: superbucket +"#, + ); + + let inventory = inventory(temp.path(), "prod", None, true); + + assert_eq!( + key(&inventory, "device-api", "RUST_LOG") + .value_preview + .as_deref(), + Some("debug") + ); + assert_eq!( + key(&inventory, "device-api", "RUST_LOG").source, + "stacker.yml app environment" + ); + assert_eq!( + key(&inventory, "upload", "S3_BUCKET").source, + "stacker.yml service environment" + ); + } + + #[test] + fn config_inventory_attributes_top_level_env_file_keys() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + env_file: docker/prod/.env +"#, + ); + write( + &temp.path().join("docker/prod/.env"), + "DATABASE_URL=postgres://db\n", + ); + + let inventory = inventory(temp.path(), "prod", None, false); + + assert_eq!( + key(&inventory, "device-api", "DATABASE_URL").source, + "stacker env_file" + ); + assert!(key(&inventory, "device-api", "DATABASE_URL").secret); + assert_eq!( + key(&inventory, "device-api", "DATABASE_URL").value_preview, + None + ); + } + + #[test] + fn config_inventory_supports_relative_config_path_in_current_directory() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +env: + RUST_LOG: debug +"#, + ); + + let previous_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp.path()).unwrap(); + let result = load_inventory( + Path::new("stacker.yml"), + &InventoryOptions { + environment: "prod".to_string(), + service: None, + show_values: true, + }, + ); + std::env::set_current_dir(previous_dir).unwrap(); + + let inventory = result.unwrap(); + assert_eq!( + key(&inventory, "device-api", "RUST_LOG") + .value_preview + .as_deref(), + Some("debug") + ); + } + + #[test] + fn config_inventory_collects_compose_environment_and_env_file_by_service() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + compose_file: docker/prod/compose.yml +"#, + ); + write( + &temp.path().join("docker/prod/compose.yml"), + r#" +services: + upload: + image: upload:latest + env_file: + - upload.env + environment: + UPLOAD_TMP_DIR: /tmp/upload +"#, + ); + write( + &temp.path().join("docker/prod/upload.env"), + "S3_BUCKET=superbucket\n", + ); + + let inventory = inventory(temp.path(), "prod", None, true); + + assert_eq!( + key(&inventory, "upload", "S3_BUCKET").source, + "compose env_file" + ); + assert_eq!( + key(&inventory, "upload", "UPLOAD_TMP_DIR") + .value_preview + .as_deref(), + Some("/tmp/upload") + ); + } + + #[test] + fn config_inventory_collects_app_local_env_files() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: stack +deploy: + environment: prod +"#, + ); + write( + &temp.path().join("device-api/docker/prod/.env"), + "RUST_LOG=debug\n", + ); + + let inventory = inventory(temp.path(), "prod", None, true); + + assert_eq!( + key(&inventory, "device-api", "RUST_LOG").source, + "app-local .env" + ); + assert_eq!( + key(&inventory, "device-api", "RUST_LOG") + .value_preview + .as_deref(), + Some("debug") + ); + } + + #[test] + fn config_inventory_attributes_app_local_compose_to_app_directory() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: stack +deploy: + environment: prod +"#, + ); + write( + &temp.path().join("device-api/docker/prod/compose.yml"), + r#" +services: + app: + image: device-api:latest + environment: + RUST_LOG: debug +"#, + ); + + let inventory = inventory(temp.path(), "prod", None, true); + + assert_eq!( + key(&inventory, "device-api", "RUST_LOG").source, + "app-local compose environment" + ); + } + + #[test] + fn config_inventory_filters_to_one_service() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +services: + upload: + image: upload:latest + environment: + S3_BUCKET: superbucket +"#, + ); + + let inventory = inventory(temp.path(), "prod", Some("upload"), true); + + assert_eq!(inventory.targets.len(), 1); + assert_eq!(inventory.targets[0].target_code, "upload"); + } + + #[test] + fn config_inventory_redacts_secret_like_values_even_in_json_model() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +env: + API_TOKEN: supersecret +"#, + ); + + let inventory = inventory(temp.path(), "prod", None, true); + let token = key(&inventory, "device-api", "API_TOKEN"); + + assert!(token.secret); + assert!(token.value_hash.is_some()); + assert_eq!(token.value_preview, None); + } + + #[test] + fn config_inventory_reports_missing_compose_env_file_without_panicking() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + compose_file: docker/prod/compose.yml +"#, + ); + write( + &temp.path().join("docker/prod/compose.yml"), + r#" +services: + upload: + image: upload:latest + env_file: missing.env +"#, + ); + + let inventory = inventory(temp.path(), "prod", None, true); + + assert!(inventory + .warnings + .iter() + .any(|warning| warning.contains("docker/prod/missing.env"))); + } + + #[test] + fn config_inventory_service_filter_omits_unrelated_missing_env_file_warnings() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + compose_file: docker/prod/compose.yml +"#, + ); + write( + &temp.path().join("docker/prod/compose.yml"), + r#" +services: + device-api: + image: device-api:latest + env_file: device-api/.env + upload: + image: upload:latest + env_file: upload/.env +"#, + ); + + let inventory = inventory(temp.path(), "prod", Some("upload"), true); + + assert!(inventory + .warnings + .iter() + .any(|warning| warning.contains("docker/prod/upload/.env"))); + assert!(!inventory + .warnings + .iter() + .any(|warning| warning.contains("docker/prod/device-api/.env"))); + } + + #[test] + fn config_inventory_merges_remote_secret_metadata_without_plaintext() { + let mut inventory = ConfigInventory { + environment: "prod".to_string(), + targets: vec![TargetConfigInventory { + target_code: "upload".to_string(), + keys: Vec::new(), + }], + warnings: Vec::new(), + }; + + merge_remote_secret_names( + &mut inventory, + "upload", + vec!["S3_BUCKET".to_string(), "S3_SECRET_KEY".to_string()], + ); + + assert_eq!(inventory.targets[0].keys.len(), 2); + assert!(inventory.targets[0].keys.iter().all(|key| key.secret)); + assert!(inventory.targets[0] + .keys + .iter() + .all(|key| key.value_preview.is_none() && key.value_hash.is_none())); + assert_eq!( + inventory.targets[0].keys[0].source, + "remote secret metadata" + ); + } +} diff --git a/src/cli/config_parser.rs b/src/cli/config_parser.rs index 3459114b..0a3d3236 100644 --- a/src/cli/config_parser.rs +++ b/src/cli/config_parser.rs @@ -684,6 +684,24 @@ pub struct ProjectConfig { pub identity: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ConfigContract { + #[serde(default)] + pub services: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TargetConfigContract { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub required: Vec, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub optional: Vec, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secret: Vec, +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // StackerConfig — the root configuration type // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -732,6 +750,9 @@ pub struct StackerConfig { #[serde(default)] pub env: HashMap, + + #[serde(default)] + pub config_contract: ConfigContract, } impl StackerConfig { @@ -1365,6 +1386,7 @@ impl ConfigBuilder { hooks: self.hooks.unwrap_or_default(), env_file: self.env_file, env: self.env, + config_contract: ConfigContract::default(), }) } } diff --git a/src/cli/config_promote.rs b/src/cli/config_promote.rs new file mode 100644 index 00000000..c1ba8095 --- /dev/null +++ b/src/cli/config_promote.rs @@ -0,0 +1,181 @@ +use std::collections::BTreeSet; +use std::path::Path; + +use serde::Serialize; + +use crate::cli::config_diff::{load_diff, ConfigDiff, DiffItem}; +use crate::cli::error::CliError; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigPromotionPlan { + pub from_environment: String, + pub to_environment: String, + pub service: Option, + pub items: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigPromotionItem { + pub target: String, + pub key: String, + pub secret: bool, + pub from_source: Option, + pub placeholder: String, +} + +pub fn load_promotion_plan( + config_path: &Path, + from_environment: &str, + to_environment: &str, + service: Option, + keys: Vec, +) -> Result { + let diff = load_diff( + config_path, + from_environment, + to_environment, + service.clone(), + )?; + Ok(promotion_plan_from_diff(diff, keys)) +} + +pub fn promotion_plan_from_diff(diff: ConfigDiff, keys: Vec) -> ConfigPromotionPlan { + let key_filter = keys + .into_iter() + .map(|key| key.trim().to_string()) + .filter(|key| !key.is_empty()) + .collect::>(); + let items = diff + .missing_in_to + .iter() + .filter(|item| key_filter.is_empty() || key_filter.contains(&item.key)) + .map(promotion_item) + .collect(); + + ConfigPromotionPlan { + from_environment: diff.from_environment, + to_environment: diff.to_environment, + service: diff.service, + items, + warnings: diff.warnings, + } +} + +impl ConfigPromotionPlan { + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +fn promotion_item(item: &DiffItem) -> ConfigPromotionItem { + ConfigPromotionItem { + target: item.target.clone(), + key: item.key.clone(), + secret: item.secret, + from_source: item.from_source.clone(), + placeholder: format!("{}=", item.key), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + fn promotion(root: &Path, keys: Vec) -> ConfigPromotionPlan { + load_promotion_plan( + &root.join("stacker.yml"), + "local", + "prod", + Some("upload".to_string()), + keys, + ) + .unwrap() + } + + fn write_project(root: &Path) { + write( + &root.join("stacker.yml"), + r#" +name: device-api +environments: + local: + compose_file: docker/local/compose.yml + prod: + compose_file: docker/prod/compose.yml +"#, + ); + write( + &root.join("docker/local/compose.yml"), + r#" +services: + upload: + image: upload:latest + environment: + S3_BUCKET: local-bucket + S3_SECRET_KEY: local-secret + REDIS_URL: redis://local +"#, + ); + write( + &root.join("docker/prod/compose.yml"), + r#" +services: + upload: + image: upload:latest + environment: + REDIS_URL: redis://prod +"#, + ); + } + + #[test] + fn config_promote_plans_placeholders_for_missing_target_keys() { + let temp = TempDir::new().unwrap(); + write_project(temp.path()); + + let plan = promotion(temp.path(), Vec::new()); + + assert_eq!(plan.items.len(), 2); + assert!(plan.items.iter().any(|item| item.key == "S3_BUCKET")); + assert!(plan + .items + .iter() + .any(|item| item.placeholder == "S3_SECRET_KEY=")); + } + + #[test] + fn config_promote_marks_secret_placeholders_without_values() { + let temp = TempDir::new().unwrap(); + write_project(temp.path()); + + let plan = promotion(temp.path(), Vec::new()); + let secret = plan + .items + .iter() + .find(|item| item.key == "S3_SECRET_KEY") + .unwrap(); + + assert!(secret.secret); + assert_eq!(secret.placeholder, "S3_SECRET_KEY="); + } + + #[test] + fn config_promote_respects_key_filter() { + let temp = TempDir::new().unwrap(); + write_project(temp.path()); + + let plan = promotion(temp.path(), vec!["S3_BUCKET".to_string()]); + + assert_eq!(plan.items.len(), 1); + assert_eq!(plan.items[0].key, "S3_BUCKET"); + } +} diff --git a/src/cli/credentials.rs b/src/cli/credentials.rs index d20a514d..7838d0c1 100644 --- a/src/cli/credentials.rs +++ b/src/cli/credentials.rs @@ -264,6 +264,7 @@ fn resolve_auth_url(request: &LoginRequest) -> Result { .clone() .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) .ok_or_else(|| { CliError::ConfigValidation( "Missing auth URL. Pass `stacker login --auth-url --server-url ` or set STACKER_AUTH_URL (or STACKER_API_URL) and STACKER_URL.".to_string(), @@ -276,6 +277,7 @@ fn resolve_server_url(request: &LoginRequest) -> Result { .server_url .clone() .or_else(|| std::env::var("STACKER_URL").ok()) + .or_else(|| crate::cli::user_config::UserConfig::load().server_url) .map(|value| crate::cli::install_runner::normalize_stacker_server_url(&value)) .ok_or_else(|| { CliError::ConfigValidation( @@ -404,6 +406,272 @@ impl fmt::Display for StoredCredentials { } } +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// RFC 8628 Device Authorization Grant helpers +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Strip a trailing `/auth/login` suffix so we always work from the user-service +/// base URL (e.g. `https://try.direct/server/user`). +fn oauth_base_url(auth_url: &str) -> String { + let url = auth_url.trim_end_matches('/'); + for suffix in &["/auth/login", "/server/user/auth/login"] { + if let Some(base) = url.strip_suffix(suffix) { + return base.to_string(); + } + } + url.to_string() +} + +/// Response from `POST /oauth_client/device_authorization`. +struct DeviceAuthResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: String, + expires_in: u64, + interval: u64, +} + +/// RFC 8628 §3.1 — Device Authorization Request. +/// +/// POSTs `{"client_id": "stacker-cli", "provider": ""}` and returns +/// the server-generated codes the CLI needs to display and poll with. +fn request_device_authorization( + base_url: &str, + provider: &str, +) -> Result { + let endpoint = format!("{base_url}/oauth_client/device_authorization"); + + 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 body = serde_json::json!({ "client_id": "stacker-cli", "provider": provider }); + let resp = client + .post(&endpoint) + .json(&body) + .send() + .map_err(|e| CliError::AuthFailed(format!("Network error: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let preview: String = resp.text().unwrap_or_default().chars().take(200).collect(); + return Err(CliError::AuthFailed(format!( + "Device authorization failed ({status}): {preview}" + ))); + } + + let data: serde_json::Value = resp + .json() + .map_err(|e| CliError::AuthFailed(format!("Invalid response: {e}")))?; + let inner = data.get("data").unwrap_or(&data); + + let field = |key: &str| -> Result { + inner + .get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| CliError::AuthFailed(format!("Response missing `{key}`: {data}"))) + }; + + Ok(DeviceAuthResponse { + device_code: field("device_code")?, + user_code: field("user_code")?, + verification_uri: field("verification_uri")?, + verification_uri_complete: field("verification_uri_complete")?, + expires_in: inner + .get("expires_in") + .and_then(|v| v.as_u64()) + .unwrap_or(300), + interval: inner.get("interval").and_then(|v| v.as_u64()).unwrap_or(5), + }) +} + +/// RFC 8628 §3.4 — Device Access Token Request (polling). +/// +/// Polls `POST /oauth_client/device_token` every `interval` seconds. +/// Handles `authorization_pending` (keep waiting), `slow_down` (+5 s), +/// `access_denied`, and `expired_token` per the spec. +fn poll_device_token( + base_url: &str, + device_code: &str, + interval_secs: u64, + expires_in: u64, +) -> Result<(String, Option, Option), CliError> { + let endpoint = format!("{base_url}/oauth_client/device_token"); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| CliError::AuthFailed(format!("HTTP client error: {e}")))?; + + let body = serde_json::json!({ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device_code, + "client_id": "stacker-cli", + }); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(expires_in); + let mut interval = std::time::Duration::from_secs(interval_secs); + + loop { + std::thread::sleep(interval); + + if std::time::Instant::now() >= deadline { + return Err(CliError::AuthFailed( + "Authentication timed out. Please try again.".to_string(), + )); + } + + let resp = match client.post(&endpoint).json(&body).send() { + Ok(r) => r, + Err(_) => continue, // transient network error — keep polling + }; + + let status = resp.status(); + let data: serde_json::Value = resp.json().unwrap_or(serde_json::Value::Null); + + if status.is_success() { + let access_token = data + .get("access_token") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + CliError::AuthFailed("Token response missing access_token".to_string()) + })? + .to_string(); + let refresh_token = data + .get("refresh_token") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let token_expires_in = data.get("expires_in").and_then(|v| v.as_u64()); + return Ok((access_token, refresh_token, token_expires_in)); + } + + // RFC 8628 §3.5 error codes + match data.get("error").and_then(|v| v.as_str()) { + Some("authorization_pending") => {} // normal — keep polling + Some("slow_down") => interval += std::time::Duration::from_secs(5), + Some("access_denied") => { + return Err(CliError::AuthFailed("Access denied by user.".to_string())) + } + Some("expired_token") => { + return Err(CliError::AuthFailed( + "Session expired. Please run `stacker login` again.".to_string(), + )) + } + Some(other) => return Err(CliError::AuthFailed(format!("Auth error: {other}"))), + None => {} // unexpected non-200 without error field — keep polling + } + } +} + +/// Retrieve the authenticated user's email from the user service. +pub fn fetch_user_email(auth_url: &str, access_token: &str) -> Result, CliError> { + let base = oauth_base_url(auth_url); + let endpoint = format!("{base}/oauth_server/api/me"); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| CliError::AuthFailed(format!("HTTP client error: {e}")))?; + + let resp = client + .get(&endpoint) + .bearer_auth(access_token) + .send() + .map_err(|e| CliError::AuthFailed(format!("Network error fetching user profile: {e}")))?; + + if !resp.status().is_success() { + return Ok(None); + } + + let data: serde_json::Value = resp.json().unwrap_or(serde_json::Value::Null); + let email = data + .get("email") + .or_else(|| data.get("user").and_then(|u| u.get("email"))) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Ok(email) +} + +/// RFC 8628 Device Authorization Grant login flow. +/// +/// 1. Requests device + user codes from the server. +/// 2. Shows the user_code and opens the browser to the OAuth URL. +/// 3. Polls until the user authenticates or the session expires. +/// 4. Saves and returns the credentials. +pub fn browser_login( + store: &CredentialsManager, + auth_url: &str, + server_url: &str, + provider: &str, + org: Option<&str>, + domain: Option<&str>, +) -> Result { + let base = oauth_base_url(auth_url); + let device_auth = request_device_authorization(&base, provider)?; + + eprintln!("\nTo sign in, open this URL in your browser:"); + eprintln!(" {}", device_auth.verification_uri_complete); + eprintln!(); + + let opened = { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(&device_auth.verification_uri_complete) + .status() + .is_ok() + } + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open") + .arg(&device_auth.verification_uri_complete) + .status() + .is_ok() + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + false + } + }; + if opened { + eprintln!(" (Browser opened automatically)"); + } + + eprintln!("Waiting for authentication..."); + + let (access_token, refresh_token, token_expires_in) = poll_device_token( + &base, + &device_auth.device_code, + device_auth.interval, + device_auth.expires_in, + )?; + + let email = fetch_user_email(auth_url, &access_token)?; + + let min_ttl = session_ttl_secs(); + let ttl = token_expires_in.unwrap_or(min_ttl).max(min_ttl); + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl as i64); + + let creds = StoredCredentials { + access_token, + refresh_token, + token_type: "Bearer".to_string(), + expires_at, + email, + server_url: Some(crate::cli::install_runner::normalize_stacker_server_url( + server_url, + )), + org: org.map(|s| s.to_string()), + domain: domain.map(|s| s.to_string()), + }; + + store.save(&creds)?; + Ok(creds) +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Tests // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -918,7 +1186,11 @@ mod tests { domain: None, }; + // Isolate from the real ~/.config/stacker/config.yml which may provide a fallback URL. + let tmp = tempfile::tempdir().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", tmp.path()); let err = login(&manager, &oauth, &request).unwrap_err(); + std::env::remove_var("XDG_CONFIG_HOME"); assert!(format!("{err}").contains("Missing auth URL")); } @@ -935,7 +1207,11 @@ mod tests { domain: None, }; + // Isolate from the real ~/.config/stacker/config.yml which may provide a fallback URL. + let tmp = tempfile::tempdir().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", tmp.path()); let err = login(&manager, &oauth, &request).unwrap_err(); + std::env::remove_var("XDG_CONFIG_HOME"); assert!(format!("{err}").contains("Missing Stacker API URL")); } diff --git a/src/cli/debug.rs b/src/cli/debug.rs new file mode 100644 index 00000000..d58cfa74 --- /dev/null +++ b/src/cli/debug.rs @@ -0,0 +1,84 @@ +pub fn cli_debug_enabled() -> bool { + ["DEBUG", "STACKER_DEBUG"].iter().any(|key| { + std::env::var(key) + .map(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) + }) || std::env::var("RUST_LOG") + .map(|value| { + value.split(',').any(|directive| { + let directive = directive.trim(); + directive.eq_ignore_ascii_case("debug") + || directive + .rsplit_once('=') + .is_some_and(|(_, level)| level.eq_ignore_ascii_case("debug")) + }) + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::cli_debug_enabled; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() + } + + fn clear_debug_env() { + std::env::remove_var("DEBUG"); + std::env::remove_var("STACKER_DEBUG"); + std::env::remove_var("RUST_LOG"); + } + + #[test] + fn cli_debug_enabled_accepts_debug_true() { + let _guard = env_lock(); + clear_debug_env(); + std::env::set_var("DEBUG", "true"); + assert!(cli_debug_enabled()); + clear_debug_env(); + } + + #[test] + fn cli_debug_enabled_accepts_stacker_debug_true() { + let _guard = env_lock(); + clear_debug_env(); + std::env::set_var("STACKER_DEBUG", "true"); + assert!(cli_debug_enabled()); + clear_debug_env(); + } + + #[test] + fn cli_debug_enabled_accepts_rust_log_debug() { + let _guard = env_lock(); + clear_debug_env(); + std::env::set_var("RUST_LOG", "debug"); + assert!(cli_debug_enabled()); + clear_debug_env(); + } + + #[test] + fn cli_debug_enabled_accepts_module_rust_log_debug() { + let _guard = env_lock(); + clear_debug_env(); + std::env::set_var("RUST_LOG", "info,stacker=debug"); + assert!(cli_debug_enabled()); + clear_debug_env(); + } + + #[test] + fn cli_debug_enabled_ignores_non_debug_rust_log() { + let _guard = env_lock(); + clear_debug_env(); + std::env::set_var("RUST_LOG", "info,stacker=trace"); + assert!(!cli_debug_enabled()); + clear_debug_env(); + } +} diff --git a/src/cli/error.rs b/src/cli/error.rs index ff890b7f..42c13d2e 100644 --- a/src/cli/error.rs +++ b/src/cli/error.rs @@ -2,6 +2,7 @@ use std::fmt; use std::path::PathBuf; use crate::cli::config_parser::DeployTarget; +use crate::services::TypedErrorEnvelope; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // CliError — unified error hierarchy for all CLI operations @@ -104,6 +105,7 @@ pub enum CliError { // IO errors Io(std::io::Error), + Typed(TypedErrorEnvelope), } impl fmt::Display for CliError { @@ -250,6 +252,9 @@ impl fmt::Display for CliError { Self::Io(err) => { write!(f, "I/O error: {err}") } + Self::Typed(envelope) => { + write!(f, "{}", envelope.to_json()) + } } } } @@ -268,6 +273,12 @@ impl From for CliError { } } +impl From for CliError { + fn from(envelope: TypedErrorEnvelope) -> Self { + Self::Typed(envelope) + } +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // ValidationIssue — structured validation results // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/src/cli/generator/compose.rs b/src/cli/generator/compose.rs index 55fc4e6d..c9e92d02 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -3,7 +3,7 @@ use std::convert::TryFrom; use std::fmt; use std::path::Path; -use crate::cli::config_parser::{AppType, ProxyType, ServiceDefinition, StackerConfig}; +use crate::cli::config_parser::{AppType, DomainConfig, ProxyType, ServiceDefinition, StackerConfig}; use crate::cli::error::CliError; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -22,6 +22,7 @@ pub struct ComposeService { pub depends_on: Vec, pub restart: String, pub networks: Vec, + pub labels: HashMap, /// Container runtime (e.g., "kata"). None or "runc" means default. pub runtime: Option, } @@ -39,6 +40,7 @@ impl Default for ComposeService { depends_on: Vec::new(), restart: "unless-stopped".to_string(), networks: vec!["app-network".to_string()], + labels: HashMap::new(), runtime: None, } } @@ -47,7 +49,7 @@ impl Default for ComposeService { /// Convert a `ServiceDefinition` (from stacker.yml) into a `ComposeService`. impl From<&ServiceDefinition> for ComposeService { fn from(svc: &ServiceDefinition) -> Self { - Self { + let mut compose_service = Self { name: svc.name.clone(), image: Some(svc.image.clone()), ports: svc.ports.clone(), @@ -55,7 +57,16 @@ impl From<&ServiceDefinition> for ComposeService { volumes: svc.volumes.clone(), depends_on: svc.depends_on.clone(), ..Default::default() - } + }; + crate::helpers::stacker_labels::insert_runtime_labels( + &mut compose_service.labels, + None::, + None, + crate::helpers::stacker_labels::SCOPE_PROJECT, + &svc.name, + &svc.name, + ); + compose_service } } @@ -67,6 +78,7 @@ impl From<&ServiceDefinition> for ComposeService { pub struct ComposeDefinition { pub services: Vec, pub networks: Vec, + pub external_networks: Vec, pub volumes: Vec, } @@ -75,6 +87,7 @@ impl Default for ComposeDefinition { Self { services: Vec::new(), networks: vec!["app-network".to_string()], + external_networks: Vec::new(), volumes: Vec::new(), } } @@ -119,6 +132,31 @@ impl TryFrom<&StackerConfig> for ComposeDefinition { // --- 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() + { + let proxied: Vec = config + .proxy + .domains + .iter() + .filter_map(|d| upstream_service_name_from_domain(d)) + .collect(); + + let mut injected = false; + for svc in compose.services.iter_mut() { + if proxied.contains(&svc.name) + && !svc.networks.contains(&"default_network".to_string()) + { + svc.networks.push("default_network".to_string()); + injected = true; + } + } + if injected { + compose.external_networks.push("default_network".to_string()); + } + } + Ok(compose) } } @@ -132,6 +170,14 @@ fn build_app_service(config: &StackerConfig) -> ComposeService { name: "app".to_string(), ..Default::default() }; + crate::helpers::stacker_labels::insert_runtime_labels( + &mut svc.labels, + None::, + None, + crate::helpers::stacker_labels::SCOPE_PROJECT, + "app", + "app", + ); // If user specifies an image directly, use it. if let Some(ref img) = config.app.image { @@ -193,7 +239,7 @@ fn build_proxy_service(config: &StackerConfig) -> Option { Some(svc) } ProxyType::NginxProxyManager => { - let svc = ComposeService { + let mut svc = ComposeService { name: "proxy-manager".to_string(), image: Some("jc21/nginx-proxy-manager:latest".to_string()), ports: vec![ @@ -204,6 +250,14 @@ fn build_proxy_service(config: &StackerConfig) -> Option { depends_on: vec!["app".to_string()], ..Default::default() }; + crate::helpers::stacker_labels::insert_runtime_labels( + &mut svc.labels, + None::, + None, + crate::helpers::stacker_labels::SCOPE_PLATFORM, + "nginx_proxy_manager", + "nginx-proxy-manager", + ); Some(svc) } ProxyType::Traefik => { @@ -222,6 +276,21 @@ fn build_proxy_service(config: &StackerConfig) -> Option { } } +/// Extract the service (host) name from a DomainConfig upstream like `svc:3000` or `http://svc:3000`. +fn upstream_service_name_from_domain(domain: &DomainConfig) -> Option { + let s = domain + .upstream + .trim_start_matches("https://") + .trim_start_matches("http://"); + let host = s.split('/').next()?; + let name = host.split(':').next()?; + if name.is_empty() { + None + } else { + Some(name.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 { @@ -306,15 +375,27 @@ impl ComposeDefinition { } } + if !svc.labels.is_empty() { + out.push_str(" labels:\n"); + let mut keys: Vec<&String> = svc.labels.keys().collect(); + keys.sort(); + for k in keys { + out.push_str(&format!(" {}: \"{}\"\n", k, svc.labels[k])); + } + } + out.push('\n'); } // Top-level networks - if !self.networks.is_empty() { + if !self.networks.is_empty() || !self.external_networks.is_empty() { out.push_str("networks:\n"); for n in &self.networks { out.push_str(&format!(" {}:\n driver: bridge\n", n)); } + for n in &self.external_networks { + out.push_str(&format!(" {}:\n external: true\n", n)); + } out.push('\n'); } @@ -357,7 +438,9 @@ impl fmt::Display for ComposeDefinition { #[cfg(test)] mod tests { use super::*; - use crate::cli::config_parser::{AppSource, ConfigBuilder, DeployConfig, ProxyConfig, SslMode}; + use crate::cli::config_parser::{ + AppSource, ConfigBuilder, DeployConfig, DomainConfig, ProxyConfig, SslMode, + }; use std::collections::HashMap; fn minimal_config(app_type: AppType) -> StackerConfig { @@ -617,6 +700,42 @@ mod tests { ); } + #[test] + fn service_definition_adds_project_scope_labels() { + let svc_def = ServiceDefinition { + name: "smtp".into(), + image: "trydirect/smtp:latest".into(), + ports: Vec::new(), + environment: HashMap::new(), + volumes: Vec::new(), + depends_on: Vec::new(), + }; + + let compose_svc = ComposeService::from(&svc_def); + + assert_eq!( + compose_svc + .labels + .get(crate::helpers::stacker_labels::SCOPE) + .map(String::as_str), + Some("project") + ); + assert_eq!( + compose_svc + .labels + .get(crate::helpers::stacker_labels::SERVICE) + .map(String::as_str), + Some("smtp") + ); + assert_eq!( + compose_svc + .labels + .get(crate::helpers::stacker_labels::DNS) + .map(String::as_str), + Some("smtp") + ); + } + #[test] fn test_extract_named_volume_returns_name() { assert_eq!( @@ -650,6 +769,24 @@ mod tests { assert!(npm.is_some()); let npm = npm.unwrap(); assert!(npm.ports.contains(&"81:81".to_string())); // NPM admin port + assert_eq!( + npm.labels + .get(crate::helpers::stacker_labels::SCOPE) + .map(String::as_str), + Some("platform") + ); + assert_eq!( + npm.labels + .get(crate::helpers::stacker_labels::SERVICE) + .map(String::as_str), + Some("nginx_proxy_manager") + ); + assert_eq!( + npm.labels + .get(crate::helpers::stacker_labels::DNS) + .map(String::as_str), + Some("nginx-proxy-manager") + ); } #[test] @@ -663,6 +800,7 @@ mod tests { let def = ComposeDefinition { services: vec![svc], networks: vec!["app-network".to_string()], + external_networks: vec![], volumes: vec![], }; let output = def.render(); @@ -684,6 +822,7 @@ mod tests { let def = ComposeDefinition { services: vec![svc], networks: vec!["app-network".to_string()], + external_networks: vec![], volumes: vec![], }; let output = def.render(); @@ -694,6 +833,117 @@ mod tests { ); } + #[test] + fn npm_proxy_injects_default_network_into_proxied_service() { + let svc = ServiceDefinition { + name: "api".into(), + image: "myapp:latest".into(), + ports: vec!["8080:8080".into()], + environment: Default::default(), + volumes: vec![], + depends_on: vec![], + }; + let config = ConfigBuilder::new() + .name("npm-proxied") + .app_type(AppType::Node) + .add_service(svc) + .proxy(ProxyConfig { + proxy_type: ProxyType::NginxProxyManager, + auto_detect: false, + domains: vec![DomainConfig { + domain: "api.example.com".into(), + ssl: SslMode::Auto, + upstream: "api:8080".into(), + }], + config: None, + }) + .build() + .unwrap(); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let api_svc = compose.services.iter().find(|s| s.name == "api").unwrap(); + assert!( + api_svc.networks.contains(&"default_network".to_string()), + "proxied service should have default_network, got: {:?}", + api_svc.networks + ); + assert!( + compose.external_networks.contains(&"default_network".to_string()), + "default_network should be declared as external" + ); + let yaml = compose.render(); + assert!(yaml.contains("external: true"), "rendered YAML should declare default_network external:\n{yaml}"); + } + + #[test] + fn npm_proxy_does_not_inject_into_unproxied_service() { + let smtp = ServiceDefinition { + name: "smtp".into(), + image: "trydirect/smtp".into(), + ports: vec![], + environment: Default::default(), + volumes: vec![], + depends_on: vec![], + }; + let config = ConfigBuilder::new() + .name("partial-proxy") + .app_type(AppType::Node) + .add_service(smtp) + .proxy(ProxyConfig { + proxy_type: ProxyType::NginxProxyManager, + auto_detect: false, + domains: vec![DomainConfig { + domain: "app.example.com".into(), + ssl: SslMode::Off, + upstream: "app:3000".into(), + }], + config: None, + }) + .build() + .unwrap(); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let smtp_svc = compose.services.iter().find(|s| s.name == "smtp").unwrap(); + assert!( + !smtp_svc.networks.contains(&"default_network".to_string()), + "unproxied service should not get default_network" + ); + } + + #[test] + fn non_npm_proxy_does_not_inject_default_network() { + let svc = ServiceDefinition { + name: "web".into(), + image: "nginx:latest".into(), + ports: vec![], + environment: Default::default(), + volumes: vec![], + depends_on: vec![], + }; + let config = ConfigBuilder::new() + .name("traefik-app") + .app_type(AppType::Node) + .add_service(svc) + .proxy(ProxyConfig { + proxy_type: ProxyType::Traefik, + auto_detect: false, + domains: vec![DomainConfig { + domain: "web.example.com".into(), + ssl: SslMode::Auto, + upstream: "web:80".into(), + }], + config: None, + }) + .build() + .unwrap(); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + assert!( + compose.external_networks.is_empty(), + "Traefik proxy should not inject default_network" + ); + } + #[test] fn render_excludes_runtime_when_none() { let svc = ComposeService { @@ -705,6 +955,7 @@ mod tests { let def = ComposeDefinition { services: vec![svc], networks: vec!["app-network".to_string()], + external_networks: vec![], volumes: vec![], }; let output = def.render(); diff --git a/src/cli/generator/dockerfile.rs b/src/cli/generator/dockerfile.rs index a043aef2..eb8ed2da 100644 --- a/src/cli/generator/dockerfile.rs +++ b/src/cli/generator/dockerfile.rs @@ -1,7 +1,9 @@ use std::fmt; +use std::path::Path; use crate::cli::config_parser::AppType; use crate::cli::error::CliError; +use serde::Deserialize; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // DockerfileBuilder — generates Dockerfiles from AppType @@ -114,6 +116,41 @@ impl DockerfileBuilder { Self::default() } + pub fn for_project(project_dir: &Path, app_type: AppType) -> Self { + match app_type { + AppType::Node => { + Self::for_node_project(project_dir).unwrap_or_else(|| Self::from(app_type)) + } + _ => Self::from(app_type), + } + } + + fn for_node_project(project_dir: &Path) -> Option { + let package_json = std::fs::read_to_string(project_dir.join("package.json")).ok()?; + let manifest: NodePackageManifest = serde_json::from_str(&package_json).ok()?; + let has_next_dependency = manifest.dependencies.contains_key("next") + || manifest.dev_dependencies.contains_key("next"); + let has_build_script = manifest.scripts.contains_key("build"); + let has_start_script = manifest.scripts.contains_key("start"); + + if has_next_dependency && has_build_script && has_start_script { + return Some( + Self::default() + .base_image("node:20-alpine") + .work_dir("/app") + .env("NEXT_TELEMETRY_DISABLED", "1") + .copy("package*.json", "./") + .run("npm ci") + .copy(".", ".") + .run("npm run build") + .expose(3000) + .cmd(vec!["npm".into(), "run".into(), "start".into()]), + ); + } + + None + } + pub fn base_image>(mut self, image: S) -> Self { self.base_image = image.into(); self @@ -260,6 +297,16 @@ impl DockerfileBuilder { } } +#[derive(Debug, Default, Deserialize)] +struct NodePackageManifest { + #[serde(default)] + scripts: std::collections::BTreeMap, + #[serde(default)] + dependencies: std::collections::BTreeMap, + #[serde(default, rename = "devDependencies")] + dev_dependencies: std::collections::BTreeMap, +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Display — pretty-print Dockerfile to stdout // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -423,6 +470,52 @@ mod tests { assert!(written.contains("FROM node:20-alpine")); } + #[test] + fn test_project_aware_nextjs_node_dockerfile() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{ + "scripts": { + "build": "next build", + "start": "next start -H 0.0.0.0 -p ${PORT:-3000}" + }, + "dependencies": { + "next": "16.2.6" + } + }"#, + ) + .unwrap(); + + let content = DockerfileBuilder::for_project(dir.path(), AppType::Node).build(); + assert!(content.contains("RUN npm ci")); + assert!(content.contains("RUN npm run build")); + assert!(content.contains("CMD [\"npm\", \"run\", \"start\"]")); + assert!(content.contains("ENV NEXT_TELEMETRY_DISABLED=1")); + assert!(!content.contains("CMD [\"node\", \"server.js\"]")); + } + + #[test] + fn test_project_aware_node_falls_back_without_nextjs_hints() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{ + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "express": "^5.0.0" + } + }"#, + ) + .unwrap(); + + let content = DockerfileBuilder::for_project(dir.path(), AppType::Node).build(); + assert!(content.contains("RUN npm ci --production")); + assert!(content.contains("CMD [\"node\", \"server.js\"]")); + } + #[test] fn test_multiple_expose_ports() { let content = DockerfileBuilder::new().expose(80).expose(443).build(); diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs index d1a6eb4a..bd3ba250 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -103,6 +103,12 @@ pub struct DeployContext { /// Environment-specific config files collected from compose env_file and bind mounts. pub config_bundle: Option, + + /// Whether the Stacker-managed proxy role should be requested from Install Service. + pub managed_proxy_feature_enabled: bool, + + /// Whether the user explicitly requested a fresh cloud server (`stacker deploy --force-new`). + pub force_new: bool, } impl DeployContext { @@ -111,6 +117,10 @@ impl DeployContext { } } +fn should_run_managed_proxy_preflight(context: &DeployContext, target: DeployTarget) -> bool { + context.managed_proxy_feature_enabled && !(target == DeployTarget::Cloud && context.force_new) +} + /// Outcome of a successful deployment. #[derive(Debug, Clone)] pub struct DeployResult { @@ -547,6 +557,15 @@ impl DeployStrategy for CloudDeploy { } }; + if should_run_managed_proxy_preflight(context, DeployTarget::Cloud) { + cleanup_stale_managed_proxy_container( + &client, + project.id, + DeployTarget::Cloud, + ) + .await?; + } + // Step 2: Resolve cloud credentials let provider_str = cloud_cfg.provider.to_string(); let provider_code = provider_code_for_remote(&provider_str); @@ -686,7 +705,12 @@ impl DeployStrategy for CloudDeploy { }; // Step 4: Build deploy form - let mut deploy_form = stacker_client::build_deploy_form(config); + let mut deploy_form = stacker_client::build_deploy_form_with_options( + config, + stacker_client::DeployFormOptions { + include_managed_proxy: context.managed_proxy_feature_enabled, + }, + ); if let Some(bundle) = &context.config_bundle { stacker_client::attach_config_bundle_to_deploy_form( &mut deploy_form, @@ -1046,6 +1070,232 @@ fn ensure_remote_cloud_credentials_available( }) } +fn stale_managed_proxy_container_names( + containers: &[serde_json::Value], + app_code: &str, +) -> Vec { + let normalized_code = crate::project_app::normalize_app_code(app_code); + containers + .iter() + .filter_map(|container| { + let name = container.get("name").and_then(|value| value.as_str())?; + let normalized_name = crate::project_app::normalize_app_code(name); + let image = container + .get("image") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_lowercase(); + + let is_project_scoped = normalized_name.starts_with("project_") + && normalized_name.contains(&normalized_code); + let is_duplicate_npm_image = + image.contains("nginx-proxy-manager") && normalized_name != normalized_code; + + if is_project_scoped || is_duplicate_npm_image { + Some(name.to_string()) + } else { + None + } + }) + .collect() +} + +fn stale_managed_proxy_app_codes( + project_apps: &[stacker_client::ProjectAppInfo], + app_code: &str, +) -> Vec { + let normalized_code = crate::project_app::normalize_app_code(app_code); + project_apps + .iter() + .filter(|app| { + crate::project_app::normalize_app_code(&app.code) == normalized_code + || crate::project_app::normalize_app_code(&app.name) == normalized_code + }) + .map(|app| app.code.clone()) + .collect() +} + +async fn wait_for_agent_command_completion( + client: &StackerClient, + deployment_hash: &str, + command_id: &str, + timeout_secs: u64, + target: DeployTarget, +) -> Result { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); + let interval = std::time::Duration::from_secs(2); + let mut last_status = "pending".to_string(); + + loop { + tokio::time::sleep(interval).await; + + if tokio::time::Instant::now() >= deadline { + return Err(CliError::DeployFailed { + target, + reason: format!( + "Timed out waiting for cleanup command '{}' on deployment '{}' (last status: {})", + command_id, deployment_hash, last_status + ), + }); + } + + let status = client + .agent_command_status(deployment_hash, command_id) + .await?; + last_status = status.status.clone(); + + match status.status.as_str() { + "completed" | "failed" => return Ok(status), + _ => continue, + } + } +} + +async fn fetch_live_containers( + client: &StackerClient, + deployment_hash: &str, + target: DeployTarget, +) -> Result, CliError> { + let params = crate::forms::status_panel::ListContainersCommandRequest { + include_health: true, + include_logs: false, + log_lines: 10, + }; + let request = stacker_client::AgentEnqueueRequest::new(deployment_hash, "list_containers") + .with_parameters(¶ms) + .map_err(|error| { + CliError::ConfigValidation(format!("Invalid list_containers parameters: {}", error)) + })?; + + let completed = client.agent_poll_result(&request, 120, 2).await?; + if completed.status != "completed" { + let detail = completed + .error + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown error".to_string()); + return Err(CliError::DeployFailed { + target, + reason: format!("Failed to fetch live containers before deploy: {}", detail), + }); + } + + Ok(completed + .result + .and_then(|result| { + result + .get("containers") + .and_then(|value| value.as_array()) + .cloned() + }) + .unwrap_or_default()) +} + +async fn cleanup_stale_managed_proxy_container( + client: &StackerClient, + project_id: i32, + target: DeployTarget, +) -> Result { + let project_apps = client.list_project_apps(project_id).await?; + let stale_project_app_codes = + stale_managed_proxy_app_codes(&project_apps, "nginx_proxy_manager"); + let deployment = client.get_deployment_status_by_project(project_id).await?; + let stale_container_names = if let Some(deployment) = deployment.as_ref() { + match fetch_live_containers(client, &deployment.deployment_hash, target).await { + Ok(containers) => { + stale_managed_proxy_container_names(&containers, "nginx_proxy_manager") + } + Err(CliError::AgentNotFound { .. }) => Vec::new(), + Err(error) => return Err(error), + } + } else { + Vec::new() + }; + + if stale_project_app_codes.is_empty() && stale_container_names.is_empty() { + return Ok(false); + } + + if !stale_project_app_codes.is_empty() { + eprintln!( + " Found stale managed proxy app registrations ({}); deleting them before deploy...", + stale_project_app_codes.join(", ") + ); + for app_code in &stale_project_app_codes { + client + .delete_project_app( + project_id, + app_code, + deployment + .as_ref() + .map(|value| value.deployment_hash.as_str()), + ) + .await?; + } + } + + if stale_container_names.is_empty() { + eprintln!(" Removed stale managed nginx_proxy_manager project state"); + return Ok(true); + } + + let Some(deployment) = deployment else { + eprintln!(" Removed stale managed nginx_proxy_manager project state"); + return Ok(true); + }; + + eprintln!( + " Found stale managed proxy containers on deployment '{}': {}; removing them before managed proxy restart...", + deployment.deployment_hash, + stale_container_names.join(", ") + ); + + for container_name in &stale_container_names { + let params = crate::forms::status_panel::RemoveAppCommandRequest { + app_code: container_name.clone(), + delete_config: false, + remove_volumes: false, + remove_image: false, + }; + let request = + stacker_client::AgentEnqueueRequest::new(&deployment.deployment_hash, "remove_app") + .with_parameters(¶ms) + .map_err(|error| { + CliError::ConfigValidation(format!("Invalid cleanup parameters: {}", error)) + })?; + + let enqueued = client.agent_enqueue(&request).await?; + let completed = wait_for_agent_command_completion( + client, + &deployment.deployment_hash, + &enqueued.command_id, + 120, + target, + ) + .await?; + + if completed.status != "completed" { + let detail = completed + .error + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown error".to_string()); + return Err(CliError::DeployFailed { + target, + reason: format!( + "Failed to remove stale managed proxy container '{}' before deploy: {}", + container_name, detail + ), + }); + } + } + + if !stale_project_app_codes.is_empty() { + eprintln!(" Removed stale managed nginx_proxy_manager project state and containers"); + } else { + eprintln!(" Removed stale managed nginx_proxy_manager containers"); + } + Ok(true) +} + /// Resolve Docker registry credentials from the stacker.yml `deploy.registry` section /// and/or environment variables. Env vars override config values (same pattern as cloud_token). /// @@ -1417,6 +1667,15 @@ impl DeployStrategy for ServerDeploy { } }; + if should_run_managed_proxy_preflight(context, DeployTarget::Server) { + cleanup_stale_managed_proxy_container( + &client, + project.id, + DeployTarget::Server, + ) + .await?; + } + let existing_server = client.list_servers().await?.into_iter().find(|server| { server.project_id == project.id && (server.srv_ip.as_deref() == Some(server_cfg.host.as_str()) @@ -1447,11 +1706,14 @@ impl DeployStrategy for ServerDeploy { ) }); - let mut deploy_form = stacker_client::build_server_deploy_form( + let mut deploy_form = stacker_client::build_server_deploy_form_with_options( config, server_cfg, &effective_server_name, bootstrap_status_panel, + stacker_client::DeployFormOptions { + include_managed_proxy: context.managed_proxy_feature_enabled, + }, ); if let Some(bundle) = &context.config_bundle { stacker_client::attach_config_bundle_to_deploy_form(&mut deploy_form, bundle); @@ -1735,6 +1997,125 @@ mod tests { .unwrap() } + #[test] + fn test_stale_managed_proxy_container_names_detect_project_scoped_nginx_proxy_manager() { + let containers = vec![ + serde_json::json!({ + "name": "nginx-proxy-manager", + "state": "running", + "image": "jc21/nginx-proxy-manager:latest" + }), + serde_json::json!({ + "name": "project-nginx_proxy_manager-1", + "state": "exited", + "image": "jc21/nginx-proxy-manager:latest" + }), + ]; + + assert_eq!( + stale_managed_proxy_container_names(&containers, "nginx_proxy_manager"), + vec!["project-nginx_proxy_manager-1".to_string()] + ); + } + + #[test] + fn test_stale_managed_proxy_container_names_ignore_managed_container_only() { + let containers = vec![serde_json::json!({ + "name": "nginx-proxy-manager", + "state": "running", + "image": "jc21/nginx-proxy-manager:latest" + })]; + + assert!(stale_managed_proxy_container_names(&containers, "nginx_proxy_manager").is_empty()); + } + + #[test] + fn test_stale_managed_proxy_container_names_detect_duplicate_npm_container_alias() { + let containers = vec![ + serde_json::json!({ + "name": "nginx-proxy-manager", + "state": "running", + "image": "jc21/nginx-proxy-manager:latest" + }), + serde_json::json!({ + "name": "nginx-proxy-manager-app-1", + "state": "running", + "image": "jc21/nginx-proxy-manager:latest" + }), + ]; + + assert_eq!( + stale_managed_proxy_container_names(&containers, "nginx_proxy_manager"), + vec!["nginx-proxy-manager-app-1".to_string()] + ); + } + + #[test] + fn test_stale_managed_proxy_app_codes_detect_nginx_proxy_manager_registration() { + let apps = vec![ + stacker_client::ProjectAppInfo { + id: 1, + project_id: 1, + code: "nginx_proxy_manager".to_string(), + name: "Nginx Proxy Manager".to_string(), + image: "jc21/nginx-proxy-manager".to_string(), + enabled: true, + deploy_order: None, + parent_app_code: None, + }, + stacker_client::ProjectAppInfo { + id: 2, + project_id: 1, + code: "status-panel-web".to_string(), + name: "Status Panel".to_string(), + image: "trydirect/status".to_string(), + enabled: true, + deploy_order: None, + parent_app_code: None, + }, + ]; + + assert_eq!( + stale_managed_proxy_app_codes(&apps, "nginx_proxy_manager"), + vec!["nginx_proxy_manager".to_string()] + ); + } + + #[test] + fn test_stale_managed_proxy_app_codes_match_hyphenated_aliases() { + let apps = vec![stacker_client::ProjectAppInfo { + id: 1, + project_id: 1, + code: "nginx-proxy-manager".to_string(), + name: "Nginx Proxy Manager".to_string(), + image: "jc21/nginx-proxy-manager".to_string(), + enabled: true, + deploy_order: None, + parent_app_code: None, + }]; + + assert_eq!( + stale_managed_proxy_app_codes(&apps, "nginx_proxy_manager"), + vec!["nginx-proxy-manager".to_string()] + ); + } + + #[test] + fn test_stale_managed_proxy_app_codes_ignore_unrelated_apps() { + let apps = vec![stacker_client::ProjectAppInfo { + id: 2, + project_id: 1, + code: "status-panel-web".to_string(), + name: "Status Panel".to_string(), + image: "trydirect/status".to_string(), + enabled: true, + deploy_order: None, + parent_app_code: None, + }]; + + assert!(stale_managed_proxy_app_codes(&apps, "nginx_proxy_manager").is_empty()); + } + #[test] fn test_normalize_user_service_base_url_from_token_endpoint() { let url = normalize_user_service_base_url("https://api.try.direct/oauth_server/token"); @@ -1915,6 +2296,8 @@ mod tests { server_name_override: None, runtime: "runc".to_string(), config_bundle: None, + managed_proxy_feature_enabled: true, + force_new: false, } } @@ -2042,10 +2425,27 @@ mod tests { server_name_override: None, runtime: "runc".to_string(), config_bundle: None, + managed_proxy_feature_enabled: true, + force_new: false, }; assert_eq!(ctx.install_image(), "mycompany/install:v3"); } + #[test] + fn test_should_run_managed_proxy_preflight_skips_force_new_cloud() { + let mut ctx = sample_context(false); + ctx.force_new = true; + + assert!(!should_run_managed_proxy_preflight( + &ctx, + DeployTarget::Cloud + )); + assert!(should_run_managed_proxy_preflight( + &ctx, + DeployTarget::Server + )); + } + #[test] fn test_local_deploy_dry_run() { let config = ConfigBuilder::new().name("local-app").build().unwrap(); diff --git a/src/cli/local_pipe_store.rs b/src/cli/local_pipe_store.rs new file mode 100644 index 00000000..19f963f9 --- /dev/null +++ b/src/cli/local_pipe_store.rs @@ -0,0 +1,642 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use pipe_adapter_sdk::PipeAdapterReference; +use serde::{Deserialize, Serialize}; + +use crate::cli::error::CliError; +use crate::cli::stacker_client::{CreatePipeInstanceApiRequest, CreatePipeTemplateApiRequest}; +use crate::helpers::fs::write_atomic; + +pub const LOCAL_PIPE_SCHEMA_VERSION: u32 = 1; +const LOCAL_PIPE_FILE_MODE: u32 = 0o600; +const LOCAL_PIPE_DIR: &str = ".stacker/pipes"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LocalPipeBinding { + pub selector: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub container: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub adapter: Option, + pub method: String, + pub path: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fields: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LocalPipeTemplate { + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub source_app_type: String, + pub source_endpoint: serde_json::Value, + pub target_app_type: String, + pub target_endpoint: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_external_url: Option, + pub field_mapping: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option, + pub is_public: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LocalPipeInstance { + #[serde(skip_serializing_if = "Option::is_none")] + pub source_adapter: Option, + pub source_container: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_adapter: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_container: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub field_mapping_override: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub config_override: Option, + #[serde(default)] + pub trigger_count: i64, + #[serde(default)] + pub error_count: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_triggered_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct LocalPipePromotion { + #[serde(skip_serializing_if = "Option::is_none")] + pub last_deployment_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_template_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_instance_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub promoted_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct LocalPipeDiagnostics { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, +} + +#[derive(Debug, Clone)] +pub struct NewLocalPipeDocument { + pub name: String, + pub source: LocalPipeBinding, + pub target: LocalPipeBinding, + pub template: LocalPipeTemplate, + pub instance: LocalPipeInstance, + pub diagnostics: LocalPipeDiagnostics, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LocalPipeDocument { + pub schema_version: u32, + pub id: String, + pub name: String, + pub created_at: String, + pub updated_at: String, + pub status: String, + pub source: LocalPipeBinding, + pub target: LocalPipeBinding, + pub template: LocalPipeTemplate, + pub instance: LocalPipeInstance, + #[serde(default)] + pub promotion: LocalPipePromotion, + #[serde(default)] + pub diagnostics: LocalPipeDiagnostics, +} + +impl LocalPipeDocument { + pub fn draft(input: NewLocalPipeDocument) -> Result { + let id = local_pipe_id_from_name(&input.name)?; + let now = Utc::now().to_rfc3339(); + let document = Self { + schema_version: LOCAL_PIPE_SCHEMA_VERSION, + id, + name: input.name, + created_at: now.clone(), + updated_at: now, + status: "draft".to_string(), + source: input.source, + target: input.target, + template: input.template, + instance: input.instance, + promotion: LocalPipePromotion::default(), + diagnostics: input.diagnostics, + }; + document.validate()?; + Ok(document) + } + + pub fn validate(&self) -> Result<(), CliError> { + if self.schema_version != LOCAL_PIPE_SCHEMA_VERSION { + return Err(CliError::ConfigValidation(format!( + "Unsupported local pipe schema version {} for '{}'", + self.schema_version, self.id + ))); + } + if self.name.trim().is_empty() { + return Err(CliError::ConfigValidation( + "Local pipe name cannot be empty".to_string(), + )); + } + validate_local_pipe_id(&self.id)?; + if self.source.selector.trim().is_empty() || self.target.selector.trim().is_empty() { + return Err(CliError::ConfigValidation( + "Local pipe source and target selectors are required".to_string(), + )); + } + if self.instance.source_container.trim().is_empty() { + return Err(CliError::ConfigValidation(format!( + "Local pipe '{}' is missing a source container", + self.id + ))); + } + if self.instance.target_adapter.is_none() + && self.instance.target_container.is_none() + && self.instance.target_url.is_none() + { + return Err(CliError::ConfigValidation(format!( + "Local pipe '{}' must define a target adapter, target container, or target URL", + self.id + ))); + } + + if let Some(adapter) = &self.instance.source_adapter { + validate_adapter_config(adapter.config.as_ref(), "source_adapter.config")?; + } + if let Some(adapter) = &self.instance.target_adapter { + validate_adapter_config(adapter.config.as_ref(), "target_adapter.config")?; + } + validate_adapter_config(self.template.config.as_ref(), "template.config")?; + validate_adapter_config( + self.instance.config_override.as_ref(), + "instance.config_override", + )?; + + Ok(()) + } + + pub fn to_template_request(&self) -> CreatePipeTemplateApiRequest { + CreatePipeTemplateApiRequest { + name: self.name.clone(), + description: self.template.description.clone(), + source_app_type: self.template.source_app_type.clone(), + source_endpoint: self.template.source_endpoint.clone(), + target_app_type: self.template.target_app_type.clone(), + target_endpoint: self.template.target_endpoint.clone(), + target_external_url: self.template.target_external_url.clone(), + field_mapping: self.template.field_mapping.clone(), + config: self.template.config.clone(), + is_public: Some(self.template.is_public), + } + } + + pub fn to_instance_request( + &self, + deployment_hash: String, + template_id: String, + ) -> CreatePipeInstanceApiRequest { + CreatePipeInstanceApiRequest { + deployment_hash: Some(deployment_hash), + source_adapter: self.instance.source_adapter.clone(), + source_container: self.instance.source_container.clone(), + target_adapter: self.instance.target_adapter.clone(), + target_container: self.instance.target_container.clone(), + target_url: self.instance.target_url.clone(), + template_id: Some(template_id), + field_mapping_override: self.instance.field_mapping_override.clone(), + config_override: self.instance.config_override.clone(), + } + } + + pub fn record_promotion( + &mut self, + deployment_hash: &str, + template_id: &str, + instance_id: &str, + ) { + let promoted_at = Utc::now().to_rfc3339(); + self.updated_at = promoted_at.clone(); + self.promotion.last_deployment_hash = Some(deployment_hash.to_string()); + self.promotion.remote_template_id = Some(template_id.to_string()); + self.promotion.remote_instance_id = Some(instance_id.to_string()); + self.promotion.promoted_at = Some(promoted_at); + } + + pub fn effective_field_mapping(&self) -> &serde_json::Value { + self.instance + .field_mapping_override + .as_ref() + .unwrap_or(&self.template.field_mapping) + } + + pub fn set_status(&mut self, status: &str) { + self.status = status.to_string(); + self.updated_at = Utc::now().to_rfc3339(); + } + + pub fn record_trigger_success(&mut self) { + let now = Utc::now().to_rfc3339(); + self.updated_at = now.clone(); + self.instance.last_triggered_at = Some(now); + self.instance.trigger_count += 1; + } + + pub fn record_trigger_failure(&mut self) { + self.instance.error_count += 1; + self.set_status("error"); + } + + pub fn source_display(&self) -> &str { + self.instance + .source_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()) + .unwrap_or(self.instance.source_container.as_str()) + } + + pub fn target_display(&self) -> &str { + self.instance + .target_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()) + .or(self.instance.target_container.as_deref()) + .or(self.instance.target_url.as_deref()) + .unwrap_or("-") + } +} + +#[derive(Debug, Clone)] +pub struct LocalPipeStore { + project_dir: PathBuf, +} + +impl LocalPipeStore { + pub fn new(project_dir: impl Into) -> Self { + Self { + project_dir: project_dir.into(), + } + } + + pub fn pipes_dir(&self) -> PathBuf { + self.project_dir.join(LOCAL_PIPE_DIR) + } + + pub fn pipe_path(&self, id: &str) -> PathBuf { + self.pipes_dir().join(format!("{id}.json")) + } + + pub fn list(&self) -> Result, CliError> { + let dir = self.pipes_dir(); + if !dir.exists() { + return Ok(Vec::new()); + } + + let mut entries = Vec::new(); + for entry in fs::read_dir(&dir).map_err(CliError::Io)? { + let entry = entry.map_err(CliError::Io)?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let content = fs::read_to_string(&path).map_err(CliError::Io)?; + let document: LocalPipeDocument = serde_json::from_str(&content).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to parse local pipe file {}: {}", + path.display(), + err + )) + })?; + document.validate()?; + entries.push(document); + } + + entries.sort_by(|left, right| left.name.to_lowercase().cmp(&right.name.to_lowercase())); + Ok(entries) + } + + pub fn save_new(&self, document: &LocalPipeDocument) -> Result { + document.validate()?; + let path = self.pipe_path(&document.id); + if path.exists() { + return Err(CliError::ConfigValidation(format!( + "Local pipe '{}' already exists at {}", + document.id, + path.display() + ))); + } + + let duplicate_name = self + .list()? + .into_iter() + .find(|existing| existing.name.eq_ignore_ascii_case(&document.name)); + if let Some(existing) = duplicate_name { + return Err(CliError::ConfigValidation(format!( + "Local pipe name '{}' is already used by '{}'. Choose a different name or update the existing local pipe once edit support lands.", + document.name, + existing.id + ))); + } + + self.save(document) + } + + pub fn save(&self, document: &LocalPipeDocument) -> Result { + document.validate()?; + let path = self.pipe_path(&document.id); + let bytes = serde_json::to_vec_pretty(document).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to serialize local pipe '{}': {}", + document.id, err + )) + })?; + write_atomic(&path, &bytes, LOCAL_PIPE_FILE_MODE).map_err(CliError::Io)?; + Ok(path) + } + + pub fn resolve(&self, selector: &str) -> Result { + let matches = self + .list()? + .into_iter() + .filter(|document| document.id == selector || document.name == selector) + .collect::>(); + + match matches.len() { + 0 => Err(CliError::ConfigValidation(format!( + "Local pipe '{}' was not found under {}. Recreate it with `stacker pipe create ` if it only exists in the legacy server-backed local pipe list.", + selector, + self.pipes_dir().display() + ))), + 1 => Ok(matches.into_iter().next().expect("single match")), + _ => Err(CliError::ConfigValidation(format!( + "Local pipe selector '{}' is ambiguous; use the local pipe ID", + selector + ))), + } + } +} + +pub fn local_pipe_id_from_name(name: &str) -> Result { + let mut id = String::with_capacity(name.len()); + let mut previous_was_separator = false; + + for ch in name.trim().chars() { + if ch.is_ascii_alphanumeric() { + id.push(ch.to_ascii_lowercase()); + previous_was_separator = false; + } else if matches!(ch, '-' | '_' | ' ' | '.' | '/' | ':') && !previous_was_separator { + id.push('-'); + previous_was_separator = true; + } + } + + let normalized = id.trim_matches('-').to_string(); + validate_local_pipe_id(&normalized)?; + Ok(normalized) +} + +fn validate_local_pipe_id(id: &str) -> Result<(), CliError> { + if id.is_empty() { + return Err(CliError::ConfigValidation( + "Local pipe ID cannot be empty".to_string(), + )); + } + if !id + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_')) + { + return Err(CliError::ConfigValidation(format!( + "Local pipe ID '{}' must use lowercase ASCII letters, digits, '-' or '_'", + id + ))); + } + Ok(()) +} + +fn validate_adapter_config(value: Option<&serde_json::Value>, path: &str) -> Result<(), CliError> { + if let Some(value) = value { + reject_plaintext_secret_values(value, path)?; + } + Ok(()) +} + +fn reject_plaintext_secret_values(value: &serde_json::Value, path: &str) -> Result<(), CliError> { + match value { + serde_json::Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + reject_plaintext_secret_values(item, &format!("{path}[{index}]"))?; + } + } + serde_json::Value::Object(map) => { + for (key, nested) in map { + let nested_path = format!("{path}.{key}"); + if is_sensitive_adapter_key(key) && !is_secret_reference(nested) { + return Err(CliError::ConfigValidation(format!( + "Sensitive adapter config '{nested_path}' must use a secret reference instead of a plaintext value" + ))); + } + reject_plaintext_secret_values(nested, &nested_path)?; + } + } + _ => {} + } + Ok(()) +} + +fn is_secret_reference(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Object(map) => { + map.contains_key("secret_ref") + || map.contains_key("$env") + || map.contains_key("env") + || (map.contains_key("scope") + && map.contains_key("name") + && (map.contains_key("service") || map.contains_key("app"))) + } + _ => false, + } +} + +fn is_sensitive_adapter_key(key: &str) -> bool { + let lowered = key.trim().to_ascii_lowercase(); + lowered.contains("password") + || lowered.contains("secret") + || lowered.contains("token") + || lowered.contains("credential") + || lowered == "auth" + || lowered.ends_with("_auth") + || lowered.contains("api_key") + || lowered.ends_with("_key") + || lowered.contains("private_key") + || lowered.ends_with("cert") +} + +#[cfg(test)] +mod tests { + use super::*; + use pipe_adapter_sdk::PipeAdapterRole; + use tempfile::TempDir; + + fn sample_document() -> LocalPipeDocument { + LocalPipeDocument::draft(NewLocalPipeDocument { + name: "status-panel-web-to-smtp".to_string(), + source: LocalPipeBinding { + selector: "status-panel-web".to_string(), + container: Some("status-panel-web".to_string()), + adapter: None, + method: "POST".to_string(), + path: "/contact".to_string(), + fields: vec!["email".to_string(), "message".to_string()], + }, + target: LocalPipeBinding { + selector: "smtp".to_string(), + container: Some("smtp".to_string()), + adapter: Some( + PipeAdapterReference::new("smtp") + .with_role(PipeAdapterRole::Target) + .with_config(serde_json::json!({ + "host": "smtp", + "port": 1025, + "to": ["ops@example.com"], + "tls": false + })), + ), + method: "SEND".to_string(), + path: "adapter:smtp".to_string(), + fields: vec!["from_email".to_string(), "body_text".to_string()], + }, + template: LocalPipeTemplate { + description: Some("POST /contact -> SEND adapter:smtp".to_string()), + source_app_type: "status-panel-web".to_string(), + source_endpoint: serde_json::json!({"path": "/contact", "method": "POST"}), + target_app_type: "smtp".to_string(), + target_endpoint: serde_json::json!({ + "mode": "adapter", + "adapter": "smtp", + "display_name": "SMTP target" + }), + target_external_url: None, + field_mapping: serde_json::json!({"body_text": "$.message"}), + config: Some(serde_json::json!({"retry_count": 3})), + is_public: false, + }, + instance: LocalPipeInstance { + source_adapter: None, + source_container: "status-panel-web".to_string(), + target_adapter: Some( + PipeAdapterReference::new("smtp") + .with_role(PipeAdapterRole::Target) + .with_config(serde_json::json!({ + "host": "smtp", + "port": 1025, + "to": ["ops@example.com"], + "tls": false + })), + ), + target_container: Some("smtp".to_string()), + target_url: None, + field_mapping_override: None, + config_override: None, + trigger_count: 0, + error_count: 0, + last_triggered_at: None, + }, + diagnostics: LocalPipeDiagnostics { + notes: vec!["local discovery cached".to_string()], + }, + }) + .expect("sample local pipe should be valid") + } + + #[test] + fn local_pipe_id_is_slugified() { + assert_eq!( + local_pipe_id_from_name("Status Panel Web: SMTP / Prod").unwrap(), + "status-panel-web-smtp-prod" + ); + } + + #[test] + fn store_round_trips_local_pipe_document() { + let dir = TempDir::new().unwrap(); + let store = LocalPipeStore::new(dir.path()); + let document = sample_document(); + + let path = store.save_new(&document).unwrap(); + assert!(path.ends_with("status-panel-web-to-smtp.json")); + + let stored = store.resolve("status-panel-web-to-smtp").unwrap(); + assert_eq!(stored.id, document.id); + assert_eq!(stored.name, document.name); + assert_eq!(stored.instance.target_container, Some("smtp".to_string())); + } + + #[test] + fn duplicate_name_is_rejected() { + let dir = TempDir::new().unwrap(); + let store = LocalPipeStore::new(dir.path()); + let first = sample_document(); + let second = sample_document(); + + store.save_new(&first).unwrap(); + let err = store.save_new(&second).unwrap_err(); + assert!(err.to_string().contains("already exists")); + } + + #[test] + fn plaintext_secret_values_are_rejected() { + let result = LocalPipeDocument::draft(NewLocalPipeDocument { + instance: LocalPipeInstance { + target_adapter: Some( + PipeAdapterReference::new("smtp") + .with_role(PipeAdapterRole::Target) + .with_config(serde_json::json!({ + "host": "smtp", + "password": "super-secret" + })), + ), + ..sample_document().instance + }, + ..NewLocalPipeDocument { + name: sample_document().name, + source: sample_document().source, + target: sample_document().target, + template: sample_document().template, + instance: sample_document().instance, + diagnostics: sample_document().diagnostics, + } + }); + + let err = result.unwrap_err(); + assert!(err + .to_string() + .contains("must use a secret reference instead of a plaintext value")); + } + + #[test] + fn secret_reference_values_are_allowed() { + let mut document = sample_document(); + document.instance.target_adapter = Some( + PipeAdapterReference::new("smtp") + .with_role(PipeAdapterRole::Target) + .with_config(serde_json::json!({ + "host": "smtp", + "password": { + "secret_ref": { + "scope": "service", + "service": "smtp", + "name": "SMTP_PASSWORD" + } + } + })), + ); + + assert!(document.validate().is_ok()); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 78837467..bb203dd0 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2,12 +2,20 @@ pub mod ai_client; pub mod ai_field_matcher; pub mod ai_pipe_suggest; pub mod ai_scanner; +pub mod ai_scenarios; pub mod ci_export; pub mod cloud_env; +pub mod compose_service_sync; pub mod compose_targets; pub mod config_bundle; +pub mod config_check; +pub mod config_contract; +pub mod config_diff; +pub mod config_inventory; pub mod config_parser; +pub mod config_promote; pub mod credentials; +pub mod debug; pub mod deployment_lock; pub mod detector; pub mod error; @@ -16,9 +24,12 @@ pub mod fmt; pub mod generator; pub mod install_runner; pub mod local_compose; +pub mod local_pipe_store; pub mod ml_field_matcher; pub mod progress; pub mod proxy_manager; pub mod runtime; pub mod service_catalog; +pub mod service_import; pub mod stacker_client; +pub mod user_config; diff --git a/src/cli/progress.rs b/src/cli/progress.rs index aa383002..3858d280 100644 --- a/src/cli/progress.rs +++ b/src/cli/progress.rs @@ -76,11 +76,19 @@ pub fn update_message(pb: &ProgressBar, msg: &str) { /// Return a status icon for a deployment status string. pub fn status_icon(status: &str) -> &'static str { match status { + // Deployment statuses "completed" | "confirmed" => "✓", "failed" | "error" | "cancelled" => "✗", "in_progress" => "⟳", "pending" | "wait_start" => "◷", "paused" | "wait_resume" => "⏸", + // Agent statuses + "online" => "●", + "offline" | "disconnected" => "○", + // Container states (Docker) + "running" => "▶", + "exited" | "stopped" | "dead" => "■", + "restarting" | "created" => "⟳", _ => "?", } } @@ -111,6 +119,13 @@ mod tests { assert_eq!(status_icon("in_progress"), "⟳"); assert_eq!(status_icon("pending"), "◷"); assert_eq!(status_icon("paused"), "⏸"); + // Agent statuses + assert_eq!(status_icon("online"), "●"); + assert_eq!(status_icon("offline"), "○"); + // Container states + assert_eq!(status_icon("running"), "▶"); + assert_eq!(status_icon("exited"), "■"); + assert_eq!(status_icon("restarting"), "⟳"); assert_eq!(status_icon("unknown_status"), "?"); } } diff --git a/src/cli/proxy_manager.rs b/src/cli/proxy_manager.rs index 62288275..4ab4d61d 100644 --- a/src/cli/proxy_manager.rs +++ b/src/cli/proxy_manager.rs @@ -283,6 +283,7 @@ pub fn generate_nginx_server_block(domain: &DomainConfig) -> Result Result Result Result String { + if upstream.starts_with("http://") || upstream.starts_with("https://") { + upstream.to_string() + } else { + format!("http://{}", upstream) + } +} + /// Generate nginx configs for all domains in a proxy config. /// Returns a map of `filename → config content` for writing to `./nginx/conf.d/`. pub fn generate_nginx_configs( @@ -544,6 +553,18 @@ mod tests { assert!(!block.contains("443")); } + #[test] + fn test_generate_nginx_server_block_keeps_upstream_scheme() { + let domain = DomainConfig { + domain: "app.local".to_string(), + ssl: SslMode::Off, + upstream: "http://app:8080".to_string(), + }; + let block = generate_nginx_server_block(&domain).unwrap(); + assert!(block.contains("proxy_pass http://app:8080;")); + assert!(!block.contains("proxy_pass http://http://app:8080;")); + } + #[test] fn test_generate_nginx_configs_multiple_domains() { let domains = vec![ diff --git a/src/cli/service_catalog.rs b/src/cli/service_catalog.rs index bedad030..5ad3780e 100644 --- a/src/cli/service_catalog.rs +++ b/src/cli/service_catalog.rs @@ -156,6 +156,7 @@ impl ServiceCatalog { "mq" | "rabbit" | "rabbitmq" => "rabbitmq".to_string(), "npm" | "nginx-proxy-manager" => "nginx_proxy_manager".to_string(), "pma" | "phpmyadmin" => "phpmyadmin".to_string(), + "mail" | "mailer" | "smtp" => "smtp".to_string(), "mh" | "mailhog" => "mailhog".to_string(), "rc" | "rocketchat" | "rocket.chat" | "rocket-chat" => "rocketchat".to_string(), "mm" | "mattermost" => "mattermost".to_string(), @@ -467,6 +468,28 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec!["mysql".into()], }, + CatalogEntry { + code: "smtp".into(), + name: "SMTP Test Server".into(), + category: "mail".into(), + description: "Attachable SMTP companion app for local delivery and relay testing" + .into(), + service: ServiceDefinition { + name: "smtp".into(), + image: "trydirect/smtp".into(), + ports: vec!["1025:25".into()], + environment: HashMap::from([ + ( + "RELAY_NETWORKS".into(), + ":127.0.0.0/8:10.0.0.0/8:172.16.0.0/12:192.168.0.0/16".into(), + ), + ("PORT".into(), "25".into()), + ]), + volumes: vec!["smtp_data:/data".into()], + depends_on: vec![], + }, + related: vec![], + }, CatalogEntry { code: "mailhog".into(), name: "MailHog".into(), @@ -557,7 +580,7 @@ pub fn catalog_summary_for_ai() -> String { )); } lines.push(String::new()); - lines.push("Common aliases: wp→wordpress, pg→postgres, my→mysql, mongo→mongodb, es→elasticsearch, mq→rabbitmq, pma→phpmyadmin, mh→mailhog".to_string()); + lines.push("Common aliases: wp→wordpress, pg→postgres, my→mysql, mongo→mongodb, es→elasticsearch, mq→rabbitmq, pma→phpmyadmin, smtp→smtp, mail→smtp, mh→mailhog".to_string()); lines.join("\n") } @@ -592,6 +615,13 @@ mod tests { ); } + #[test] + fn test_resolve_alias_smtp_companion() { + assert_eq!(ServiceCatalog::resolve_alias("smtp"), "smtp"); + assert_eq!(ServiceCatalog::resolve_alias("mail"), "smtp"); + assert_eq!(ServiceCatalog::resolve_alias("mailer"), "smtp"); + } + #[test] fn test_hardcoded_catalog_not_empty() { let catalog = build_hardcoded_catalog(); @@ -611,6 +641,29 @@ mod tests { assert!(e.service.ports.contains(&"5432:5432".to_string())); } + #[test] + fn test_lookup_hardcoded_smtp_companion() { + let cat = ServiceCatalog::offline(); + let entry = cat.lookup_hardcoded("smtp").expect("smtp service exists"); + + assert_eq!(entry.category, "mail"); + assert_eq!(entry.service.name, "smtp"); + assert_eq!(entry.service.image, "trydirect/smtp"); + assert!(entry.service.ports.contains(&"1025:25".to_string())); + assert_eq!( + entry.service.environment.get("PORT").map(String::as_str), + Some("25") + ); + assert_eq!( + entry + .service + .environment + .get("RELAY_NETWORKS") + .map(String::as_str), + Some(":127.0.0.0/8:10.0.0.0/8:172.16.0.0/12:192.168.0.0/16") + ); + } + #[test] fn test_lookup_hardcoded_unknown() { let cat = ServiceCatalog::offline(); @@ -623,6 +676,7 @@ mod tests { assert!(summary.contains("postgres")); assert!(summary.contains("wordpress")); assert!(summary.contains("redis")); + assert!(summary.contains("smtp")); assert!(summary.contains("add_service")); } } diff --git a/src/cli/service_import.rs b/src/cli/service_import.rs new file mode 100644 index 00000000..a57ffb49 --- /dev/null +++ b/src/cli/service_import.rs @@ -0,0 +1,669 @@ +//! Review-first Docker Compose service import helpers. +//! +//! This module is intentionally pure: it parses Compose YAML and builds a +//! review model plus `ServiceDefinition`s, but never executes Compose content +//! and never mutates `stacker.yml`. + +use std::collections::{BTreeSet, HashMap}; +use std::path::Path; + +use serde::Serialize; +use serde_yaml::{Mapping, Value}; + +use crate::cli::config_parser::ServiceDefinition; +use crate::cli::error::CliError; + +const SUPPORTED_FIELDS: &[&str] = &["image", "ports", "environment", "volumes", "depends_on"]; +const RISK_FIELDS: &[&str] = &[ + "build", + "cap_add", + "devices", + "extra_hosts", + "ipc", + "network_mode", + "pid", + "privileged", + "security_opt", +]; +const MAIL_PORTS: &[&str] = &["25", "465", "587", "993"]; + +#[derive(Debug, Clone)] +pub struct ComposeImportRequest { + pub import_name: String, + pub selected_service: Option, + pub renames: Vec<(String, String)>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ServiceImportReview { + pub import_name: String, + pub services: Vec, + pub risks: Vec, + pub guidance: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ImportedServiceReview { + pub source_name: String, + pub name: String, + pub image: String, + pub ports: Vec, + pub environment_keys: Vec, + pub environment: HashMap, + pub volumes: Vec, + pub depends_on: Vec, + pub unsupported_fields: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ImportRisk { + pub service: String, + pub kind: String, + pub detail: String, +} + +#[derive(Debug, Clone)] +pub struct ServiceImportPlan { + pub review: ServiceImportReview, + pub services: Vec, +} + +pub fn import_plan_from_compose_file( + compose_path: &Path, + request: &ComposeImportRequest, +) -> Result { + let content = std::fs::read_to_string(compose_path).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to read compose file '{}': {}", + compose_path.display(), + err + )) + })?; + import_plan_from_compose_str(&content, request) +} + +pub fn import_plan_from_compose_str( + compose_yaml: &str, + request: &ComposeImportRequest, +) -> Result { + let document: Value = serde_yaml::from_str(compose_yaml).map_err(|err| { + CliError::ConfigValidation(format!("Failed to parse Docker Compose YAML: {err}")) + })?; + let service_map = document + .get(Value::String("services".to_string())) + .and_then(Value::as_mapping) + .ok_or_else(|| { + CliError::ConfigValidation( + "Docker Compose file must contain a top-level 'services' mapping".to_string(), + ) + })?; + + let mut services = Vec::new(); + let mut reviews = Vec::new(); + let mut risks = Vec::new(); + let mut has_mail_server_shape = false; + let rename_map = request + .renames + .iter() + .cloned() + .collect::>(); + + for (name, definition) in service_map { + let Some(source_name) = name.as_str() else { + continue; + }; + if let Some(selected) = &request.selected_service { + if selected != source_name { + continue; + } + } + + let definition = definition.as_mapping().ok_or_else(|| { + CliError::ConfigValidation(format!("Compose service '{source_name}' must be a mapping")) + })?; + let image = mapping_string(definition, "image").ok_or_else(|| { + CliError::ConfigValidation(format!( + "Compose service '{source_name}' must use an image; build-only imports are not supported yet" + )) + })?; + let destination_name = destination_service_name(source_name, request, &rename_map); + let ports = mapping_sequence(definition, "ports") + .into_iter() + .filter_map(compose_port_to_string) + .collect::>(); + let environment = sanitized_compose_environment(definition); + let mut environment_keys = environment.keys().cloned().collect::>(); + environment_keys.sort(); + let volumes = mapping_sequence(definition, "volumes") + .into_iter() + .filter_map(compose_volume_to_string) + .collect::>(); + let depends_on = compose_depends_on(definition) + .into_iter() + .map(|dependency| rename_map.get(&dependency).cloned().unwrap_or(dependency)) + .collect::>(); + let unsupported_fields = unsupported_fields(definition); + + risks.extend(classify_risks( + source_name, + definition, + &ports, + &volumes, + &environment_keys, + )); + has_mail_server_shape |= looks_like_mail_server(source_name, &image, &ports); + + services.push(ServiceDefinition { + name: destination_name.clone(), + image: image.clone(), + ports: ports.clone(), + environment: environment.clone(), + volumes: volumes.clone(), + depends_on: depends_on.clone(), + }); + reviews.push(ImportedServiceReview { + source_name: source_name.to_string(), + name: destination_name, + image, + ports, + environment_keys, + environment: redacted_environment(&environment), + volumes, + depends_on, + unsupported_fields, + }); + } + + if let Some(selected) = &request.selected_service { + if services.is_empty() { + return Err(CliError::ConfigValidation(format!( + "Compose service '{selected}' was not found" + ))); + } + } else if services.is_empty() { + return Err(CliError::ConfigValidation( + "No importable image-backed Compose services were found".to_string(), + )); + } + + let mut guidance = Vec::new(); + if has_mail_server_shape { + guidance.extend(docker_mailserver_guidance()); + } + + Ok(ServiceImportPlan { + review: ServiceImportReview { + import_name: request.import_name.clone(), + services: reviews, + risks, + guidance, + }, + services, + }) +} + +pub fn parse_renames(values: &[String]) -> Result, CliError> { + values + .iter() + .map(|value| { + let (from, to) = value.split_once('=').ok_or_else(|| { + CliError::ConfigValidation(format!( + "Invalid --rename '{value}'. Expected format: old=new" + )) + })?; + if from.trim().is_empty() || to.trim().is_empty() { + return Err(CliError::ConfigValidation(format!( + "Invalid --rename '{value}'. Service names cannot be empty" + ))); + } + Ok((from.trim().to_string(), to.trim().to_string())) + }) + .collect() +} + +fn destination_service_name( + source_name: &str, + request: &ComposeImportRequest, + rename_map: &HashMap, +) -> String { + if let Some(renamed) = rename_map.get(source_name) { + return renamed.clone(); + } + + if request.selected_service.is_some() { + return request.import_name.clone(); + } + + source_name.to_string() +} + +fn mapping_string(mapping: &Mapping, key: &str) -> Option { + mapping + .get(Value::String(key.to_string())) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +fn mapping_sequence<'a>(mapping: &'a Mapping, key: &str) -> Vec<&'a Value> { + mapping + .get(Value::String(key.to_string())) + .and_then(Value::as_sequence) + .map(|values| values.iter().collect()) + .unwrap_or_default() +} + +fn mapping_scalar(mapping: &Mapping, key: &str) -> Option { + mapping + .get(Value::String(key.to_string())) + .map(yaml_scalar_to_string) + .filter(|value| !value.is_empty()) +} + +fn yaml_scalar_to_string(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => value.clone(), + _ => serde_yaml::to_string(value) + .unwrap_or_default() + .trim() + .to_string(), + } +} + +fn compose_port_to_string(value: &Value) -> Option { + if let Some(port) = value.as_str() { + return Some(port.to_string()); + } + + let map = value.as_mapping()?; + let target = mapping_scalar(map, "target")?; + let published = mapping_scalar(map, "published"); + Some(match published { + Some(published) => format!("{published}:{target}"), + None => target, + }) +} + +fn compose_volume_to_string(value: &Value) -> Option { + if let Some(volume) = value.as_str() { + return Some(volume.to_string()); + } + + let map = value.as_mapping()?; + let target = mapping_scalar(map, "target")?; + let source = mapping_scalar(map, "source").unwrap_or_default(); + let read_only = map + .get(Value::String("read_only".to_string())) + .and_then(Value::as_bool) + .unwrap_or(false); + + Some(if read_only { + format!("{source}:{target}:ro") + } else if source.is_empty() { + target + } else { + format!("{source}:{target}") + }) +} + +fn sanitized_compose_environment(mapping: &Mapping) -> HashMap { + let mut environment = HashMap::new(); + let Some(value) = mapping.get(Value::String("environment".to_string())) else { + return environment; + }; + + if let Some(map) = value.as_mapping() { + for (key, value) in map { + if let Some(key) = key.as_str() { + environment.insert(key.to_string(), sanitized_env_value(key, value)); + } + } + return environment; + } + + if let Some(sequence) = value.as_sequence() { + for item in sequence { + if let Some(entry) = item.as_str() { + match entry.split_once('=') { + Some((key, value)) => { + environment.insert( + key.to_string(), + sanitized_env_value_from_string(key, value.to_string()), + ); + } + None => { + environment.insert(entry.to_string(), String::new()); + } + } + } + } + } + + environment +} + +fn sanitized_env_value(key: &str, value: &Value) -> String { + sanitized_env_value_from_string(key, yaml_scalar_to_string(value)) +} + +fn sanitized_env_value_from_string(key: &str, value: String) -> String { + if is_sensitive_env_key(key) && !is_placeholder_value(&value) && !value.is_empty() { + format!("${{{key}}}") + } else { + value + } +} + +pub fn redacted_environment(environment: &HashMap) -> HashMap { + environment + .iter() + .map(|(key, value)| { + let redacted = if is_sensitive_env_key(key) { + "".to_string() + } else { + value.clone() + }; + (key.clone(), redacted) + }) + .collect() +} + +fn is_placeholder_value(value: &str) -> bool { + value.starts_with("${") && value.ends_with('}') +} + +fn is_sensitive_env_key(key: &str) -> bool { + let upper = key.to_ascii_uppercase(); + upper.contains("PASSWORD") + || upper.contains("PASS") + || upper.contains("SECRET") + || upper.contains("TOKEN") + || upper.contains("KEY") + || upper.contains("CREDENTIAL") + || upper.contains("PRIVATE") +} + +fn compose_depends_on(mapping: &Mapping) -> Vec { + let Some(value) = mapping.get(Value::String("depends_on".to_string())) else { + return Vec::new(); + }; + + if let Some(sequence) = value.as_sequence() { + return sequence + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect(); + } + + value + .as_mapping() + .map(|depends_on| { + depends_on + .keys() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default() +} + +fn unsupported_fields(mapping: &Mapping) -> Vec { + let supported = SUPPORTED_FIELDS + .iter() + .copied() + .collect::>(); + mapping + .keys() + .filter_map(Value::as_str) + .filter(|key| !supported.contains(key)) + .map(ToOwned::to_owned) + .collect::>() + .into_iter() + .collect() +} + +fn classify_risks( + service_name: &str, + mapping: &Mapping, + ports: &[String], + volumes: &[String], + environment_keys: &[String], +) -> Vec { + let mut risks = Vec::new(); + + for field in RISK_FIELDS { + if mapping.contains_key(Value::String((*field).to_string())) { + if *field == "privileged" + && !mapping + .get(Value::String("privileged".to_string())) + .and_then(Value::as_bool) + .unwrap_or(false) + { + continue; + } + if (*field == "network_mode" + && mapping_string(mapping, "network_mode").as_deref() == Some("host")) + || *field != "network_mode" + { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: (*field).to_string(), + detail: format!("Compose field '{field}' can weaken container isolation"), + }); + } + } + } + + if mapping_string(mapping, "pid").as_deref() == Some("host") { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: "pid_host".to_string(), + detail: "Service uses host PID namespace".to_string(), + }); + } + if mapping_string(mapping, "ipc").as_deref() == Some("host") { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: "ipc_host".to_string(), + detail: "Service uses host IPC namespace".to_string(), + }); + } + + for volume in volumes { + let host_part = volume.split(':').next().unwrap_or_default(); + if host_part == "/var/run/docker.sock" { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: "docker_socket_mount".to_string(), + detail: "Mounts /var/run/docker.sock, which can grant host-level Docker control" + .to_string(), + }); + } else if host_part.starts_with('/') { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: "absolute_host_path".to_string(), + detail: format!("Uses absolute host path mount '{host_part}'"), + }); + } + } + + for key in environment_keys { + if is_sensitive_env_key(key) { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: "sensitive_env_name".to_string(), + detail: format!("Environment key '{key}' looks sensitive; value will be redacted"), + }); + } + } + + for port in ports { + if published_port(port).is_some() { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: "public_port".to_string(), + detail: format!("Publishes host port '{port}'"), + }); + } + } + + risks +} + +fn published_port(port: &str) -> Option<&str> { + let mut parts = port.split(':'); + let first = parts.next()?; + let second = parts.next(); + second.map(|_| first) +} + +fn looks_like_mail_server(service_name: &str, image: &str, ports: &[String]) -> bool { + let identity = format!("{service_name} {image}").to_ascii_lowercase(); + identity.contains("docker-mailserver") + || identity.contains("mailserver") + || ports.iter().any(|port| { + let public = published_port(port).unwrap_or(port); + MAIL_PORTS.contains(&public) + }) +} + +fn docker_mailserver_guidance() -> Vec { + vec![ + "Mail server imports require DNS MX, SPF, DKIM, DMARC, PTR/rDNS records before production use.".to_string(), + "Confirm your provider allows SMTP egress, especially port 25; many clouds block it by default.".to_string(), + "Open only the required firewall ports (commonly 25, 465, 587, 993) and keep mail data on persistent volumes.".to_string(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> ComposeImportRequest { + ComposeImportRequest { + import_name: "smtp".to_string(), + selected_service: Some("mailserver".to_string()), + renames: vec![("mailserver".to_string(), "smtp".to_string())], + } + } + + #[test] + fn parses_compose_service_into_stacker_definition() { + let plan = import_plan_from_compose_str( + r#" +services: + mailserver: + image: docker.io/mailserver/docker-mailserver:latest + ports: + - "25:25" + - target: 993 + published: 993 + environment: + OVERRIDE_HOSTNAME: mail.example.com + ACCOUNT_PASSWORD: super-secret + DKIM_PRIVATE_KEY: ${DKIM_PRIVATE_KEY} + volumes: + - maildata:/var/mail + depends_on: + redis: + condition: service_started +"#, + &request(), + ) + .unwrap(); + + let service = &plan.services[0]; + assert_eq!(service.name, "smtp"); + assert_eq!( + service.image, + "docker.io/mailserver/docker-mailserver:latest" + ); + assert_eq!(service.ports, vec!["25:25", "993:993"]); + assert_eq!( + service.environment.get("ACCOUNT_PASSWORD").unwrap(), + "${ACCOUNT_PASSWORD}" + ); + assert_eq!( + service.environment.get("DKIM_PRIVATE_KEY").unwrap(), + "${DKIM_PRIVATE_KEY}" + ); + assert_eq!(service.depends_on, vec!["redis"]); + assert!(!plan.review.guidance.is_empty()); + } + + #[test] + fn classifies_risky_compose_fields() { + let plan = import_plan_from_compose_str( + r#" +services: + mailserver: + image: docker.io/mailserver/docker-mailserver:latest + privileged: true + network_mode: host + pid: host + ipc: host + cap_add: [NET_ADMIN] + devices: ["/dev/net/tun:/dev/net/tun"] + extra_hosts: ["host.docker.internal:host-gateway"] + security_opt: ["apparmor:unconfined"] + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /srv/mail:/var/mail + environment: + API_TOKEN: abc + ports: + - "587:587" +"#, + &request(), + ) + .unwrap(); + + let kinds = plan + .review + .risks + .iter() + .map(|risk| risk.kind.as_str()) + .collect::>(); + for expected in [ + "privileged", + "network_mode", + "pid", + "ipc", + "pid_host", + "ipc_host", + "cap_add", + "devices", + "extra_hosts", + "security_opt", + "docker_socket_mount", + "absolute_host_path", + "sensitive_env_name", + "public_port", + ] { + assert!(kinds.contains(expected), "missing risk {expected}"); + } + } + + #[test] + fn redacts_secret_like_environment_values_in_review() { + let plan = import_plan_from_compose_str( + r#" +services: + mailserver: + image: mail:latest + environment: + PASSWORD: literal + PUBLIC_NAME: example +"#, + &request(), + ) + .unwrap(); + + let review_env = &plan.review.services[0].environment; + assert_eq!(review_env.get("PASSWORD").unwrap(), ""); + assert_eq!(review_env.get("PUBLIC_NAME").unwrap(), "example"); + } +} diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index d415e495..8359f8c6 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -9,8 +9,13 @@ //! All endpoints require `Authorization: Bearer ` from `stacker login`. use crate::cli::config_parser::DeployTarget; +use crate::cli::debug::cli_debug_enabled; use crate::cli::error::CliError; use crate::handoff::{DeploymentHandoffPayload, DeploymentHandoffResolveRequest}; +use crate::services::{ + DeployPlan, DeployPlanOperation, DeploymentEventFeed, DeploymentState, TypedErrorEnvelope, +}; +use pipe_adapter_sdk::PipeAdapterReference; use serde::{Deserialize, Serialize}; /// Default Stacker server base URL (distinct from the User Service auth URL). @@ -39,6 +44,40 @@ struct ApiResponse { pub meta: Option, } +fn parse_typed_error_response(body: &str) -> Option { + serde_json::from_str(body).ok() +} + +fn stacker_api_failure(action: &str, status: u16, body: &str) -> String { + stacker_api_failure_with_message( + "Stacker server request failed", + action, + status, + body, + cli_debug_enabled(), + ) +} + +fn stacker_api_failure_with_debug(action: &str, status: u16, body: &str, debug: bool) -> String { + stacker_api_failure_with_message("Stacker server request failed", action, status, body, debug) +} + +fn stacker_api_failure_with_message( + summary: &str, + action: &str, + status: u16, + body: &str, + debug: bool, +) -> String { + if debug { + format!("Stacker server {action} failed ({status}): {body}") + } else { + format!( + "{summary} ({status}). Rerun with DEBUG=true or RUST_LOG=debug for endpoint details." + ) + } +} + /// Project as returned by `/project` endpoints #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectInfo { @@ -277,8 +316,12 @@ pub struct PipeInstanceInfo { #[serde(default)] pub template_id: Option, pub deployment_hash: String, + #[serde(default)] + pub source_adapter: Option, pub source_container: String, #[serde(default)] + pub target_adapter: Option, + #[serde(default)] pub target_container: Option, #[serde(default)] pub target_url: Option, @@ -335,7 +378,7 @@ pub struct PipeReplayResponse { } /// Request body for creating a pipe template -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreatePipeTemplateApiRequest { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -354,12 +397,16 @@ pub struct CreatePipeTemplateApiRequest { } /// Request body for creating a pipe instance -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreatePipeInstanceApiRequest { #[serde(skip_serializing_if = "Option::is_none")] pub deployment_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_adapter: Option, pub source_container: String, #[serde(skip_serializing_if = "Option::is_none")] + pub target_adapter: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub target_container: Option, #[serde(skip_serializing_if = "Option::is_none")] pub target_url: Option, @@ -497,7 +544,13 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Stacker handoff resolve failed ({}): {}", status, body), + reason: stacker_api_failure_with_message( + "Stacker handoff resolve failed", + "POST /api/v1/handoff/resolve", + status, + &body, + cli_debug_enabled(), + ), }); } @@ -526,7 +579,7 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!("Stacker server GET /project failed ({}): {}", status, body), + reason: stacker_api_failure("GET /project", status, &body), }); } @@ -573,11 +626,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); + if let Some(error) = parse_typed_error_response(&body) { + return Err(error.into()); + } return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!( - "Stacker server GET /project/{}/apps failed ({}): {}", - project_id, status, body + reason: stacker_api_failure( + &format!("GET /project/{project_id}/apps"), + status, + &body, ), }); } @@ -613,9 +670,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target, - reason: format!( - "Stacker server POST /project/{}/apps failed ({}): {}", - project_id, status, body + reason: stacker_api_failure( + &format!("POST /project/{project_id}/apps"), + status, + &body, ), }); } @@ -632,6 +690,52 @@ impl StackerClient { }) } + /// Delete one project app target by exact code. + pub async fn delete_project_app( + &self, + project_id: i32, + app_code: &str, + deployment_hash: Option<&str>, + ) -> Result<(), CliError> { + let suffix = if let Some(hash) = deployment_hash.filter(|value| !value.trim().is_empty()) { + format!( + "/{}/apps/{}?deployment_hash={}", + project_id, + app_code, + urlencoding::encode(hash) + ) + } else { + format!("/{}/apps/{}", project_id, app_code) + }; + + let resp = self + .send_project_request( + reqwest::Method::DELETE, + &suffix, + None, + "DELETE /project/{id}/apps/{code}", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + if let Some(error) = parse_typed_error_response(&body) { + return Err(error.into()); + } + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("DELETE /project/{project_id}/apps/{app_code}"), + status, + &body, + ), + }); + } + + Ok(()) + } + // ── Deployments ─────────────────────────────────── /// List deployments for the authenticated user. @@ -660,10 +764,7 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "Stacker server GET /api/v1/deployments failed ({}): {}", - status, body - ), + reason: stacker_api_failure("GET /api/v1/deployments", status, &body), }); } @@ -728,7 +829,7 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!("Stacker server POST /project failed ({}): {}", status, body), + reason: stacker_api_failure("POST /project", status, &body), }); } @@ -764,10 +865,7 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!( - "Stacker server PUT /project/{} failed ({}): {}", - project_id, status, body - ), + reason: stacker_api_failure(&format!("PUT /project/{project_id}"), status, &body), }); } @@ -804,7 +902,7 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Stacker server GET /cloud failed ({}): {}", status, body), + reason: stacker_api_failure("GET /cloud", status, &body), }); } @@ -859,10 +957,7 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "Stacker server GET /cloud/{} failed ({}): {}", - cloud_id, status, body - ), + reason: stacker_api_failure(&format!("GET /cloud/{cloud_id}"), status, &body), }); } @@ -957,10 +1052,7 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "Stacker server PUT /cloud/{} failed ({}): {}", - id, status, body - ), + reason: stacker_api_failure(&format!("PUT /cloud/{id}"), status, &body), }); } @@ -1033,7 +1125,7 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Stacker server POST /cloud failed ({}): {}", status, body), + reason: stacker_api_failure("POST /cloud", status, &body), }); } @@ -1070,7 +1162,7 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!("Stacker server GET /server failed ({}): {}", status, body), + reason: stacker_api_failure("GET /server", status, &body), }); } @@ -1119,9 +1211,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!( - "Stacker server GET /project/{}/apps/{}/secrets/{} failed ({}): {}", - project_id, app_code, name, status, body + reason: stacker_api_failure( + &format!("GET /project/{project_id}/apps/{app_code}/secrets/{name}"), + status, + &body, ), }); } @@ -1154,9 +1247,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!( - "Stacker server GET /project/{}/apps/{}/secrets failed ({}): {}", - project_id, app_code, status, body + reason: stacker_api_failure( + &format!("GET /project/{project_id}/apps/{app_code}/secrets"), + status, + &body, ), }); } @@ -1192,9 +1286,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!( - "Stacker server PUT /project/{}/apps/{}/secrets/{} failed ({}): {}", - project_id, app_code, name, status, body + reason: stacker_api_failure( + &format!("PUT /project/{project_id}/apps/{app_code}/secrets/{name}"), + status, + &body, ), }); } @@ -1231,9 +1326,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!( - "Stacker server DELETE /project/{}/apps/{}/secrets/{} failed ({}): {}", - project_id, app_code, name, status, body + reason: stacker_api_failure( + &format!("DELETE /project/{project_id}/apps/{app_code}/secrets/{name}"), + status, + &body, ), }); } @@ -1264,9 +1360,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!( - "Stacker server GET /server/{}/secrets/{} failed ({}): {}", - server_id, name, status, body + reason: stacker_api_failure( + &format!("GET /server/{server_id}/secrets/{name}"), + status, + &body, ), }); } @@ -1298,9 +1395,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!( - "Stacker server GET /server/{}/secrets failed ({}): {}", - server_id, status, body + reason: stacker_api_failure( + &format!("GET /server/{server_id}/secrets"), + status, + &body, ), }); } @@ -1335,9 +1433,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!( - "Stacker server PUT /server/{}/secrets/{} failed ({}): {}", - server_id, name, status, body + reason: stacker_api_failure( + &format!("PUT /server/{server_id}/secrets/{name}"), + status, + &body, ), }); } @@ -1369,9 +1468,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!( - "Stacker server DELETE /server/{}/secrets/{} failed ({}): {}", - server_id, name, status, body + reason: stacker_api_failure( + &format!("DELETE /server/{server_id}/secrets/{name}"), + status, + &body, ), }); } @@ -1400,9 +1500,12 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "SSH key generation failed for server {} ({}): {}", - server_id, status, body + reason: stacker_api_failure_with_message( + &format!("SSH key generation failed for server {server_id}"), + &format!("POST /server/{server_id}/ssh-key/generate"), + status, + &body, + cli_debug_enabled(), ), }); } @@ -1438,9 +1541,12 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "Failed to fetch SSH public key for server {} ({}): {}", - server_id, status, body + reason: stacker_api_failure_with_message( + &format!("Failed to fetch SSH public key for server {server_id}"), + &format!("GET /server/{server_id}/ssh-key/public"), + status, + &body, + cli_debug_enabled(), ), }); } @@ -1492,9 +1598,12 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target, - reason: format!( - "Failed to authorize SSH public key for server {} ({}): {}", - server_id, status, body + reason: stacker_api_failure_with_message( + &format!("Failed to authorize SSH public key for server {server_id}"), + &format!("POST /server/{server_id}/ssh-key/authorize-public-key"), + status, + &body, + cli_debug_enabled(), ), }); } @@ -1534,9 +1643,12 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target, - reason: format!( - "Failed to configure cloud firewall for server {} ({}): {}", - server_id, status, body + reason: stacker_api_failure_with_message( + &format!("Failed to configure cloud firewall for server {server_id}"), + &format!("POST /server/{server_id}/cloud-firewall"), + status, + &body, + cli_debug_enabled(), ), }); } @@ -1583,9 +1695,12 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "SSH key upload failed for server {} ({}): {}", - server_id, status, body + reason: stacker_api_failure_with_message( + &format!("SSH key upload failed for server {server_id}"), + &format!("POST /server/{server_id}/ssh-key/upload"), + status, + &body, + cli_debug_enabled(), ), }); } @@ -1638,7 +1753,13 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Marketplace listing failed ({}): {}", status, body), + reason: stacker_api_failure_with_message( + "Marketplace listing failed", + "GET /marketplace", + status, + &body, + cli_debug_enabled(), + ), }); } @@ -1677,7 +1798,13 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Marketplace template fetch failed ({}): {}", status, body), + reason: stacker_api_failure_with_message( + "Marketplace template fetch failed", + &format!("GET /marketplace/{slug}"), + status, + &body, + cli_debug_enabled(), + ), }); } @@ -1717,7 +1844,13 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!("Stacker server deploy failed ({}): {}", status, body), + reason: stacker_api_failure_with_message( + "Stacker server deploy failed", + &format!("POST /project{suffix}"), + status, + &body, + cli_debug_enabled(), + ), }); } @@ -1748,9 +1881,18 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); + if let Some(error) = parse_typed_error_response(&body) { + return Err(error.into()); + } return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!("Stacker server rollback failed ({}): {}", status, body), + reason: stacker_api_failure_with_message( + "Stacker server rollback failed", + &format!("POST /project/{project_id}/rollback"), + status, + &body, + cli_debug_enabled(), + ), }); } @@ -1791,9 +1933,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!( - "Stacker server GET /api/v1/deployments/{} failed ({}): {}", - deployment_id, status, body + reason: stacker_api_failure( + &format!("GET /api/v1/deployments/{deployment_id}"), + status, + &body, ), }); } @@ -1807,6 +1950,174 @@ impl StackerClient { Ok(api.item) } + /// Fetch canonical deployment state by deployment hash. + /// Returns `GET /api/v1/deployments/{deployment_hash}/state`. + pub async fn get_deployment_state_by_hash( + &self, + deployment_hash: &str, + ) -> Result, CliError> { + let url = format!( + "{}/api/v1/deployments/{}/state", + self.base_url, deployment_hash + ); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("GET /api/v1/deployments/{deployment_hash}/state"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.item) + } + + /// Fetch structured deployment events by deployment hash. + pub async fn get_deployment_events_by_hash( + &self, + deployment_hash: &str, + ) -> Result, CliError> { + let url = format!( + "{}/api/v1/deployments/{}/events", + self.base_url, deployment_hash + ); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("GET /api/v1/deployments/{deployment_hash}/events"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.item) + } + + /// Fetch a read-only deployment plan by deployment hash. + pub async fn get_deployment_plan_by_hash( + &self, + deployment_hash: &str, + operation: DeployPlanOperation, + target: &str, + app_code: Option<&str>, + rollback_target: Option<&str>, + expected_fingerprint: Option<&str>, + ) -> Result, CliError> { + let url = format!( + "{}/api/v1/deployments/{}/plan", + self.base_url, deployment_hash + ); + let mut query = vec![ + ( + "operation".to_string(), + serde_json::to_string(&operation) + .unwrap() + .trim_matches('"') + .to_string(), + ), + ("target".to_string(), target.to_string()), + ]; + if let Some(app_code) = app_code.filter(|value| !value.trim().is_empty()) { + query.push(("appCode".to_string(), app_code.to_string())); + } + if let Some(rollback_target) = rollback_target.filter(|value| !value.trim().is_empty()) { + query.push(("rollbackTarget".to_string(), rollback_target.to_string())); + } + if let Some(fingerprint) = expected_fingerprint.filter(|value| !value.trim().is_empty()) { + query.push(("expectedFingerprint".to_string(), fingerprint.to_string())); + } + + let resp = self + .http + .get(&url) + .query(&query) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + if let Some(error) = parse_typed_error_response(&body) { + return Err(error.into()); + } + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("GET /api/v1/deployments/{deployment_hash}/plan"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.item) + } + /// Fetch the latest deployment status for a project. /// Returns `GET /api/v1/deployments/project/{project_id}`. pub async fn get_deployment_status_by_project( @@ -1837,9 +2148,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: self.target.clone(), - reason: format!( - "Stacker server GET /api/v1/deployments/project/{} failed ({}): {}", - project_id, status, body + reason: stacker_api_failure( + &format!("GET /api/v1/deployments/project/{project_id}"), + status, + &body, ), }); } @@ -1882,9 +2194,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "GET /api/v1/deployments/hash/{} failed ({}): {}", - hash, status, body + reason: stacker_api_failure( + &format!("GET /api/v1/deployments/hash/{hash}"), + status, + &body, ), }); } @@ -1930,7 +2243,13 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Force-complete failed ({}): {}", status, body), + reason: stacker_api_failure_with_message( + "Force-complete failed", + &format!("POST /api/v1/deployments/{deployment_id}/force-complete"), + status, + &body, + cli_debug_enabled(), + ), }); } @@ -1973,7 +2292,13 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::AgentCommandFailed { command_id: String::new(), - error: format!("Enqueue failed ({}): {}", status, body), + error: stacker_api_failure_with_message( + "Enqueue failed", + "POST /api/v1/agent/commands/enqueue", + status, + &body, + cli_debug_enabled(), + ), }); } @@ -2026,7 +2351,13 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::AgentCommandFailed { command_id: command_id.to_string(), - error: format!("Status check failed ({}): {}", status, body), + error: stacker_api_failure_with_message( + "Status check failed", + &format!("GET /api/v1/commands/{deployment_hash}/{command_id}"), + status, + &body, + cli_debug_enabled(), + ), }); } @@ -2120,7 +2451,13 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::AgentCommandFailed { command_id: String::new(), - error: format!("Snapshot failed ({}): {}", status, body), + error: stacker_api_failure_with_message( + "Snapshot failed", + &format!("GET /api/v1/agent/deployments/{deployment_hash}"), + status, + &body, + cli_debug_enabled(), + ), }); } @@ -2156,9 +2493,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "GET /api/v1/agent/project/{} failed ({}): {}", - project_id, status, body + reason: stacker_api_failure( + &format!("GET /api/v1/agent/project/{project_id}"), + status, + &body, ), }); } @@ -2190,6 +2528,53 @@ impl StackerClient { Ok((json, hash)) } + /// Fetch deployment agent capabilities. + /// + /// `GET /api/v1/deployments/{deployment_hash}/capabilities` + pub async fn deployment_capabilities( + &self, + deployment_hash: &str, + ) -> Result { + let url = format!( + "{}/api/v1/deployments/{}/capabilities", + self.base_url, deployment_hash + ); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| { + CliError::ConfigValidation(format!( + "Failed to fetch deployment capabilities: {}", + e + )) + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Capabilities lookup failed", + &format!("GET /api/v1/deployments/{deployment_hash}/capabilities"), + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::ConfigValidation(format!("Invalid deployment capabilities response: {}", e)) + })?; + + api.item.ok_or_else(|| { + CliError::ConfigValidation("Empty deployment capabilities response".to_string()) + }) + } + // ── Pipe management ───────────────────────────── /// List pipe instances for a deployment. @@ -2214,10 +2599,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::ConfigValidation(format!( - "List pipes failed ({}): {}", - status, body - ))); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "List pipes failed", + &format!("GET /api/v1/pipes/instances/{deployment_hash}"), + status, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp.json().await.map_err(|e| { @@ -2245,10 +2635,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::ConfigValidation(format!( - "List local pipes failed ({}): {}", - status, body - ))); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "List local pipes failed", + "GET /api/v1/pipes/instances/local", + status, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp.json().await.map_err(|e| { @@ -2283,10 +2678,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::ConfigValidation(format!( - "Get pipe failed ({}): {}", - status, body - ))); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Get pipe failed", + &format!("GET /api/v1/pipes/instances/detail/{instance_id}"), + status, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp @@ -2319,10 +2719,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::ConfigValidation(format!( - "Create pipe template failed ({}): {}", - status, body - ))); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Create pipe template failed", + "POST /api/v1/pipes/templates", + status, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp @@ -2356,10 +2761,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::ConfigValidation(format!( - "Create pipe instance failed ({}): {}", - status, body - ))); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Create pipe instance failed", + "POST /api/v1/pipes/instances", + status, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp @@ -2398,10 +2808,15 @@ impl StackerClient { if !resp.status().is_success() { let status_code = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::ConfigValidation(format!( - "Update pipe status failed ({}): {}", - status_code, body - ))); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Update pipe status failed", + &format!("PUT /api/v1/pipes/instances/{instance_id}/status"), + status_code, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp @@ -2445,10 +2860,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::ConfigValidation(format!( - "List templates failed ({}): {}", - status, body - ))); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "List templates failed", + "GET /api/v1/pipes/templates", + status, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp.json().await.map_err(|e| { @@ -2484,10 +2904,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::ConfigValidation(format!( - "List executions failed ({}): {}", - status, body - ))); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "List executions failed", + &format!("GET /api/v1/pipes/instances/{instance_id}/executions"), + status, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp.json().await.map_err(|e| { @@ -2520,10 +2945,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::ConfigValidation(format!( - "Get execution failed ({}): {}", - status, body - ))); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Get execution failed", + &format!("GET /api/v1/pipes/executions/{execution_id}"), + status, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp.json().await.map_err(|e| { @@ -2557,10 +2987,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::ConfigValidation(format!( - "Replay failed ({}): {}", - status, body - ))); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Replay failed", + &format!("POST /api/v1/pipes/executions/{execution_id}/replay"), + status, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp @@ -2599,10 +3034,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::ConfigValidation(format!( - "Deploy failed ({}): {}", - status, body - ))); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Deploy failed", + &format!("POST /api/v1/pipes/instances/{instance_id}/deploy"), + status, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp @@ -2632,10 +3072,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::MarketplaceFailed(format!( - "GET /api/templates/mine failed ({}): {}", - status, body - ))); + return Err(CliError::MarketplaceFailed( + stacker_api_failure_with_message( + "Marketplace submissions fetch failed", + "GET /api/templates/mine", + status, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp.json().await.map_err(|e| { @@ -2664,10 +3109,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::MarketplaceFailed(format!( - "GET /api/templates/{}/reviews failed ({}): {}", - template_id, status, body - ))); + return Err(CliError::MarketplaceFailed( + stacker_api_failure_with_message( + "Marketplace reviews fetch failed", + &format!("GET /api/templates/{template_id}/reviews"), + status, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp.json().await.map_err(|e| { @@ -2695,10 +3145,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::MarketplaceFailed(format!( - "Create template failed ({}): {}", - status, body - ))); + return Err(CliError::MarketplaceFailed( + stacker_api_failure_with_message( + "Create template failed", + "POST /api/templates", + status, + &body, + cli_debug_enabled(), + ), + )); } let api: ApiResponse = resp @@ -2726,10 +3181,15 @@ impl StackerClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::MarketplaceFailed(format!( - "Submit failed ({}): {}", - status, body - ))); + return Err(CliError::MarketplaceFailed( + stacker_api_failure_with_message( + "Submit failed", + &format!("POST /api/templates/{template_id}/submit"), + status, + &body, + cli_debug_enabled(), + ), + )); } Ok(()) @@ -2818,6 +3278,32 @@ pub struct AgentCommandInfo { pub updated_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DeploymentCapabilityFeatures { + #[serde(default)] + pub kata_runtime: bool, + #[serde(default)] + pub compose: bool, + #[serde(default)] + pub backup: bool, + #[serde(default)] + pub pipes: bool, + #[serde(default)] + pub proxy_credentials_vault: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DeploymentCapabilitiesInfo { + #[serde(default)] + pub deployment_hash: String, + #[serde(default)] + pub status: String, + #[serde(default)] + pub capabilities: Vec, + #[serde(default)] + pub features: DeploymentCapabilityFeatures, +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Helper: build deploy form from stacker.yml config // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -2867,13 +3353,14 @@ fn parse_docker_image(image: &str) -> (Option, String, Option) { } } -/// Parse a port mapping string like "8080:80", "8080:80/tcp", or "3000" +/// Parse a port mapping string like "8080:80", "127.0.0.1:8080:80", or "3000" /// into (host_port, container_port) tuple. fn parse_port_mapping(port_str: &str) -> (String, String) { // Remove protocol suffix like "/tcp", "/udp" let port_no_proto = port_str.split('/').next().unwrap_or(port_str); - if let Some((host, container)) = port_no_proto.split_once(':') { - (host.to_string(), container.to_string()) + if let Some((host_part, container)) = port_no_proto.rsplit_once(':') { + let host_port = host_part.rsplit(':').next().unwrap_or(host_part); + (host_port.to_string(), container.to_string()) } else { (port_no_proto.to_string(), port_no_proto.to_string()) } @@ -2961,13 +3448,8 @@ fn service_to_app_json(svc: &ServiceDefinition, network_ids: &[String]) -> serde app } -fn is_nginx_proxy_manager_service(svc: &ServiceDefinition) -> bool { - let service_name = svc.name.to_ascii_lowercase().replace('-', "_"); - let image = svc.image.to_ascii_lowercase(); - - service_name == "nginx_proxy_manager" - || service_name == "npm" - || image.contains("nginx-proxy-manager") +fn is_platform_managed_service(svc: &ServiceDefinition) -> bool { + crate::project_app::is_platform_managed_app_identity(&svc.name, Some(&svc.image)) } /// Convert the `app` section of stacker.yml into the Stacker server's app JSON @@ -3103,17 +3585,11 @@ pub fn build_project_body(config: &StackerConfig) -> serde_json::Value { web_apps.push(main_app); } - let proxy_is_managed = matches!( - config.proxy.proxy_type, - crate::cli::config_parser::ProxyType::Nginx - | crate::cli::config_parser::ProxyType::NginxProxyManager - ); - - // Include additional services as service targets. Managed proxy services - // are installed via extended_features, not as project apps, to avoid - // duplicate NPM containers. + // Include additional services as service targets. Platform-managed apps + // are installed by their own roles and directories, not by the project + // compose, to avoid duplicate containers and host-port conflicts. for svc in &config.services { - if proxy_is_managed && is_nginx_proxy_manager_service(svc) { + if is_platform_managed_service(svc) { continue; } service_apps.push(service_to_app_json(svc, &network_ids)); @@ -3233,6 +3709,22 @@ pub fn generate_server_name(project_name: &str) -> String { format!("{}-{}", truncated, suffix) } +#[derive(Debug, Clone, Copy, Default)] +pub struct DeployFormOptions { + pub include_managed_proxy: bool, +} + +pub fn build_deploy_form_with_options( + config: &StackerConfig, + options: DeployFormOptions, +) -> serde_json::Value { + let mut form = build_deploy_form(config); + if !options.include_managed_proxy { + remove_extended_feature(&mut form, "nginx_proxy_manager"); + } + form +} + pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { let cloud = config.deploy.cloud.as_ref(); let provider = cloud @@ -3368,6 +3860,24 @@ pub fn build_server_deploy_form( server_cfg: &crate::cli::config_parser::ServerConfig, server_name: &str, force_status_panel: bool, +) -> serde_json::Value { + build_server_deploy_form_with_options( + config, + server_cfg, + server_name, + force_status_panel, + DeployFormOptions { + include_managed_proxy: true, + }, + ) +} + +pub fn build_server_deploy_form_with_options( + config: &StackerConfig, + server_cfg: &crate::cli::config_parser::ServerConfig, + server_name: &str, + force_status_panel: bool, + options: DeployFormOptions, ) -> serde_json::Value { let project_name = config .project @@ -3405,22 +3915,24 @@ pub fn build_server_deploy_form( } } - match config.proxy.proxy_type { - crate::cli::config_parser::ProxyType::Nginx - | crate::cli::config_parser::ProxyType::NginxProxyManager => { - 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 npm = serde_json::Value::String("nginx_proxy_manager".to_string()); - if !arr.contains(&npm) { - arr.push(npm); + if options.include_managed_proxy { + match config.proxy.proxy_type { + crate::cli::config_parser::ProxyType::Nginx + | crate::cli::config_parser::ProxyType::NginxProxyManager => { + 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 npm = serde_json::Value::String("nginx_proxy_manager".to_string()); + if !arr.contains(&npm) { + arr.push(npm); + } } } } + _ => {} } - _ => {} } if config.monitoring.status_panel || force_status_panel { @@ -3463,12 +3975,56 @@ pub fn build_server_deploy_form( form } +fn remove_extended_feature(form: &mut serde_json::Value, code: &str) { + let Some(features) = form + .get_mut("stack") + .and_then(|stack| stack.get_mut("extended_features")) + .and_then(|features| features.as_array_mut()) + else { + return; + }; + + features.retain(|feature| feature.as_str() != Some(code)); +} + #[cfg(test)] mod tests { use super::*; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; + #[test] + fn stacker_api_failure_hides_endpoint_and_body_without_debug() { + let message = stacker_api_failure_with_debug( + "GET /cloud", + 400, + r#"{"message":"401 Unauthorized"}"#, + false, + ); + + assert!(message.contains("Stacker server request failed (400)")); + assert!(!message.contains("GET /cloud")); + assert!(!message.contains("401 Unauthorized")); + assert!(!message.contains(r#"{"message""#)); + assert!(message.contains("DEBUG=true")); + assert!(message.contains("RUST_LOG=debug")); + } + + #[test] + fn stacker_api_failure_includes_endpoint_and_body_with_debug() { + let message = stacker_api_failure_with_debug( + "GET /cloud", + 400, + r#"{"message":"401 Unauthorized"}"#, + true, + ); + + assert_eq!( + message, + r#"Stacker server GET /cloud failed (400): {"message":"401 Unauthorized"}"# + ); + } + #[test] fn test_build_deploy_form_defaults() { let config = crate::cli::config_parser::ConfigBuilder::new() @@ -3777,6 +4333,83 @@ mod tests { ); } + #[test] + fn pipe_instance_request_serializes_adapter_references() { + let request = CreatePipeInstanceApiRequest { + deployment_hash: Some("dep-123".into()), + source_adapter: Some( + PipeAdapterReference::new("imap") + .with_config(serde_json::json!({ "mailbox": "INBOX" })), + ), + source_container: "status-panel-web".into(), + target_adapter: Some( + PipeAdapterReference::new("smtp") + .with_config(serde_json::json!({ "host": "smtp.example.com" })), + ), + target_container: None, + target_url: Some("smtp://mail.example.com:587".into()), + template_id: Some("tpl-123".into()), + field_mapping_override: Some(serde_json::json!({ "subject": "$.subject" })), + config_override: Some(serde_json::json!({ "timeout_secs": 30 })), + }; + + let value = serde_json::to_value(&request).unwrap(); + assert_eq!(value["source_adapter"]["code"], "imap"); + assert_eq!(value["target_adapter"]["code"], "smtp"); + assert_eq!(value["source_adapter"]["config"]["mailbox"], "INBOX"); + assert_eq!( + value["target_adapter"]["config"]["host"], + "smtp.example.com" + ); + } + + #[test] + fn pipe_instance_info_deserializes_adapter_references() { + let value = serde_json::json!({ + "id": "pipe-123", + "template_id": "tpl-123", + "deployment_hash": "dep-123", + "source_adapter": { + "code": "imap", + "role": "source", + "config": { "mailbox": "INBOX" } + }, + "source_container": "status-panel-web", + "target_adapter": { + "code": "smtp", + "role": "target", + "config": { "host": "smtp.example.com" } + }, + "target_container": "smtp", + "target_url": null, + "field_mapping_override": { "subject": "$.subject" }, + "config_override": { "timeout_secs": 30 }, + "status": "draft", + "last_triggered_at": null, + "trigger_count": 0, + "error_count": 0, + "created_by": "user-123", + "created_at": "2026-05-21T00:00:00Z", + "updated_at": "2026-05-21T00:00:00Z" + }); + + let info: PipeInstanceInfo = serde_json::from_value(value).unwrap(); + assert_eq!( + info.source_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()), + Some("imap") + ); + assert_eq!( + info.target_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()), + Some("smtp") + ); + assert_eq!(info.source_container, "status-panel-web"); + assert_eq!(info.target_container.as_deref(), Some("smtp")); + } + #[test] fn test_build_project_body_skips_declared_npm_service_when_proxy_is_managed() { let npm_service = ServiceDefinition { @@ -3821,6 +4454,79 @@ mod tests { assert_eq!(codes, vec!["redis"]); } + #[test] + fn test_build_project_body_skips_platform_managed_services_even_without_proxy() { + let npm_service = ServiceDefinition { + name: "nginx_proxy_manager".to_string(), + image: "jc21/nginx-proxy-manager:latest".to_string(), + ports: vec![ + "80:80".to_string(), + "443:443".to_string(), + "81:81".to_string(), + ], + environment: std::collections::HashMap::new(), + volumes: vec!["npm_data:/data".to_string()], + depends_on: vec![], + }; + let statuspanel_service = ServiceDefinition { + name: "statuspanel".to_string(), + image: "ghcr.io/trydirect/statuspanel:latest".to_string(), + ports: vec!["5000:5000".to_string()], + environment: std::collections::HashMap::new(), + volumes: vec![], + depends_on: vec![], + }; + let smtp_service = ServiceDefinition { + name: "smtp".to_string(), + image: "trydirect/smtp:latest".to_string(), + ports: vec!["127.0.0.1:1025:25".to_string()], + environment: std::collections::HashMap::new(), + volumes: vec![], + depends_on: vec![], + }; + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .add_service(npm_service) + .add_service(statuspanel_service) + .add_service(smtp_service) + .proxy(crate::cli::config_parser::ProxyConfig { + proxy_type: crate::cli::config_parser::ProxyType::None, + auto_detect: false, + domains: vec![], + config: None, + }) + .build() + .unwrap(); + + let body = build_project_body(&config); + let service = body["custom"]["service"].as_array().unwrap(); + let codes = service + .iter() + .filter_map(|app| app["code"].as_str()) + .collect::>(); + assert_eq!(codes, vec!["smtp"]); + } + + #[test] + fn test_parse_port_mapping_accepts_host_ip_bindings() { + assert_eq!( + parse_port_mapping("127.0.0.1:1025:25"), + ("1025".to_string(), "25".to_string()) + ); + assert_eq!( + parse_port_mapping("127.0.0.1:1025:25/tcp"), + ("1025".to_string(), "25".to_string()) + ); + assert_eq!( + parse_port_mapping("3000:3000"), + ("3000".to_string(), "3000".to_string()) + ); + assert_eq!( + parse_port_mapping("8080"), + ("8080".to_string(), "8080".to_string()) + ); + } + #[test] fn test_scn_001_stacker_yml_service_serializes_as_service_target() { let upload_service = ServiceDefinition { diff --git a/src/cli/user_config.rs b/src/cli/user_config.rs new file mode 100644 index 00000000..aba12fcd --- /dev/null +++ b/src/cli/user_config.rs @@ -0,0 +1,68 @@ +use std::path::PathBuf; + +use serde::Deserialize; + +/// User-level configuration stored at `~/.config/stacker/config.yml`. +/// +/// Written once by `install.sh` with platform defaults; the user can edit it +/// manually. Values here are overridden by CLI flags and environment variables. +/// +/// Priority (highest to lowest): +/// CLI flag → environment variable → `~/.config/stacker/config.yml` → built-in default +#[derive(Debug, Clone, Deserialize, Default)] +pub struct UserConfig { + /// TryDirect user-service base URL (e.g. `https://try.direct/server/user`). + pub auth_url: Option, + /// Stacker API URL (e.g. `https://try.direct/stacker`). + pub server_url: Option, + /// Default organisation slug for multi-org accounts. + pub org: Option, + /// Login-specific preferences. + pub login: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct LoginConfig { + /// Use browser-based OAuth2 flow by default (`true`) or email/password (`false`). + pub browser: Option, + /// Default OAuth provider code: `gc` (Google), `gh` (GitHub), etc. + pub provider: Option, +} + +impl UserConfig { + /// Load from `~/.config/stacker/config.yml`. + /// Returns `Default` silently if the file is absent or unreadable. + pub fn load() -> Self { + let path = Self::config_path(); + if !path.exists() { + return Self::default(); + } + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return Self::default(), + }; + serde_yaml::from_str(&content).unwrap_or_default() + } + + /// Platform-appropriate path for the user config file. + pub fn config_path() -> PathBuf { + let base = std::env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|_| std::env::var("HOME").map(|h| PathBuf::from(h).join(".config"))) + .unwrap_or_else(|_| PathBuf::from(".")); + base.join("stacker").join("config.yml") + } + + /// Default browser preference: `true` unless explicitly disabled. + pub fn browser_default(&self) -> bool { + self.login.as_ref().and_then(|l| l.browser).unwrap_or(true) + } + + /// Default provider: `gc` (Google) unless overridden. + pub fn provider_default(&self) -> String { + self.login + .as_ref() + .and_then(|l| l.provider.clone()) + .unwrap_or_else(|| "gc".to_string()) + } +} diff --git a/src/connectors/hetzner.rs b/src/connectors/hetzner.rs new file mode 100644 index 00000000..863d387b --- /dev/null +++ b/src/connectors/hetzner.rs @@ -0,0 +1,311 @@ +//! Hetzner Cloud connector. +//! +//! Keep all Hetzner API calls behind this trait so MCP/routes can be tested +//! without touching real infrastructure. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::time::Duration; + +use crate::connectors::ConnectorError; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HetznerSnapshotTarget { + pub provider_server_id: Option, + pub server_name: Option, + pub public_ip: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HetznerSnapshot { + pub action_id: i64, + pub status: String, + pub image_id: Option, +} + +#[async_trait] +pub trait HetznerCloudConnector: Send + Sync { + async fn create_server_snapshot( + &self, + token: &str, + target: HetznerSnapshotTarget, + description: &str, + ) -> Result; +} + +#[derive(Clone)] +pub struct HetznerCloudClient { + http_client: reqwest::Client, + base_url: String, +} + +impl HetznerCloudClient { + pub fn new(base_url: impl Into) -> Result { + let http_client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(45)) + .build() + .map_err(ConnectorError::from)?; + Ok(Self { + http_client, + base_url: base_url.into().trim_end_matches("/").to_string(), + }) + } + + pub fn from_env() -> Result { + let base_url = std::env::var("HETZNER_API_BASE_URL") + .unwrap_or_else(|_| "https://api.hetzner.cloud/v1".to_string()); + Self::new(base_url) + } + + async fn resolve_server_id( + &self, + token: &str, + target: &HetznerSnapshotTarget, + ) -> Result { + if let Some(id) = target.provider_server_id { + return Ok(id); + } + + let response = self + .http_client + .get(format!("{}/servers", self.base_url)) + .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()))?; + find_matching_hetzner_server(&body.servers, target) + .map(|server| server.id) + .ok_or_else(|| { + ConnectorError::NotFound( + "No Hetzner server matched the saved Stacker server name or public IP" + .to_string(), + ) + }) + } +} + +#[async_trait] +impl HetznerCloudConnector for HetznerCloudClient { + async fn create_server_snapshot( + &self, + token: &str, + target: HetznerSnapshotTarget, + description: &str, + ) -> Result { + let server_id = self.resolve_server_id(token, &target).await?; + let response = self + .http_client + .post(format!( + "{}/servers/{}/actions/create_image", + self.base_url, server_id + )) + .bearer_auth(token) + .json(&json!({ + "type": "snapshot", + "description": description, + })) + .send() + .await + .map_err(ConnectorError::from)?; + + let status = response.status(); + if !status.is_success() { + return Err(status_to_error(status, "Hetzner snapshot request failed")); + } + + let body: HetznerCreateImageResponse = response + .json() + .await + .map_err(|err| ConnectorError::InvalidResponse(err.to_string()))?; + let image_id = body + .action + .resources + .iter() + .find(|resource| resource.resource_type == "image") + .map(|resource| resource.id); + + Ok(HetznerSnapshot { + action_id: body.action.id, + status: body.action.status, + image_id, + }) + } +} + +fn status_to_error(status: reqwest::StatusCode, message: &str) -> ConnectorError { + match status.as_u16() { + 401 | 403 => { + ConnectorError::Unauthorized("Hetzner rejected the saved cloud token".to_string()) + } + 404 => ConnectorError::NotFound(message.to_string()), + 429 => ConnectorError::RateLimited("Hetzner API rate limit exceeded".to_string()), + _ => ConnectorError::HttpError(format!("{} with status {}", message, status.as_u16())), + } +} + +fn find_matching_hetzner_server<'a>( + servers: &'a [HetznerServer], + target: &HetznerSnapshotTarget, +) -> Option<&'a HetznerServer> { + let expected_ip = target + .public_ip + .as_deref() + .filter(|value| !value.trim().is_empty()); + let expected_name = target + .server_name + .as_deref() + .filter(|value| !value.trim().is_empty()); + + servers.iter().find(|server| { + expected_ip.is_some_and(|ip| hetzner_server_ip(server) == Some(ip)) + || expected_name.is_some_and(|name| server.name == name) + }) +} + +fn hetzner_server_ip(server: &HetznerServer) -> Option<&str> { + server + .public_net + .as_ref() + .and_then(|net| net.ipv4.as_ref()) + .map(|ipv4| ipv4.ip.as_str()) +} + +#[derive(Debug, Deserialize)] +struct HetznerServersResponse { + servers: Vec, +} + +#[derive(Debug, Deserialize)] +struct HetznerServer { + id: i64, + name: String, + #[serde(default)] + public_net: Option, +} + +#[derive(Debug, Deserialize)] +struct HetznerServerPublicNet { + #[serde(default)] + ipv4: Option, +} + +#[derive(Debug, Deserialize)] +struct HetznerServerIpv4 { + ip: String, +} + +#[derive(Debug, Deserialize)] +struct HetznerCreateImageResponse { + action: HetznerAction, +} + +#[derive(Debug, Deserialize)] +struct HetznerAction { + id: i64, + status: String, + #[serde(default)] + resources: Vec, +} + +#[derive(Debug, Deserialize)] +struct HetznerActionResource { + id: i64, + #[serde(rename = "type")] + resource_type: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{body_partial_json, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn create_snapshot_resolves_server_by_public_ip_without_live_api() { + let api = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/servers")) + .and(header("authorization", "Bearer test-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "servers": [{ + "id": 123, + "name": "prod-web-1", + "public_net": { "ipv4": { "ip": "203.0.113.10" } } + }] + }))) + .mount(&api) + .await; + Mock::given(method("POST")) + .and(path("/servers/123/actions/create_image")) + .and(header("authorization", "Bearer test-token")) + .and(body_partial_json(json!({"type": "snapshot"}))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "action": { + "id": 777, + "status": "running", + "resources": [{"id": 888, "type": "image"}] + } + }))) + .mount(&api) + .await; + + let client = HetznerCloudClient::new(api.uri()).unwrap(); + let snapshot = client + .create_server_snapshot( + "test-token", + HetznerSnapshotTarget { + provider_server_id: None, + server_name: None, + public_ip: Some("203.0.113.10".to_string()), + }, + "Stacker troubleshooting snapshot", + ) + .await + .unwrap(); + + assert_eq!(snapshot.action_id, 777); + assert_eq!(snapshot.image_id, Some(888)); + assert_eq!(snapshot.status, "running"); + } + + #[tokio::test] + async fn create_snapshot_can_use_known_provider_server_id() { + let api = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/servers/456/actions/create_image")) + .and(header("authorization", "Bearer test-token")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "action": { "id": 778, "status": "running", "resources": [] } + }))) + .mount(&api) + .await; + + let client = HetznerCloudClient::new(api.uri()).unwrap(); + let snapshot = client + .create_server_snapshot( + "test-token", + HetznerSnapshotTarget { + provider_server_id: Some(456), + server_name: None, + public_ip: None, + }, + "Stacker troubleshooting snapshot", + ) + .await + .unwrap(); + + assert_eq!(snapshot.action_id, 778); + assert_eq!(snapshot.image_id, None); + } +} diff --git a/src/connectors/install_service/client.rs b/src/connectors/install_service/client.rs index 5d6009d1..ac474ce5 100644 --- a/src/connectors/install_service/client.rs +++ b/src/connectors/install_service/client.rs @@ -9,6 +9,73 @@ use async_trait::async_trait; /// Real implementation that publishes deployment requests through RabbitMQ pub struct InstallServiceClient; +fn normalize_server_region_for_installer(provider: &str, server: &mut crate::forms::ServerForm) { + if !matches!(provider, "htz" | "hetzner") { + return; + } + + let Some(region) = server.region.as_deref() else { + return; + }; + + let location = match region { + "nbg1-dc3" => "nbg1", + "fsn1-dc14" => "fsn1", + "hel1-dc2" => "hel1", + "ash-dc1" => "ash", + "hil-dc1" => "hil", + _ => return, + }; + + server.region = Some(location.to_string()); +} + +#[cfg(test)] +mod tests { + use super::normalize_server_region_for_installer; + use crate::forms::ServerForm; + + #[test] + fn preserves_hetzner_location_for_installer() { + let mut server = ServerForm { + region: Some("nbg1".to_string()), + server: Some("cpx21".to_string()), + os: Some("docker-ce".to_string()), + ..Default::default() + }; + + normalize_server_region_for_installer("htz", &mut server); + + assert_eq!(server.region.as_deref(), Some("nbg1")); + assert_eq!(server.server.as_deref(), Some("cpx21")); + assert_eq!(server.os.as_deref(), Some("docker-ce")); + } + + #[test] + fn normalizes_hetzner_datacenter_to_location_for_installer() { + let mut server = ServerForm { + region: Some("fsn1-dc14".to_string()), + ..Default::default() + }; + + normalize_server_region_for_installer("htz", &mut server); + + assert_eq!(server.region.as_deref(), Some("fsn1")); + } + + #[test] + fn leaves_non_hetzner_regions_unchanged() { + let mut server = ServerForm { + region: Some("fra1".to_string()), + ..Default::default() + }; + + normalize_server_region_for_installer("do", &mut server); + + assert_eq!(server.region.as_deref(), Some("fra1")); + } +} + #[async_trait] impl InstallServiceConnector for InstallServiceClient { async fn deploy( @@ -44,6 +111,7 @@ impl InstallServiceConnector for InstallServiceClient { payload.server = Some(server.into()); // Inject newly-generated public key so Install Service can append it to authorized_keys if let Some(ref mut srv) = payload.server { + normalize_server_region_for_installer(&cloud_creds.provider, srv); if srv.public_key.is_none() { srv.public_key = server_public_key; } diff --git a/src/connectors/mod.rs b/src/connectors/mod.rs index 4f60556a..78117932 100644 --- a/src/connectors/mod.rs +++ b/src/connectors/mod.rs @@ -43,6 +43,7 @@ pub mod app_service_catalog; pub mod config; pub mod dockerhub_service; pub mod errors; +pub mod hetzner; pub mod install_service; pub mod user_service; @@ -53,6 +54,9 @@ pub use config::{ ConnectorConfig, EventsConfig, InstallServiceConfig, PaymentServiceConfig, UserServiceConfig, }; pub use errors::ConnectorError; +pub use hetzner::{ + HetznerCloudClient, HetznerCloudConnector, HetznerSnapshot, HetznerSnapshotTarget, +}; pub use install_service::{InstallServiceClient, InstallServiceConnector}; pub use user_service::{ CategoryInfo, DeploymentValidationError, DeploymentValidator, MarketplaceWebhookPayload, diff --git a/src/console/commands/cli/agent.rs b/src/console/commands/cli/agent.rs index 818104fd..f1981e6f 100644 --- a/src/console/commands/cli/agent.rs +++ b/src/console/commands/cli/agent.rs @@ -9,10 +9,12 @@ //! The CLI never connects to the agent directly. All communication is mediated //! by the Stacker server. -use crate::cli::config_bundle::build_config_bundle; +use crate::cli::config_bundle::{build_config_bundle, ConfigBundleArtifacts}; use crate::cli::config_parser::StackerConfig; +use crate::cli::debug::cli_debug_enabled; use crate::cli::error::CliError; use crate::cli::fmt; +use crate::cli::generator::compose::ComposeDefinition; use crate::cli::install_runner::resolve_docker_registry_credentials; use crate::cli::progress; use crate::cli::runtime::CliRuntime; @@ -36,7 +38,7 @@ const DEFAULT_POLL_INTERVAL_SECS: u64 = 2; /// 1. Explicit `--deployment` flag value /// 2. `stacker.yml` project name → API project lookup → active agent hash (most reliable) /// 3. `.stacker/deployment.lock` → `deployment_id` → API lookup for hash (fallback) -fn resolve_deployment_hash( +pub(crate) fn resolve_deployment_hash( explicit: &Option, ctx: &CliRuntime, ) -> Result { @@ -94,7 +96,7 @@ fn resolve_deployment_hash( )) } -fn resolve_registry_auth_for_agent_deploy( +pub(crate) fn resolve_registry_auth_for_agent_deploy( project_dir: &Path, ) -> Option { let config_path = project_dir.join("stacker.yml"); @@ -185,9 +187,33 @@ fn json_error_message(value: &serde_json::Value) -> Option { } } +fn sanitize_npm_credentials_message(raw_message: String, code: Option<&str>) -> String { + // Fall back to substring match when the error arrives as a pre-formatted string + // with no structured "code" field (the server embeds the code inline). + if code == Some("npm_credentials_invalid") || raw_message.contains("npm_credentials_invalid") { + let user_msg = "NPM credentials are invalid or missing. \ + Update them with:\n \ + stacker secrets set npm_credentials --scope server \ + --body-file ./npm_credentials.json" + .to_string(); + if cli_debug_enabled() { + format!("{}\n [debug] {}", user_msg, raw_message) + } else { + user_msg + } + } else { + raw_message + } +} + fn agent_command_error_message(info: &AgentCommandInfo) -> Option { if let Some(error) = info.error.as_ref() { - return json_error_message(error).or_else(|| Some(fmt::pretty_json(error))); + let raw = json_error_message(error).unwrap_or_else(|| fmt::pretty_json(error)); + let code = error + .get("code") + .and_then(|v| v.as_str()) + .or_else(|| error.get("error_code").and_then(|v| v.as_str())); + return Some(sanitize_npm_credentials_message(raw, code)); } let result = info.result.as_ref()?; @@ -200,14 +226,56 @@ fn agent_command_error_message(info: &AgentCommandInfo) -> Option { return None; } - let mut message = json_error_message(result) + let raw_message = json_error_message(result) .unwrap_or_else(|| "Agent command reported an application error".to_string()); - if let Some(code) = result.get("error_code").and_then(|value| value.as_str()) { - message = format!("{} ({})", message, code); + + // "code" is already embedded into raw_message by format_error_message. + // "error_code" is a separate field not yet appended — handled below. + let inline_code = result.get("code").and_then(|v| v.as_str()); + let extra_code = result.get("error_code").and_then(|v| v.as_str()); + + let mut message = sanitize_npm_credentials_message(raw_message, inline_code.or(extra_code)); + + // Append extra_code (the "error_code" field) if present — it is NOT yet in the message. + if let Some(code) = extra_code { + // Skip appending if sanitize_npm_credentials_message already replaced the whole message. + if inline_code != Some("npm_credentials_invalid") { + message = format!("{} ({})", message, code); + if code == "npm_create_failed" { + message = format!( + "{}\n\n{}", + message, + npm_create_failed_guidance(Some(result)) + ); + } + } } Some(message) } +fn npm_create_failed_guidance(result: Option<&serde_json::Value>) -> String { + let domain = result + .and_then(|value| value.get("domain_names")) + .and_then(|value| value.as_array()) + .and_then(|domains| domains.first()) + .and_then(|value| value.as_str()) + .or_else(|| { + result + .and_then(|value| value.get("domain")) + .and_then(|value| value.as_str()) + }) + .unwrap_or(""); + + format!( + "Route diagnostics:\n\ + - Nginx Proxy Manager may have created the host despite returning an error; check for an existing host for {domain} and retry configure-proxy to adopt it.\n\ + - Verify DNS A/AAAA records for {domain} point at this server before requesting Let's Encrypt.\n\ + - Ensure cloud firewall ports are open: stacker cloud firewall add --server-id --public-ports 80/tcp,443/tcp\n\ + - Check for a duplicate NPM proxy host using the same domain.\n\ + - SSL is off by default; add --ssl only once DNS is confirmed and ports 80/443 are open." + ) +} + async fn execute_agent_command( ctx: &CliRuntime, request: &AgentEnqueueRequest, @@ -264,7 +332,7 @@ async fn execute_agent_command( } } -fn run_agent_command( +pub(crate) fn run_agent_command( ctx: &CliRuntime, request: &AgentEnqueueRequest, spinner_msg: &str, @@ -372,6 +440,93 @@ fn print_command_result(info: &AgentCommandInfo, json: bool) { } } +fn print_health_result(info: &AgentCommandInfo) { + if let Some(ref result) = info.result { + let result_type = result.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + // "all_health": list of all containers + if result_type == "all_health" { + let overall = result.get("status").and_then(|v| v.as_str()).unwrap_or("-"); + println!("Overall: {} {}", progress::status_icon(overall), overall); + println!(); + if let Some(containers) = result.get("containers").and_then(|v| v.as_array()) { + println!("{:<28} {:<10} {}", "CONTAINER", "STATE", "STATUS"); + for c in containers { + let name = c.get("container_name").and_then(|v| v.as_str()).unwrap_or("-"); + let state = c.get("container_state").and_then(|v| v.as_str()).unwrap_or("-"); + let status = c.get("status").and_then(|v| v.as_str()).unwrap_or("-"); + println!( + "{:<28} {} {:<8} {}", + fmt::truncate(name, 26), + progress::status_icon(state), + state, + status, + ); + } + } + return; + } + + // Single-container health + if result_type == "health" { + let state = result.get("container_state").and_then(|v| v.as_str()).unwrap_or("-"); + let status = result.get("status").and_then(|v| v.as_str()).unwrap_or("-"); + let app = result.get("app_code").and_then(|v| v.as_str()).unwrap_or("-"); + println!( + "{}: {} {} ({})", + app, + progress::status_icon(state), + state, + status + ); + if let Some(metrics) = result.get("metrics") { + println!("{}", fmt::pretty_json(metrics)); + } + return; + } + + // Fallback + println!("{}", fmt::pretty_json(result)); + } + + if let Some(error) = agent_command_error_message(info) { + eprintln!("Error: {}", error); + } +} + +fn print_all_container_health(containers: &[serde_json::Value]) { + if containers.is_empty() { + println!("No containers found."); + return; + } + + let all_running = containers.iter().all(|c| { + let state = c.get("status").and_then(|v| v.as_str()).unwrap_or("-"); + state == "running" + }); + let overall = if all_running { "running" } else { "degraded" }; + println!("Overall: {} {}", progress::status_icon(overall), overall); + println!(); + + println!("{:<28} {:<12} {:<8} {:<8} {}", "CONTAINER", "STATE", "CPU%", "MEM%", "IMAGE"); + for c in containers { + let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("-"); + let state = c.get("status").and_then(|v| v.as_str()).unwrap_or("-"); + let image = c.get("image").and_then(|v| v.as_str()).unwrap_or("-"); + let cpu = c.get("cpu_pct").and_then(|v| v.as_f64()).unwrap_or(0.0); + let mem = c.get("mem_pct").and_then(|v| v.as_f64()).unwrap_or(0.0); + println!( + "{:<28} {} {:<10} {:<8.1} {:<8.1} {}", + fmt::truncate(name, 26), + progress::status_icon(state), + state, + cpu, + mem, + fmt::truncate(image, 30), + ); + } +} + /// Pre-flight connection check for risky agent commands. /// /// Enqueues a `check_connections` command to the agent and, if active HTTP @@ -498,8 +653,21 @@ impl CallableTrait for AgentHealthCommand { let ctx = CliRuntime::new("agent health")?; let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + // No specific app requested → list all containers with health metrics. + // This avoids sending app_code="all" to older agents that don't handle it. + if self.app_code.is_none() && !self.include_system { + let containers = fetch_live_containers(&ctx, &hash)? + .unwrap_or_default(); + if self.json { + println!("{}", serde_json::to_string_pretty(&containers)?); + } else { + print_all_container_health(&containers); + } + return Ok(()); + } + let params = crate::forms::status_panel::HealthCommandRequest { - app_code: self.app_code.clone().unwrap_or_else(|| "all".to_string()), + app_code: self.app_code.clone().unwrap_or_default(), container: None, include_metrics: true, include_system: self.include_system, @@ -510,7 +678,11 @@ impl CallableTrait for AgentHealthCommand { .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; let info = run_agent_command(&ctx, &request, "Checking health", DEFAULT_TIMEOUT_SECS)?; - print_command_result(&info, self.json); + if self.json { + print_command_result(&info, true); + } else { + print_health_result(&info); + } Ok(()) } } @@ -631,6 +803,8 @@ pub struct AgentDeployAppCommand { pub json: bool, pub deployment: Option, pub environment: Option, + pub plan: bool, + pub apply_plan: Option, } impl AgentDeployAppCommand { @@ -651,16 +825,69 @@ impl AgentDeployAppCommand { json, deployment, environment, + plan: false, + apply_plan: None, } } + + pub fn with_plan(mut self, plan: bool) -> Self { + self.plan = plan; + self + } + + pub fn with_apply_plan(mut self, apply_plan: Option) -> Self { + self.apply_plan = apply_plan; + self + } } impl CallableTrait for AgentDeployAppCommand { fn call(&self) -> Result<(), Box> { let ctx = CliRuntime::new("agent deploy-app")?; - let project_dir = std::env::current_dir().map_err(CliError::Io)?; let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + if self.plan { + return crate::console::commands::cli::deployment::run_remote_deployment_plan( + Some(&hash), + crate::services::DeployPlanOperation::DeployApp, + Some(&self.app_code), + None, + None, + ); + } + + if let Some(fingerprint) = self.apply_plan.as_deref() { + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let config_path = project_dir.join("stacker.yml"); + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + let base_url = + crate::console::commands::cli::status::resolve_stacker_base_url(&ctx.creds); + let validated_plan = ctx.block_on(async { + crate::console::commands::cli::deployment::fetch_remote_deployment_plan( + &config, + &base_url, + &ctx.client, + Some(&hash), + crate::services::DeployPlanOperation::DeployApp, + Some(&self.app_code), + None, + Some(fingerprint), + ) + .await + })?; + if !validated_plan.has_changes { + println!( + "Plan already satisfied for {}. Nothing to apply.", + validated_plan.deployment_hash + ); + return Ok(()); + } + } + + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + check_active_connections(&ctx, &hash, self.force_recreate)?; let local_config = local_config_files_for_agent_deploy( &project_dir, @@ -774,12 +1001,19 @@ fn local_config_files_for_agent_deploy( } let bundle = if compose_path == configured_compose_path.as_path() { - build_config_bundle( + let mut bundle = build_config_bundle( project_dir, &environment, &configured_compose_path, config.env_file.as_deref(), - )? + )?; + if materialize_stacker_service_in_bundle(&mut bundle, &config, app_code)? { + result.notices.push(format!( + "Materialized service '{}' from stacker.yml into the remote compose payload.", + app_code + )); + } + bundle } else { let app_bundle = build_config_bundle(project_dir, &environment, compose_path, None)?; let project_compose = std::fs::read_to_string(&configured_compose_path).map_err(|err| { @@ -801,6 +1035,20 @@ fn local_config_files_for_agent_deploy( if result.compose_content.is_none() { result.compose_content = Some(bundle_compose_content(&bundle)?); } + if let Some(compose_content) = result.compose_content.take() { + let lock = crate::cli::deployment_lock::DeploymentLock::load_active(project_dir)?; + let target_label = active_target + .clone() + .or_else(|| lock.as_ref().map(|lock| lock.target.clone())); + let project_id_label = lock + .and_then(|lock| lock.project_id) + .map(|project_id| project_id.to_string()); + result.compose_content = Some(annotate_project_compose_with_stacker_labels( + &compose_content, + target_label.as_deref(), + project_id_label.as_deref(), + )?); + } let deploy_config_files: Vec<_> = bundle .config_files @@ -808,7 +1056,7 @@ fn local_config_files_for_agent_deploy( .filter(|file| { file.get("destination_path") .and_then(|path| path.as_str()) - .map(|path| path == ".env" || path.starts_with("/opt/stacker/deployments/")) + .map(|path| path != "docker-compose.yml") .unwrap_or(false) }) .collect(); @@ -832,6 +1080,51 @@ fn bundle_compose_content( }) } +fn materialize_stacker_service_in_bundle( + bundle: &mut ConfigBundleArtifacts, + config: &StackerConfig, + app_code: &str, +) -> Result { + let compose = bundle_compose_content(bundle)?; + let updated = merge_stacker_config_service(&compose, config, app_code)?; + if updated == compose { + return Ok(false); + } + + std::fs::write(&bundle.remote_compose_path, &updated)?; + if let Some(file) = bundle.config_files.iter_mut().find(|file| { + file.get("destination_path").and_then(|path| path.as_str()) == Some("docker-compose.yml") + }) { + file["content"] = serde_json::Value::String(updated); + } + Ok(true) +} + +fn merge_stacker_config_service( + project_compose: &str, + config: &StackerConfig, + app_code: &str, +) -> Result { + let project_doc: serde_yaml::Value = serde_yaml::from_str(project_compose)?; + let service_exists = project_doc + .as_mapping() + .and_then(|root| root.get(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping) + .map(|services| services.contains_key(serde_yaml::Value::String(app_code.to_string()))) + .unwrap_or(false); + if service_exists + || !config + .services + .iter() + .any(|service| service.name == app_code) + { + return Ok(project_compose.to_string()); + } + + let generated_compose = ComposeDefinition::try_from(config)?.render(); + merge_compose_service(project_compose, &generated_compose, app_code) +} + fn merge_compose_service( project_compose: &str, app_compose: &str, @@ -840,7 +1133,7 @@ fn merge_compose_service( let mut project_doc: serde_yaml::Value = serde_yaml::from_str(project_compose)?; let app_doc: serde_yaml::Value = serde_yaml::from_str(app_compose)?; - let app_service = app_doc + let mut app_service = app_doc .as_mapping() .and_then(|root| root.get(serde_yaml::Value::String("services".to_string()))) .and_then(serde_yaml::Value::as_mapping) @@ -851,6 +1144,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(); + align_service_networks_with_project(&mut app_service, &project_doc); let project_services = project_doc .as_mapping_mut() @@ -861,13 +1156,172 @@ fn merge_compose_service( })?; project_services.insert(serde_yaml::Value::String(app_code.to_string()), app_service); - merge_compose_top_level_mapping(&mut project_doc, &app_doc, "networks"); + if should_merge_networks { + merge_compose_top_level_mapping(&mut project_doc, &app_doc, "networks"); + } merge_compose_top_level_mapping(&mut project_doc, &app_doc, "volumes"); serde_yaml::to_string(&project_doc) .map_err(|err| CliError::ConfigValidation(format!("failed to merge compose: {err}"))) } +fn annotate_project_compose_with_stacker_labels( + compose_content: &str, + target: Option<&str>, + project_id: Option<&str>, +) -> Result { + let mut doc: serde_yaml::Value = serde_yaml::from_str(compose_content)?; + let services = doc + .as_mapping_mut() + .and_then(|root| root.get_mut(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping_mut) + .ok_or_else(|| CliError::ConfigValidation("compose does not define services".into()))?; + + for (service_name, service) in services { + let Some(service_name) = service_name.as_str().map(ToOwned::to_owned) else { + continue; + }; + let Some(service_map) = service.as_mapping_mut() else { + continue; + }; + let labels_key = serde_yaml::Value::String("labels".to_string()); + let mut labels = service_map + .remove(&labels_key) + .map(compose_labels_to_mapping) + .unwrap_or_default(); + + insert_compose_label( + &mut labels, + crate::helpers::stacker_labels::SCOPE, + crate::helpers::stacker_labels::SCOPE_PROJECT, + ); + insert_compose_label( + &mut labels, + crate::helpers::stacker_labels::SERVICE, + &service_name, + ); + insert_compose_label( + &mut labels, + crate::helpers::stacker_labels::DNS, + &service_name, + ); + if let Some(target) = target.filter(|value| !value.trim().is_empty()) { + insert_compose_label(&mut labels, crate::helpers::stacker_labels::TARGET, target); + } + if let Some(project_id) = project_id.filter(|value| !value.trim().is_empty()) { + insert_compose_label( + &mut labels, + crate::helpers::stacker_labels::PROJECT_ID, + project_id, + ); + } + + service_map.insert(labels_key, serde_yaml::Value::Mapping(labels)); + } + + serde_yaml::to_string(&doc) + .map_err(|err| CliError::ConfigValidation(format!("failed to annotate compose: {err}"))) +} + +fn compose_labels_to_mapping(value: serde_yaml::Value) -> serde_yaml::Mapping { + match value { + serde_yaml::Value::Mapping(mapping) => mapping, + serde_yaml::Value::Sequence(items) => items + .into_iter() + .filter_map(|item| { + let label = item.as_str()?; + let (key, value) = label.split_once('=')?; + Some(( + serde_yaml::Value::String(key.to_string()), + serde_yaml::Value::String(value.to_string()), + )) + }) + .collect(), + _ => serde_yaml::Mapping::new(), + } +} + +fn insert_compose_label(labels: &mut serde_yaml::Mapping, key: &str, value: &str) { + labels.insert( + serde_yaml::Value::String(key.to_string()), + serde_yaml::Value::String(value.to_string()), + ); +} + +fn align_service_networks_with_project( + app_service: &mut serde_yaml::Value, + project_doc: &serde_yaml::Value, +) { + let project_networks = project_service_networks(project_doc); + let Some(service_map) = app_service.as_mapping_mut() else { + return; + }; + let networks_key = serde_yaml::Value::String("networks".to_string()); + if project_networks.is_empty() { + service_map.remove(&networks_key); + return; + } + + service_map.insert( + networks_key, + serde_yaml::Value::Sequence( + project_networks + .into_iter() + .map(serde_yaml::Value::String) + .collect(), + ), + ); +} + +fn project_service_networks(project_doc: &serde_yaml::Value) -> Vec { + let Some(project_services) = project_doc + .as_mapping() + .and_then(|root| root.get(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping) + else { + return Vec::new(); + }; + + let mut networks = Vec::new(); + for service in project_services.values() { + let Some(networks_value) = service + .as_mapping() + .and_then(|service| service.get(serde_yaml::Value::String("networks".to_string()))) + else { + continue; + }; + collect_network_names(networks_value, &mut networks); + } + networks +} + +fn collect_network_names(value: &serde_yaml::Value, networks: &mut Vec) { + match value { + serde_yaml::Value::String(name) => push_unique_network(networks, name), + serde_yaml::Value::Sequence(items) => { + for item in items { + if let Some(name) = item.as_str() { + push_unique_network(networks, name); + } + } + } + serde_yaml::Value::Mapping(map) => { + for key in map.keys() { + if let Some(name) = key.as_str() { + push_unique_network(networks, name); + } + } + } + _ => {} + } +} + +fn push_unique_network(networks: &mut Vec, name: &str) { + if !networks.iter().any(|existing| existing == name) { + networks.push(name.to_string()); + } +} + fn merge_compose_top_level_mapping( project_doc: &mut serde_yaml::Value, app_doc: &serde_yaml::Value, @@ -1217,22 +1671,24 @@ impl CallableTrait for AgentStatusCommand { .unwrap_or("unknown"); let version = item .get("agent") - .and_then(|a| a.get("version")) - .and_then(|v| v.as_str()) - .unwrap_or("-"); + .and_then(|agent| agent_display_version(agent, None)); let n_apps = item .get("apps") .and_then(|v| v.as_array()) .map(|a| a.len()) .unwrap_or(0); + let version_label = version + .as_deref() + .map(agent_version_label) + .unwrap_or_default(); progress::finish_success( &pb, &format!( - "Agent status fetched — {} {} · v{} · {} app(s)", + "Agent status fetched — {} {}{} · {} app(s)", progress::status_icon(agent_status), agent_status, - version, + version_label, n_apps, ), ); @@ -1308,7 +1764,7 @@ fn print_containers_summary(containers: &[serde_json::Value]) { return; } - println!("{:<24} {:<12} {:<30}", "CONTAINER", "STATE", "IMAGE"); + println!("{:<24} {:<12} {:<22} {:<30}", "CONTAINER", "STATE", "PORTS", "IMAGE"); for c in containers { let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("-"); let state = c @@ -1317,11 +1773,23 @@ fn print_containers_summary(containers: &[serde_json::Value]) { .and_then(|v| v.as_str()) .unwrap_or("-"); let image = c.get("image").and_then(|v| v.as_str()).unwrap_or("-"); + let ports = c + .get("ports") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|p| p.as_str()) + .collect::>() + .join(", ") + }) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "-".to_string()); println!( - "{:<24} {} {:<10} {:<30}", + "{:<24} {} {:<10} {:<22} {:<30}", fmt::truncate(name, 22), progress::status_icon(state), state, + fmt::truncate(&ports, 20), fmt::truncate(image, 28), ); } @@ -1346,6 +1814,87 @@ fn is_stale_platform_project_container(container: &serde_json::Value) -> bool { .any(|code| normalized_name.contains(code)) } +fn agent_display_version( + agent: &serde_json::Value, + live_containers: Option<&Vec>, +) -> Option { + agent + .get("system_info") + .and_then(agent_version_from_system_info) + .or_else(|| { + agent + .get("version") + .and_then(|value| value.as_str()) + .and_then(non_placeholder_agent_version) + }) + .or_else(|| { + live_containers.and_then(|containers| agent_version_from_live_containers(containers)) + }) +} + +fn agent_version_from_system_info(system_info: &serde_json::Value) -> Option { + [ + "agent_version", + "agentVersion", + "status_panel_agent_version", + "statusPanelAgentVersion", + "dashboard_version", + "dashboardVersion", + "version", + ] + .iter() + .find_map(|key| { + system_info + .get(*key) + .and_then(|value| value.as_str()) + .and_then(non_placeholder_agent_version) + }) +} + +fn agent_version_from_live_containers(containers: &[serde_json::Value]) -> Option { + containers.iter().find_map(|container| { + let name = container.get("name").and_then(|value| value.as_str())?; + let normalized_name = crate::project_app::normalize_app_code(name); + if !normalized_name.contains("statuspanel_agent") + && !normalized_name.contains("status_panel_agent") + { + return None; + } + + container + .get("image") + .and_then(|value| value.as_str()) + .and_then(image_tag) + .and_then(non_placeholder_agent_version) + }) +} + +fn image_tag(image: &str) -> Option<&str> { + let image_without_digest = image.split('@').next().unwrap_or(image); + image_without_digest + .rsplit_once(':') + .map(|(_, tag)| tag) + .filter(|tag| !tag.contains('/')) +} + +fn non_placeholder_agent_version(version: &str) -> Option { + let version = version.trim().trim_start_matches('v'); + if version.is_empty() + || matches!( + version.to_ascii_lowercase().as_str(), + "1.0.0" | "latest" | "main" | "stable" | "unknown" + ) + { + return None; + } + + Some(version.to_string()) +} + +fn agent_version_label(version: &str) -> String { + format!(" · v{}", version.trim().trim_start_matches('v')) +} + /// Pretty-print a snapshot summary for human consumption. fn print_snapshot_summary( snap: &serde_json::Value, @@ -1359,17 +1908,20 @@ fn print_snapshot_summary( .get("status") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - let version = agent.get("version").and_then(|v| v.as_str()).unwrap_or("-"); + let version_label = agent_display_version(agent, live_containers) + .as_deref() + .map(agent_version_label) + .unwrap_or_default(); let heartbeat = agent .get("last_heartbeat") .and_then(|v| v.as_str()) .unwrap_or("-"); println!( - "Agent: {} {} (v{})", + "Agent: {} {}{}", progress::status_icon(status), status, - version + version_label ); println!("Heartbeat: {}", heartbeat); } else { @@ -1670,7 +2222,7 @@ impl CallableTrait for AgentHistoryCommand { // ── Install (deploy Status Panel to existing server) ─ -/// `stacker agent install [--file ] [--json]` +/// `stacker agent install [--file ] [--persist-config] [--json]` /// /// Deploys the Status Panel agent to an existing server that was previously /// deployed without it. Reads the project identity from stacker.yml, finds @@ -1678,12 +2230,17 @@ impl CallableTrait for AgentHistoryCommand { /// a deploy with only the statuspanel feature enabled. pub struct AgentInstallCommand { pub file: Option, + pub persist_config: bool, pub json: bool, } impl AgentInstallCommand { - pub fn new(file: Option, json: bool) -> Self { - Self { file, json } + pub fn new(file: Option, persist_config: bool, json: bool) -> Self { + Self { + file, + persist_config, + json, + } } } @@ -1714,6 +2271,94 @@ fn fallback_server_config_for_agent_install( }) } +const AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_KEY: &str = "status_panel_only"; +const AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_VALUE: &str = "true"; +const AGENT_INSTALL_MODE_KEY: &str = "statuspanel_install_mode"; +const AGENT_INSTALL_MODE_VALUE: &str = "status_only"; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentInstallConfigPersistence { + config_path: PathBuf, + backup_path: PathBuf, + changed: bool, +} + +fn persist_agent_install_config( + config_path: &Path, +) -> Result { + let mut config = crate::cli::config_parser::StackerConfig::from_file_raw(config_path)?; + let changed = !config.monitoring.status_panel; + let backup_path = PathBuf::from(format!("{}.bak", config_path.display())); + + if changed { + config.monitoring.status_panel = true; + let yaml = serde_yaml::to_string(&config).map_err(|e| { + CliError::ConfigValidation(format!("Failed to serialize config: {}", e)) + })?; + std::fs::copy(config_path, &backup_path)?; + std::fs::write(config_path, yaml)?; + } + + Ok(AgentInstallConfigPersistence { + config_path: config_path.to_path_buf(), + backup_path, + changed, + }) +} + +fn persist_agent_install_config_if_requested( + config_path: &Path, + persist_config: bool, +) -> Result, CliError> { + if !persist_config { + return Ok(None); + } + + persist_agent_install_config(config_path).map(Some) +} + +fn print_agent_install_config_persistence(result: &AgentInstallConfigPersistence) { + if result.changed { + eprintln!( + "✓ Updated monitoring.status_panel=true in {}", + result.config_path.display() + ); + eprintln!(" Backup written to {}", result.backup_path.display()); + } else { + eprintln!( + "✓ monitoring.status_panel already enabled in {}", + result.config_path.display() + ); + } +} + +fn add_agent_install_scope_contract(deploy_form: &mut serde_json::Value) { + if let Some(root) = deploy_form.as_object_mut() { + root.entry(AGENT_INSTALL_MODE_KEY.to_string()) + .or_insert_with(|| serde_json::Value::String(AGENT_INSTALL_MODE_VALUE.to_string())); + } + + let Some(vars) = deploy_form + .get_mut("stack") + .and_then(|value| value.get_mut("vars")) + .and_then(|value| value.as_array_mut()) + else { + return; + }; + + if vars.iter().any(|value| { + value.get("key").and_then(|key| key.as_str()) + == Some(AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_KEY) + }) { + return; + } + + vars.push(serde_json::json!({ + "key": AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_KEY, + "value": AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_VALUE, + })); +} + fn build_agent_install_deploy_request( config: &crate::cli::config_parser::StackerConfig, server: &crate::cli::stacker_client::ServerInfo, @@ -1786,6 +2431,7 @@ fn build_agent_install_deploy_request( } } + add_agent_install_scope_contract(&mut deploy_form); return Ok((None, deploy_form)); } @@ -1797,7 +2443,7 @@ fn build_agent_install_deploy_request( ) })?; - let deploy_form = serde_json::json!({ + let mut deploy_form = serde_json::json!({ "cloud": { "provider": server.cloud.clone().unwrap_or_else(|| "htz".to_string()), "save_token": true, @@ -1826,6 +2472,7 @@ fn build_agent_install_deploy_request( }, }); + add_agent_install_scope_contract(&mut deploy_form); Ok((Some(cloud_id), deploy_form)) } @@ -1897,6 +2544,8 @@ impl CallableTrait for AgentInstallCommand { match result { Ok(resp) => { progress::finish_success(&pb, "Status Panel agent installation triggered"); + let persistence = + persist_agent_install_config_if_requested(&config_path, self.persist_config)?; if self.json { println!( @@ -1916,6 +2565,13 @@ impl CallableTrait for AgentInstallCommand { println!(); println!("The Status Panel agent will be installed on the server."); println!("Once ready, use `stacker agent status` to verify connectivity."); + if let Some(persistence) = persistence.as_ref() { + print_agent_install_config_persistence(persistence); + } else { + println!( + "Local stacker.yml unchanged. Re-run with --persist-config to set monitoring.status_panel=true locally." + ); + } } } Err(e) => { @@ -1937,6 +2593,12 @@ mod tests { use super::*; use tempfile::TempDir; + fn label_value<'a>(labels: &'a serde_yaml::Mapping, key: &str) -> Option<&'a str> { + labels + .get(serde_yaml::Value::String(key.to_string())) + .and_then(serde_yaml::Value::as_str) + } + fn sample_server_info() -> crate::cli::stacker_client::ServerInfo { crate::cli::stacker_client::ServerInfo { id: 7, @@ -1959,6 +2621,19 @@ mod tests { } } + fn stack_var_value<'a>(deploy_form: &'a serde_json::Value, key: &str) -> Option<&'a str> { + deploy_form["stack"]["vars"] + .as_array()? + .iter() + .find(|value| value.get("key").and_then(|item| item.as_str()) == Some(key)) + .and_then(|value| value.get("value")) + .and_then(|value| value.as_str()) + } + + fn top_level_str<'a>(deploy_form: &'a serde_json::Value, key: &str) -> Option<&'a str> { + deploy_form.get(key).and_then(|value| value.as_str()) + } + #[test] fn compose_service_has_env_file_detects_service_topology() { let dir = TempDir::new().expect("temp dir"); @@ -2131,6 +2806,92 @@ environments: })); } + #[test] + fn local_config_files_materializes_stacker_yml_service_into_project_compose() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + std::fs::create_dir_all(root.join("docker/prod")).expect("project compose dir"); + std::fs::write( + root.join("docker/prod/compose.yml"), + r#" +services: + status-panel-web: + image: trydirect/status-panel-web:latest +"#, + ) + .expect("project compose"); + std::fs::write( + root.join("stacker.yml"), + r#" +name: status-panel +project: + identity: status-panel +app: + image: trydirect/status-panel-web:latest +deploy: + target: server + environment: prod +environments: + prod: + compose_file: docker/prod/compose.yml +services: + - name: smtp + image: trydirect/smtp + ports: + - "1025:25" + environment: + PORT: "25" + RELAY_NETWORKS: ":127.0.0.0/8:10.0.0.0/8:172.16.0.0/12:192.168.0.0/16" + volumes: + - smtp_data:/data +"#, + ) + .expect("stacker config"); + + let config = local_config_files_for_agent_deploy(root, "smtp", None).unwrap(); + + let compose = config.compose_content.expect("compose content"); + assert!(compose.contains("status-panel-web:")); + assert!(compose.contains("smtp:")); + assert!(compose.contains("image: trydirect/smtp")); + assert!(compose.contains("1025:25")); + assert!(compose.contains("RELAY_NETWORKS")); + assert!(compose.contains("smtp_data:")); + assert!(compose.contains("my.stacker.scope: project")); + assert!(compose.contains("my.stacker.service: smtp")); + assert!(compose.contains("my.stacker.dns: smtp")); + assert!(!compose.contains("app-network")); + } + + #[test] + fn annotate_project_compose_adds_stable_stacker_labels() { + let compose = r#" +services: + smtp: + image: trydirect/smtp + labels: + - existing=value +"#; + + let annotated = + annotate_project_compose_with_stacker_labels(compose, Some("cloud"), Some("123")) + .unwrap(); + let doc: serde_yaml::Value = serde_yaml::from_str(&annotated).unwrap(); + let labels = doc + .get("services") + .and_then(|services| services.get("smtp")) + .and_then(|service| service.get("labels")) + .and_then(serde_yaml::Value::as_mapping) + .unwrap(); + + assert_eq!(label_value(labels, "existing"), Some("value")); + assert_eq!(label_value(labels, "my.stacker.project_id"), Some("123")); + assert_eq!(label_value(labels, "my.stacker.target"), Some("cloud")); + assert_eq!(label_value(labels, "my.stacker.scope"), Some("project")); + assert_eq!(label_value(labels, "my.stacker.service"), Some("smtp")); + assert_eq!(label_value(labels, "my.stacker.dns"), Some("smtp")); + } + #[test] fn local_config_files_merges_app_local_service_into_project_compose() { let dir = TempDir::new().expect("temp dir"); @@ -2173,13 +2934,13 @@ environments: assert!(compose.contains("syncopia/device-api:prod")); assert!(compose.contains("postgres:17-alpine")); assert!(!compose.contains("syncopia/device-api:latest")); - assert!(compose.contains("/opt/stacker/deployments/prod/files/device-api/docker/prod/.env")); + assert!(compose.contains("device-api/docker/prod/.env")); assert!(config.notices.is_empty()); let config_files = config.config_files.expect("config files"); assert!(config_files.iter().any(|file| { file.get("destination_path") .and_then(|path| path.as_str()) - .map(|path| path.ends_with("/device-api/docker/prod/.env")) + .map(|path| path == "device-api/docker/prod/.env") .unwrap_or(false) })); } @@ -2230,7 +2991,7 @@ environments: assert!(config_files.iter().any(|file| { file.get("destination_path") .and_then(|path| path.as_str()) - .map(|path| path.ends_with("/device-api/docker/prod/.env")) + .map(|path| path == "device-api/docker/prod/.env") .unwrap_or(false) })); assert!(!config_files.iter().any(|file| { @@ -2278,6 +3039,47 @@ environments: print_snapshot_summary(&snap, None); } + #[test] + fn agent_display_version_suppresses_placeholder_version() { + let agent = serde_json::json!({ + "version": "1.0.0", + "status": "online" + }); + + assert_eq!(agent_display_version(&agent, None), None); + } + + #[test] + fn agent_display_version_prefers_system_info_version() { + let agent = serde_json::json!({ + "version": "1.0.0", + "system_info": { + "agent_version": "0.2.8" + } + }); + + assert_eq!( + agent_display_version(&agent, None), + Some("0.2.8".to_string()) + ); + } + + #[test] + fn agent_display_version_can_use_status_agent_container_tag() { + let agent = serde_json::json!({ + "version": "1.0.0" + }); + let containers = vec![serde_json::json!({ + "name": "statuspanel-agent", + "image": "ghcr.io/trydirect/statuspanel-agent:0.3.1" + })]; + + assert_eq!( + agent_display_version(&agent, Some(&containers)), + Some("0.3.1".to_string()) + ); + } + #[test] fn visible_containers_hides_stale_platform_project_container() { let containers = vec![ @@ -2339,6 +3141,14 @@ environments: .as_array() .expect("integrated_features array") .contains(&serde_json::Value::String("statuspanel".to_string()))); + assert_eq!( + stack_var_value(&deploy_form, AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_KEY), + Some(AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_VALUE) + ); + assert_eq!( + top_level_str(&deploy_form, AGENT_INSTALL_MODE_KEY), + Some(AGENT_INSTALL_MODE_VALUE) + ); } #[test] @@ -2364,6 +3174,116 @@ environments: assert_eq!(deploy_form["cloud"]["provider"], "htz"); assert_eq!(deploy_form["server"]["server_id"], 7); assert_eq!(deploy_form["server"]["connection_mode"], "status_panel"); + assert_eq!( + stack_var_value(&deploy_form, AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_KEY), + Some(AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_VALUE) + ); + assert_eq!( + top_level_str(&deploy_form, AGENT_INSTALL_MODE_KEY), + Some(AGENT_INSTALL_MODE_VALUE) + ); + } + + #[test] + fn persist_agent_install_config_enables_status_panel_monitoring() { + let dir = TempDir::new().expect("temp dir"); + let config_path = dir.path().join("stacker.yml"); + std::fs::write( + &config_path, + "name: demo\napp:\n image: ${APP_IMAGE}\nmonitoring:\n status_panel: false\n", + ) + .expect("stacker config"); + + let result = persist_agent_install_config(&config_path).expect("persist config"); + + assert!(result.changed); + assert!(result.backup_path.exists()); + + let written = std::fs::read_to_string(&config_path).expect("written config"); + assert!(written.contains("${APP_IMAGE}")); + + let config = + crate::cli::config_parser::StackerConfig::from_file_raw(&config_path).expect("config"); + assert!(config.monitoring.status_panel); + } + + #[test] + fn persist_agent_install_config_if_requested_skips_local_write_by_default() { + let dir = TempDir::new().expect("temp dir"); + let config_path = dir.path().join("stacker.yml"); + let original = + "name: demo\napp:\n image: ${APP_IMAGE}\nmonitoring:\n status_panel: false\n"; + std::fs::write(&config_path, original).expect("stacker config"); + + let result = + persist_agent_install_config_if_requested(&config_path, false).expect("skip persist"); + + assert!(result.is_none()); + assert_eq!( + std::fs::read_to_string(&config_path).expect("config should remain unchanged"), + original + ); + assert!(!dir.path().join("stacker.yml.bak").exists()); + } + + #[test] + fn persist_agent_install_config_is_noop_when_status_panel_monitoring_enabled() { + let dir = TempDir::new().expect("temp dir"); + let config_path = dir.path().join("stacker.yml"); + std::fs::write( + &config_path, + "name: demo\nmonitoring:\n status_panel: true\n", + ) + .expect("stacker config"); + + let result = persist_agent_install_config(&config_path).expect("persist config"); + + assert!(!result.changed); + assert!(!result.backup_path.exists()); + let config = + crate::cli::config_parser::StackerConfig::from_file_raw(&config_path).expect("config"); + assert!(config.monitoring.status_panel); + } + + #[test] + fn given_stacker_agent_install_when_config_is_persisted_then_stacker_yml_reflects_status_panel() + { + let dir = TempDir::new().expect("temp dir"); + let config_path = dir.path().join("stacker.yml"); + std::fs::write( + &config_path, + r#" +name: web +proxy: + type: nginx-proxy-manager + domains: + - domain: status.stacker.my + ssl: auto + upstream: status-panel-web:3000 +monitoring: + status_panel: false + healthcheck: null + metrics: null +"#, + ) + .expect("stacker config"); + + let result = persist_agent_install_config(&config_path).expect("persist config"); + + assert!(result.changed); + assert!(result.backup_path.exists()); + + let config = + crate::cli::config_parser::StackerConfig::from_file_raw(&config_path).expect("config"); + assert!(config.monitoring.status_panel); + assert_eq!( + config + .proxy + .domains + .first() + .map(|domain| domain.domain.as_str()), + Some("status.stacker.my") + ); } #[test] @@ -2460,6 +3380,193 @@ environments: ); } + // Shared lock so env-var tests don't race each other. + fn npm_creds_env_lock() -> std::sync::MutexGuard<'static, ()> { + use std::sync::{Mutex, OnceLock}; + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() + } + + fn npm_creds_invalid_info_via_result(vault_path: &str) -> AgentCommandInfo { + AgentCommandInfo { + command_id: "cmd_npm_creds".to_string(), + deployment_hash: "dep".to_string(), + command_type: "configure_proxy".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: None, + result: Some(serde_json::json!({ + "status": "error", + "code": "npm_credentials_invalid", + "message": format!("NPM credentials in Vault are invalid at {}", vault_path), + "details": vault_path, + })), + error: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + fn npm_creds_invalid_info_via_error(vault_path: &str) -> AgentCommandInfo { + AgentCommandInfo { + command_id: "cmd_npm_creds".to_string(), + deployment_hash: "dep".to_string(), + command_type: "configure_proxy".to_string(), + status: "failed".to_string(), + priority: "normal".to_string(), + parameters: None, + result: None, + error: Some(serde_json::json!({ + "code": "npm_credentials_invalid", + "message": format!("NPM credentials in Vault are invalid at {}", vault_path), + "details": vault_path, + })), + created_at: String::new(), + updated_at: String::new(), + } + } + + #[test] + fn agent_command_error_message_sanitizes_vault_path_via_result_field() { + let _guard = npm_creds_env_lock(); + std::env::remove_var("STACKER_DEBUG"); + std::env::remove_var("DEBUG"); + + let vault_path = "secret/base/status_panel/hosts/86/npm_credentials"; + let message = agent_command_error_message(&npm_creds_invalid_info_via_result(vault_path)) + .expect("error message"); + + assert!( + !message.contains(vault_path), + "Vault path must not appear in user-facing output: {message}" + ); + assert!( + message.contains("stacker secrets set npm_credentials"), + "Message should include the remediation command: {message}" + ); + } + + #[test] + fn agent_command_error_message_sanitizes_vault_path_via_error_field() { + let _guard = npm_creds_env_lock(); + std::env::remove_var("STACKER_DEBUG"); + std::env::remove_var("DEBUG"); + + let vault_path = "secret/base/status_panel/hosts/86/npm_credentials"; + let message = agent_command_error_message(&npm_creds_invalid_info_via_error(vault_path)) + .expect("error message"); + + assert!( + !message.contains(vault_path), + "Vault path must not appear in user-facing output (error field path): {message}" + ); + assert!( + message.contains("stacker secrets set npm_credentials"), + "Message should include the remediation command: {message}" + ); + } + + #[test] + fn agent_command_error_message_sanitizes_vault_path_when_error_is_preformatted_string() { + let _guard = npm_creds_env_lock(); + std::env::remove_var("STACKER_DEBUG"); + std::env::remove_var("DEBUG"); + + let vault_path = "secret/base/status_panel/hosts/86/npm_credentials"; + // Simulate the server sending a pre-formatted string (no structured "code" field) + let info = AgentCommandInfo { + command_id: "cmd_npm_creds".to_string(), + deployment_hash: "dep".to_string(), + command_type: "configure_proxy".to_string(), + status: "failed".to_string(), + priority: "normal".to_string(), + parameters: None, + result: None, + error: Some(serde_json::Value::String(format!( + "NPM credentials in Vault are invalid at {vault_path} (npm_credentials_invalid): {vault_path}" + ))), + created_at: String::new(), + updated_at: String::new(), + }; + + let message = agent_command_error_message(&info).expect("error message"); + + assert!( + !message.contains(vault_path), + "Vault path must not appear when error is a pre-formatted string: {message}" + ); + assert!( + message.contains("stacker secrets set npm_credentials"), + "Message should include the remediation command: {message}" + ); + } + + #[test] + fn agent_command_error_message_exposes_vault_path_in_debug_mode_for_npm_credentials_invalid() { + let _guard = npm_creds_env_lock(); + std::env::set_var("STACKER_DEBUG", "1"); + + let vault_path = "secret/base/status_panel/hosts/86/npm_credentials"; + // Test both paths in debug mode + let msg_via_result = + agent_command_error_message(&npm_creds_invalid_info_via_result(vault_path)); + let msg_via_error = + agent_command_error_message(&npm_creds_invalid_info_via_error(vault_path)); + std::env::remove_var("STACKER_DEBUG"); + + let msg_via_result = msg_via_result.expect("error message (result path)"); + assert!( + msg_via_result.contains(vault_path), + "Vault path should appear in debug output (result path): {msg_via_result}" + ); + assert!( + msg_via_result.contains("stacker secrets set npm_credentials"), + "Debug output should still include the remediation command: {msg_via_result}" + ); + + let msg_via_error = msg_via_error.expect("error message (error field path)"); + assert!( + msg_via_error.contains(vault_path), + "Vault path should appear in debug output (error field path): {msg_via_error}" + ); + assert!( + msg_via_error.contains("stacker secrets set npm_credentials"), + "Debug output should still include the remediation command: {msg_via_error}" + ); + } + + #[test] + fn agent_command_error_message_adds_proxy_route_diagnostics_for_npm_create_failed() { + let info = AgentCommandInfo { + command_id: "cmd_proxy".to_string(), + deployment_hash: "dep".to_string(), + command_type: "configure_proxy".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: None, + result: Some(serde_json::json!({ + "status": "error", + "error_code": "npm_create_failed", + "message": "Failed to create proxy host: 500 Internal Server Error - Internal Error", + "domain_names": ["status.stacker.my"], + "forward_port": 3000 + })), + error: None, + created_at: String::new(), + updated_at: String::new(), + }; + + let message = agent_command_error_message(&info).expect("error message"); + + assert!(message.contains("npm_create_failed")); + assert!(message.contains("Route diagnostics")); + assert!(message.contains("status.stacker.my")); + assert!(message.contains( + "stacker cloud firewall add --server-id --public-ports 80/tcp,443/tcp" + )); + assert!(message.contains("--ssl")); + } + #[test] fn agent_command_error_message_reads_structured_error_array() { let info = AgentCommandInfo { diff --git a/src/console/commands/cli/ai.rs b/src/console/commands/cli/ai.rs index 7fa0d78e..c57b27f8 100644 --- a/src/console/commands/cli/ai.rs +++ b/src/console/commands/cli/ai.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use crate::cli::ai_client::{ all_write_mode_tools, create_provider, AiProvider, AiResponse, ChatMessage, ToolCall, ToolDef, }; +use crate::cli::ai_scenarios::{load_scenario_prompt_context, ScenarioSelection}; use crate::cli::config_parser::{AiConfig, AiProviderType, StackerConfig}; use crate::cli::error::CliError; use crate::cli::service_catalog::{catalog_summary_for_ai, ServiceCatalog}; @@ -102,8 +103,8 @@ Use it to answer user questions with concrete YAML examples. stacker logs [--service S] [--follow] [--tail N]\n\ stacker destroy --confirm [--volumes]\n\ stacker config validate | show | fix | example\n\ - stacker ai ask \"question\" [--context file]\n\ - stacker proxy add DOMAIN --upstream URL --ssl auto|off\n\ + stacker ai ask \"question\" [--context file] [--scenario website-deploy] [--step STEP]\n\ + stacker proxy add DOMAIN --upstream URL --ssl[=auto|off]\n\ stacker proxy detect\n\ stacker ssh-key generate --server-id N [--save-to PATH]\n\ stacker ssh-key show --server-id N [--json]\n\ @@ -348,6 +349,25 @@ pub fn build_ai_prompt(question: &str, context_content: Option<&str>) -> String } } +pub fn build_system_prompt_base( + project_dir: &Path, + ai_config: &AiConfig, + scenario: Option<&ScenarioSelection>, + include_catalog: bool, +) -> Result { + let mut sections = vec![STACKER_SCHEMA_SYSTEM_PROMPT.to_string()]; + if include_catalog { + sections.push(catalog_summary_for_ai()); + } + + if let Some(selection) = scenario { + sections + .push(load_scenario_prompt_context(project_dir, ai_config, selection)?.rendered_prompt); + } + + Ok(sections.join("\n\n")) +} + fn build_default_project_context(project_dir: &Path) -> Option { let mut blocks: Vec = Vec::new(); @@ -393,6 +413,15 @@ pub fn run_ai_ask( question: &str, context: Option<&str>, provider: &dyn AiProvider, +) -> Result { + run_ai_ask_with_system_prompt(question, context, provider, STACKER_SCHEMA_SYSTEM_PROMPT) +} + +pub fn run_ai_ask_with_system_prompt( + question: &str, + context: Option<&str>, + provider: &dyn AiProvider, + system_prompt: &str, ) -> Result { let context_content = match context { Some(path) => { @@ -411,7 +440,7 @@ pub fn run_ai_ask( }; let prompt = build_ai_prompt(question, context_content.as_deref()); - provider.complete(&prompt, STACKER_SCHEMA_SYSTEM_PROMPT) + provider.complete(&prompt, system_prompt) } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -1114,6 +1143,8 @@ pub struct AiAskCommand { pub context: Option, pub configure: bool, pub write: bool, + pub scenario: Option, + pub step: Option, } impl AiAskCommand { @@ -1123,6 +1154,8 @@ impl AiAskCommand { context, configure: false, write: false, + scenario: None, + step: None, } } @@ -1135,6 +1168,12 @@ impl AiAskCommand { self.write = write; self } + + pub fn with_scenario(mut self, scenario: Option, step: Option) -> Self { + self.scenario = scenario; + self.step = step; + self + } } impl CallableTrait for AiAskCommand { @@ -1145,13 +1184,15 @@ impl CallableTrait for AiAskCommand { load_ai_config(DEFAULT_CONFIG_FILE)? }; let provider = create_provider(&ai_config)?; + let cwd = std::env::current_dir()?; + let scenario_selection = self + .scenario + .as_ref() + .map(|name| ScenarioSelection::new(name.clone(), self.step.clone())); if self.write { - let enriched_prompt = format!( - "{}\n\n{}", - STACKER_SCHEMA_SYSTEM_PROMPT, - catalog_summary_for_ai() - ); + let enriched_prompt = + build_system_prompt_base(&cwd, &ai_config, scenario_selection.as_ref(), true)?; let response = run_ai_ask_agentic( &self.question, self.context.as_deref(), @@ -1162,7 +1203,18 @@ impl CallableTrait for AiAskCommand { println!("{}", response); } } else { - let response = run_ai_ask(&self.question, self.context.as_deref(), provider.as_ref())?; + let system_prompt = build_system_prompt_base( + &cwd, + &ai_config, + scenario_selection.as_ref(), + scenario_selection.is_some(), + )?; + let response = run_ai_ask_with_system_prompt( + &self.question, + self.context.as_deref(), + provider.as_ref(), + &system_prompt, + )?; println!("{}", response); } Ok(()) @@ -1198,11 +1250,17 @@ Tips: /// but only for `stacker.yml` and files inside `.stacker/`. pub struct AiChatCommand { pub write: bool, + pub scenario: Option, + pub step: Option, } impl AiChatCommand { - pub fn new(write: bool) -> Self { - Self { write } + pub fn new(write: bool, scenario: Option, step: Option) -> Self { + Self { + write, + scenario, + step, + } } } @@ -1236,13 +1294,15 @@ impl CallableTrait for AiChatCommand { // Seed project context into the initial system message let cwd = std::env::current_dir()?; let project_ctx = build_default_project_context(&cwd); - let catalog_ctx = catalog_summary_for_ai(); + let scenario_selection = self + .scenario + .as_ref() + .map(|name| ScenarioSelection::new(name.clone(), self.step.clone())); + let base_system = + build_system_prompt_base(&cwd, &ai_config, scenario_selection.as_ref(), true)?; let system = match project_ctx { - Some(ctx) => format!( - "{}\n\n{}\n\n## Current project files\n{}", - STACKER_SCHEMA_SYSTEM_PROMPT, catalog_ctx, ctx - ), - None => format!("{}\n\n{}", STACKER_SCHEMA_SYSTEM_PROMPT, catalog_ctx), + Some(ctx) => format!("{}\n\n## Current project files\n{}", base_system, ctx), + None => base_system, }; let mut messages: Vec = vec![ChatMessage::system(&system)]; @@ -1322,6 +1382,19 @@ impl CallableTrait for AiChatCommand { #[cfg(test)] mod tests { use super::*; + use crate::cli::config_parser::AiProviderType; + + fn scenario_ai_config() -> AiConfig { + AiConfig { + enabled: true, + provider: AiProviderType::Ollama, + model: Some("qwen2.5-coder:latest".to_string()), + api_key: None, + endpoint: Some("http://localhost:11434".to_string()), + timeout: 300, + tasks: vec![], + } + } struct MockProvider { response: String, @@ -1394,6 +1467,28 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_build_system_prompt_base_includes_scenario_step() { + let dir = tempfile::TempDir::new().unwrap(); + let state = crate::cli::ai_scenarios::ScenarioState::new("website-deploy", "init-validate"); + crate::cli::ai_scenarios::save_scenario_state(dir.path(), &state).unwrap(); + + let prompt = build_system_prompt_base( + dir.path(), + &scenario_ai_config(), + Some(&ScenarioSelection::new( + "website-deploy", + Some("init-validate".to_string()), + )), + true, + ) + .unwrap(); + + assert!(prompt.contains("Active deployment scenario")); + assert!(prompt.contains("init-validate")); + assert!(prompt.contains("Validate generated stacker config")); + } + #[test] fn test_parse_chat_repl_command_detects_paste_mode() { assert_eq!( diff --git a/src/console/commands/cli/cloud_firewall.rs b/src/console/commands/cli/cloud_firewall.rs index 22a640af..c7017107 100644 --- a/src/console/commands/cli/cloud_firewall.rs +++ b/src/console/commands/cli/cloud_firewall.rs @@ -1,3 +1,4 @@ +use crate::cli::deployment_lock::DeploymentLock; use crate::cli::error::CliError; use crate::cli::runtime::CliRuntime; use crate::console::commands::CallableTrait; @@ -40,6 +41,15 @@ impl CloudFirewallCommand { } let project_dir = std::env::current_dir().map_err(CliError::Io)?; + + if let Ok(Some(lock)) = DeploymentLock::load_active(&project_dir) { + if let Some(server_name) = lock.server_name { + if let Ok(Some(server)) = ctx.block_on(ctx.client.find_server_by_name(&server_name)) + { + return Ok(server.id); + } + } + } let config_path = project_dir.join("stacker.yml"); if !config_path.exists() { return Err(CliError::ConfigValidation( @@ -113,7 +123,7 @@ impl CallableTrait for CloudFirewallCommand { return Ok(()); } - for line in format_response_lines(&response) { + for line in format_response_lines(&response, crate::cli::debug::cli_debug_enabled()) { println!("{}", line); } @@ -121,7 +131,7 @@ impl CallableTrait for CloudFirewallCommand { } } -fn format_response_lines(response: &ConfigureCloudFirewallResponse) -> Vec { +fn format_response_lines(response: &ConfigureCloudFirewallResponse, debug: bool) -> Vec { let mut lines = Vec::new(); if response.action == CloudFirewallAction::List { lines.push(format!( @@ -136,8 +146,10 @@ fn format_response_lines(response: &ConfigureCloudFirewallResponse) -> Vec Result { }) } +fn parse_ai_provider(s: &str) -> Result { + let json = format!("\"{}\"", s.trim().to_lowercase()); + serde_json::from_str::(&json).map_err(|_| { + CliError::ConfigValidation( + "Invalid AI provider. Use: openai, anthropic, ollama, custom".to_string(), + ) + }) +} + fn default_region_for_provider(provider: CloudProvider) -> &'static str { match provider { CloudProvider::Hetzner => "nbg1", @@ -212,7 +236,7 @@ fn default_region_for_provider(provider: CloudProvider) -> &'static str { fn default_size_for_provider(provider: CloudProvider) -> &'static str { match provider { - CloudProvider::Hetzner => "cpx11", + CloudProvider::Hetzner => "cx23", CloudProvider::Digitalocean => "s-1vcpu-2gb", CloudProvider::Aws => "t3.small", CloudProvider::Linode => "g6-standard-2", @@ -484,6 +508,132 @@ fn apply_cloud_settings( }); } +pub struct AiSetupOptions<'a> { + pub provider: Option<&'a str>, + pub endpoint: Option<&'a str>, + pub model: Option<&'a str>, + pub timeout: Option, + pub tasks: &'a [String], +} + +pub fn run_setup_ai( + config_path: &str, + options: AiSetupOptions<'_>, +) -> Result, CliError> { + let path = Path::new(config_path); + if !path.exists() { + return Err(CliError::ConfigNotFound { + path: PathBuf::from(config_path), + }); + } + + let mut config = StackerConfig::from_file_raw(path)?; + let interactive = options.provider.is_none() + && options.endpoint.is_none() + && options.model.is_none() + && options.timeout.is_none() + && options.tasks.is_empty(); + + let provider = if let Some(provider) = options.provider { + parse_ai_provider(provider)? + } else if interactive { + parse_ai_provider(&prompt_with_default( + "AI provider (openai|anthropic|ollama|custom)", + &config.ai.provider.to_string(), + )?)? + } else { + AiProviderType::Ollama + }; + + let endpoint = if let Some(endpoint) = options.endpoint { + Some(endpoint.trim().to_string()).filter(|value| !value.is_empty()) + } else if interactive { + let default = config + .ai + .endpoint + .clone() + .unwrap_or_else(|| "http://localhost:11434".to_string()); + Some(prompt_with_default("AI endpoint", &default)?).filter(|value| !value.trim().is_empty()) + } else { + config.ai.endpoint.clone() + }; + + let model = if let Some(model) = options.model { + Some(model.trim().to_string()).filter(|value| !value.is_empty()) + } else if interactive { + let default = config + .ai + .model + .clone() + .unwrap_or_else(|| "llama3.1".to_string()); + Some(prompt_with_default("AI model", &default)?).filter(|value| !value.trim().is_empty()) + } else { + config.ai.model.clone() + }; + + let timeout = if let Some(timeout) = options.timeout { + timeout + } else if interactive { + prompt_with_default("AI timeout seconds", &config.ai.timeout.to_string())? + .parse::() + .unwrap_or(config.ai.timeout) + } else if config.ai.timeout == 0 { + 300 + } else { + config.ai.timeout + }; + + let tasks = if !options.tasks.is_empty() { + options + .tasks + .iter() + .flat_map(|task| task.split(',')) + .map(str::trim) + .filter(|task| !task.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + } else if interactive { + let default = if config.ai.tasks.is_empty() { + "dockerfile,compose,troubleshoot".to_string() + } else { + config.ai.tasks.join(",") + }; + prompt_with_default("AI tasks (comma-separated)", &default)? + .split(',') + .map(str::trim) + .filter(|task| !task.is_empty()) + .map(ToOwned::to_owned) + .collect() + } else if config.ai.tasks.is_empty() { + vec![ + "dockerfile".to_string(), + "compose".to_string(), + "troubleshoot".to_string(), + ] + } else { + config.ai.tasks.clone() + }; + + config.ai.enabled = true; + config.ai.provider = provider; + config.ai.endpoint = endpoint; + config.ai.model = model; + 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)?; + + Ok(vec![ + "Enabled ai configuration".to_string(), + format!("Set ai.provider={}", config.ai.provider), + format!("Backup written to {}", backup_path), + ]) +} + pub fn run_setup_cloud_interactive(config_path: &str) -> Result, CliError> { let path = Path::new(config_path); if !path.exists() { @@ -643,7 +793,7 @@ pub fn run_fix_interactive(config_path: &str) -> Result, CliError> { .cloud .as_ref() .and_then(|c| c.size.clone()) - .unwrap_or_else(|| "cpx11".to_string()); + .unwrap_or_else(|| default_size_for_provider(provider).to_string()); let size = prompt_with_default("Cloud size", &size_default)?; let ssh_key = config.deploy.cloud.as_ref().and_then(|c| c.ssh_key.clone()); @@ -766,7 +916,19 @@ pub fn run_validate(config_path: &str) -> Result, CliError> { } let mut messages = match load_raw_path_issues(path) { - Ok(issues) => issues.iter().map(render_raw_path_issue).collect::>(), + Ok(issues) => { + let mut rendered = issues.iter().map(render_raw_path_issue).collect::>(); + if issues + .iter() + .any(|issue| matches!(issue.kind, RawPathIssueKind::Empty)) + { + rendered.push( + "Run `stacker config fix` to remove empty structural path fields safely." + .to_string(), + ); + } + rendered + } Err(_) => Vec::new(), }; @@ -807,12 +969,27 @@ pub fn run_show_resolved(config_path: &str) -> Result { .or_else(|| config.env_file.clone()) .map(|env_file| resolve_display_path(config_dir, &env_file)) .unwrap_or_else(|| "".to_string()); + let runtime_env_contract = runtime_env_contract_response(); + let layers = runtime_env_contract + .layers + .iter() + .map(|layer| { + format!( + " - name: {}\n precedence: {}\n applies_when: {}\n description: {}", + layer.name, layer.precedence, layer.applies_when, layer.description + ) + }) + .collect::>() + .join("\n"); 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 layers:\n - base\n - server (requires inherit_server_secrets: true)\n - service\n - compose_environment\n", + "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(), - compose_env_file_reference() + compose_env_file_reference(), + runtime_env_contract.version, + runtime_env_contract.order, + layers )) } @@ -863,6 +1040,69 @@ pub struct ConfigShowCommand { pub resolved: bool, } +/// `stacker config inventory --env [--service ] [--json]` +/// +/// Displays a redacted, comparable configuration key inventory. +pub struct ConfigInventoryCommand { + pub file: Option, + pub environment: String, + pub service: Option, + pub json: bool, + pub show_values: bool, + pub remote: bool, + pub project: Option, +} + +/// `stacker config diff --from --to [--service ] [--json]` +/// +/// Compares redacted local configuration inventories across environments. +pub struct ConfigDiffCommand { + pub file: Option, + pub from: String, + pub to: String, + pub service: Option, + pub json: bool, + pub strict: bool, + pub remote: bool, + pub project: Option, +} + +/// `stacker config check --env [--service ] [--json] [--strict]` +/// +/// Checks an environment against optional `config_contract` requirements. +pub struct ConfigCheckCommand { + pub file: Option, + pub environment: String, + pub service: Option, + pub json: bool, + pub strict: bool, + pub remote: bool, + pub project: Option, +} + +/// `stacker config promote --from --to [--service ]` +/// +/// Generates safe target placeholders for keys missing from the target environment. +pub struct ConfigPromoteCommand { + pub file: Option, + pub from: String, + pub to: String, + pub service: Option, + pub keys: Vec, + pub json: bool, + pub remote: bool, + pub project: Option, +} + +/// `stacker config contract suggest --env [--service ]` +/// +/// Generates a reviewable `config_contract` YAML snippet from inventory. +pub struct ConfigContractSuggestCommand { + pub file: Option, + pub environment: String, + pub service: Option, +} + /// `stacker config fix [--file stacker.yml] [--interactive]` /// /// Interactively repairs common missing required fields in stacker.yml. @@ -878,6 +1118,61 @@ pub struct ConfigSetupCloudCommand { pub file: Option, } +/// `stacker config setup ai [--file stacker.yml]` +/// +/// Guided AI setup wizard that writes ai.* without replacing unrelated config. +pub struct ConfigSetupAiCommand { + pub file: Option, + pub provider: Option, + pub endpoint: Option, + pub model: Option, + pub timeout: Option, + pub tasks: Vec, +} + +impl ConfigSetupAiCommand { + pub fn new( + file: Option, + provider: Option, + endpoint: Option, + model: Option, + timeout: Option, + tasks: Vec, + ) -> Self { + Self { + file, + provider, + endpoint, + model, + timeout, + tasks, + } + } +} + +impl CallableTrait for ConfigSetupAiCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let applied = run_setup_ai( + &path, + AiSetupOptions { + provider: self.provider.as_deref(), + endpoint: self.endpoint.as_deref(), + model: self.model.as_deref(), + timeout: self.timeout, + tasks: &self.tasks, + }, + )?; + + eprintln!("✓ Updated {}", path); + for item in applied { + eprintln!(" - {}", item); + } + eprintln!("Run: stacker config validate"); + Ok(()) + } +} + impl ConfigSetupCloudCommand { pub fn new(file: Option) -> Self { Self { file } @@ -978,6 +1273,535 @@ impl CallableTrait for ConfigShowCommand { } } +impl ConfigInventoryCommand { + pub fn new( + file: Option, + environment: String, + service: Option, + json: bool, + show_values: bool, + remote: bool, + project: Option, + ) -> Self { + Self { + file, + environment, + service, + json, + show_values, + remote, + project, + } + } +} + +impl CallableTrait for ConfigInventoryCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let mut inventory = load_inventory( + Path::new(&path), + &InventoryOptions { + environment: self.environment.clone(), + service: self.service.clone(), + show_values: self.show_values, + }, + )?; + if self.remote { + enrich_remote_service_secret_metadata( + Path::new(&path), + self.project.as_deref(), + &mut inventory, + )?; + } + + if self.json { + println!("{}", serde_json::to_string_pretty(&inventory)?); + return Ok(()); + } + + for warning in &inventory.warnings { + eprintln!("⚠ {warning}"); + } + print!("{}", format_inventory_table(&inventory)); + + Ok(()) + } +} + +fn format_inventory_table(inventory: &ConfigInventory) -> String { + let mut rows = vec![[ + "Target".to_string(), + "Key".to_string(), + "Source".to_string(), + "Present".to_string(), + "Secret".to_string(), + "Value".to_string(), + ]]; + + for target in &inventory.targets { + for key in &target.keys { + let value = if key.secret { + "[REDACTED]".to_string() + } else if key.present { + key.value_preview + .clone() + .unwrap_or_else(|| "[HIDDEN]".to_string()) + } else { + "[MISSING]".to_string() + }; + + rows.push([ + target.target_code.clone(), + key.key.clone(), + key.source.clone(), + key.present.to_string(), + key.secret.to_string(), + value, + ]); + } + } + + let mut widths = [0usize; 5]; + for row in &rows { + for index in 0..widths.len() { + widths[index] = widths[index].max(row[index].len()); + } + } + + let mut output = String::new(); + for row in rows { + output.push_str(&format!( + "{:, + from: String, + to: String, + service: Option, + json: bool, + strict: bool, + remote: bool, + project: Option, + ) -> Self { + Self { + file, + from, + to, + service, + json, + strict, + remote, + project, + } + } +} + +impl CallableTrait for ConfigDiffCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let diff = if self.remote { + let from_inventory = load_inventory( + Path::new(&path), + &InventoryOptions { + environment: self.from.clone(), + service: self.service.clone(), + show_values: false, + }, + )?; + let mut to_inventory = load_inventory( + Path::new(&path), + &InventoryOptions { + environment: self.to.clone(), + service: self.service.clone(), + show_values: false, + }, + )?; + enrich_remote_service_secret_metadata( + Path::new(&path), + self.project.as_deref(), + &mut to_inventory, + )?; + diff_inventories(from_inventory, to_inventory, self.service.clone()) + } else { + load_diff(Path::new(&path), &self.from, &self.to, self.service.clone())? + }; + + if self.json { + println!("{}", serde_json::to_string_pretty(&diff)?); + } else { + print_config_diff(&diff); + } + + if self.strict && diff.has_differences() { + return Err(Box::new(CliError::ConfigValidation(format!( + "configuration differs between {} and {}", + self.from, self.to + )))); + } + + Ok(()) + } +} + +impl ConfigCheckCommand { + pub fn new( + file: Option, + environment: String, + service: Option, + json: bool, + strict: bool, + remote: bool, + project: Option, + ) -> Self { + Self { + file, + environment, + service, + json, + strict, + remote, + project, + } + } +} + +impl CallableTrait for ConfigCheckCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let result = if self.remote { + let config = StackerConfig::from_file(Path::new(&path))?; + let mut inventory = load_inventory( + Path::new(&path), + &InventoryOptions { + environment: self.environment.clone(), + service: self.service.clone(), + show_values: false, + }, + )?; + enrich_remote_service_secret_metadata( + Path::new(&path), + self.project.as_deref(), + &mut inventory, + )?; + check_inventory(config, inventory, self.service.clone()) + } else { + load_check(Path::new(&path), &self.environment, self.service.clone())? + }; + + if self.json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + print_config_check(&result); + } + + if self.strict && result.has_required_failures() { + return Err(Box::new(CliError::ConfigValidation(format!( + "required configuration missing for {}", + self.environment + )))); + } + + Ok(()) + } +} + +impl ConfigPromoteCommand { + pub fn new( + file: Option, + from: String, + to: String, + service: Option, + keys: Vec, + json: bool, + remote: bool, + project: Option, + ) -> Self { + Self { + file, + from, + to, + service, + keys, + json, + remote, + project, + } + } +} + +impl CallableTrait for ConfigPromoteCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let plan = if self.remote { + let from_inventory = load_inventory( + Path::new(&path), + &InventoryOptions { + environment: self.from.clone(), + service: self.service.clone(), + show_values: false, + }, + )?; + let mut to_inventory = load_inventory( + Path::new(&path), + &InventoryOptions { + environment: self.to.clone(), + service: self.service.clone(), + show_values: false, + }, + )?; + enrich_remote_service_secret_metadata( + Path::new(&path), + self.project.as_deref(), + &mut to_inventory, + )?; + let diff = diff_inventories(from_inventory, to_inventory, self.service.clone()); + promotion_plan_from_diff(diff, self.keys.clone()) + } else { + load_promotion_plan( + Path::new(&path), + &self.from, + &self.to, + self.service.clone(), + self.keys.clone(), + )? + }; + + if self.json { + println!("{}", serde_json::to_string_pretty(&plan)?); + } else { + print_promotion_plan(&plan); + } + + Ok(()) + } +} + +fn print_promotion_plan(plan: &ConfigPromotionPlan) { + for warning in &plan.warnings { + eprintln!("⚠ {warning}"); + } + + if plan.is_empty() { + println!( + "No missing keys to promote from {} to {}.", + plan.from_environment, plan.to_environment + ); + return; + } + + println!( + "Promotion placeholders from {} to {}:", + plan.from_environment, plan.to_environment + ); + let mut current_target = ""; + for item in &plan.items { + if current_target != item.target { + current_target = &item.target; + println!(); + println!("# {}", item.target); + } + let secret_marker = if item.secret { " # secret" } else { "" }; + println!("{}{}", item.placeholder, secret_marker); + } + println!(); + println!("Review these placeholders and fill target values manually; plaintext is not copied."); +} + +fn enrich_remote_service_secret_metadata( + config_path: &Path, + explicit_project: Option<&str>, + inventory: &mut ConfigInventory, +) -> Result<(), CliError> { + let project_ref = resolve_remote_project_reference(config_path, explicit_project)?; + let ctx = CliRuntime::new("config remote metadata")?; + let project = ctx + .block_on(ctx.client.find_project(&project_ref))? + .ok_or_else(|| { + CliError::ConfigValidation(format!("Project '{}' was not found", project_ref)) + })?; + let registered_apps = ctx.block_on(ctx.client.list_project_apps(project.id))?; + let target_codes = registered_remote_target_codes(inventory, ®istered_apps); + + for target_code in target_codes { + match ctx.block_on(ctx.client.list_service_secrets(project.id, &target_code)) { + Ok(secrets) => { + merge_remote_secret_names( + inventory, + &target_code, + secrets.into_iter().map(|secret| secret.name), + ); + } + Err(error) => inventory.warnings.push(remote_metadata_warning( + &target_code, + &error, + cli_debug_enabled(), + )), + } + } + + Ok(()) +} + +fn remote_metadata_warning(target_code: &str, error: &CliError, debug: bool) -> String { + if debug { + return format!("Remote secret metadata unavailable for {target_code}: {error}"); + } + + format!( + "Remote secret metadata unavailable for {target_code}; rerun with DEBUG=true for details." + ) +} + +fn registered_remote_target_codes( + inventory: &ConfigInventory, + registered_apps: &[ProjectAppInfo], +) -> Vec { + let registered_codes = registered_apps + .iter() + .map(|app| app.code.as_str()) + .collect::>(); + + inventory + .targets + .iter() + .filter_map(|target| { + registered_codes + .contains(target.target_code.as_str()) + .then(|| target.target_code.clone()) + }) + .collect() +} + +fn resolve_remote_project_reference( + config_path: &Path, + explicit_project: Option<&str>, +) -> Result { + if let Some(project) = explicit_project + .map(str::trim) + .filter(|project| !project.is_empty()) + { + return Ok(project.to_string()); + } + + let config = StackerConfig::from_file_raw(config_path)?; + config + .project + .identity + .map(|project| project.trim().to_string()) + .filter(|project| !project.is_empty()) + .ok_or_else(|| { + CliError::ConfigValidation( + "Remote config metadata requires --project, or set project.identity in stacker.yml." + .to_string(), + ) + }) +} + +impl ConfigContractSuggestCommand { + pub fn new(file: Option, environment: String, service: Option) -> Self { + Self { + file, + environment, + service, + } + } +} + +impl CallableTrait for ConfigContractSuggestCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let output = suggest_contract_yaml( + Path::new(&path), + &ContractSuggestOptions { + environment: self.environment.clone(), + service: self.service.clone(), + }, + )?; + println!("{}", output.trim_end()); + Ok(()) + } +} + +fn print_config_check(result: &ConfigCheckResult) { + for warning in &result.warnings { + eprintln!("⚠ {warning}"); + } + + print_check_items("Missing required:", &result.missing_required); + print_check_items("Missing optional:", &result.missing_optional); + + if !result.has_required_failures() && result.missing_optional.is_empty() { + println!( + "Configuration contract satisfied for {}.", + result.environment + ); + } +} + +fn print_check_items(title: &str, items: &[ConfigCheckItem]) { + if items.is_empty() { + return; + } + + println!("{title}"); + for item in items { + let secret_marker = if item.secret { " [secret]" } else { "" }; + println!(" {}:{}{}", item.target, item.key, secret_marker); + } +} + +fn print_config_diff(diff: &ConfigDiff) { + for warning in &diff.warnings { + eprintln!("⚠ {warning}"); + } + + print_diff_items( + &format!("Missing in {}:", diff.to_environment), + &diff.missing_in_to, + ); + print_diff_items( + &format!("Only in {}:", diff.to_environment), + &diff.only_in_to, + ); + print_diff_items("Different values:", &diff.different); + + if !diff.has_differences() { + println!( + "No configuration differences found between {} and {}.", + diff.from_environment, diff.to_environment + ); + } +} + +fn print_diff_items(title: &str, items: &[DiffItem]) { + if items.is_empty() { + return; + } + + println!("{title}"); + for item in items { + let secret_marker = if item.secret { " [secret]" } else { "" }; + println!(" {}:{}{}", item.target, item.key, secret_marker); + } +} + /// `stacker config example` /// /// Prints a full commented `stacker.yml` reference example. @@ -1173,6 +1997,9 @@ app: assert!(issues .iter() .any(|issue| issue.contains("quoted path string"))); + assert!(issues + .iter() + .any(|issue| issue.contains("stacker config fix"))); } #[test] @@ -1201,6 +2028,115 @@ app: assert_eq!(resolved, "custom.yml"); } + #[test] + fn test_inventory_table_aligns_columns() { + let inventory = ConfigInventory { + environment: "local".to_string(), + warnings: Vec::new(), + targets: vec![crate::cli::config_inventory::TargetConfigInventory { + target_code: "coolify".to_string(), + keys: vec![ + crate::cli::config_inventory::ConfigKeyInventory { + key: "APP_ENV".to_string(), + source: "compose environment".to_string(), + present: true, + secret: false, + value_hash: None, + value_preview: Some("${APP_ENV:-production}".to_string()), + }, + crate::cli::config_inventory::ConfigKeyInventory { + key: "PHP_FPM_PM_MAX_SPARE_SERVERS".to_string(), + source: "compose environment".to_string(), + present: true, + secret: false, + value_hash: None, + value_preview: Some("${PHP_FPM_PM_MAX_SPARE_SERVERS:-10}".to_string()), + }, + crate::cli::config_inventory::ConfigKeyInventory { + key: "DB_PASSWORD".to_string(), + source: "compose env_file".to_string(), + present: true, + secret: true, + value_hash: None, + value_preview: None, + }, + ], + }], + }; + + let table = format_inventory_table(&inventory); + + assert!(table.starts_with("Target Key Source")); + assert!(table.contains("coolify APP_ENV compose environment")); + assert!(table.contains("coolify DB_PASSWORD compose env_file")); + assert!(table.contains("[REDACTED]")); + assert!(!table.contains('\t')); + } + + #[test] + fn test_registered_remote_target_codes_skip_local_only_services() { + let inventory = ConfigInventory { + environment: "production".to_string(), + warnings: Vec::new(), + targets: vec![ + crate::cli::config_inventory::TargetConfigInventory { + target_code: "coolify".to_string(), + keys: Vec::new(), + }, + crate::cli::config_inventory::TargetConfigInventory { + target_code: "postgres".to_string(), + keys: Vec::new(), + }, + crate::cli::config_inventory::TargetConfigInventory { + target_code: "redis".to_string(), + keys: Vec::new(), + }, + ], + }; + let registered_apps = vec![ProjectAppInfo { + id: 1, + project_id: 229, + code: "coolify".to_string(), + name: "Coolify".to_string(), + image: "coollabsio/coolify:latest".to_string(), + enabled: true, + deploy_order: None, + parent_app_code: None, + }]; + + let codes = registered_remote_target_codes(&inventory, ®istered_apps); + + assert_eq!(codes, vec!["coolify"]); + } + + #[test] + fn test_remote_metadata_warning_hides_api_details_without_debug() { + let error = CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: "Stacker server GET /project/229/apps/postgres/secrets failed (404): {\"message\":\"App not found\"}".to_string(), + }; + + let warning = remote_metadata_warning("postgres", &error, false); + + assert!(warning.contains("postgres")); + assert!(warning.contains("DEBUG=true")); + assert!(!warning.contains("GET /project")); + assert!(!warning.contains("App not found")); + } + + #[test] + fn test_remote_metadata_warning_shows_api_details_with_debug() { + let error = CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: "Stacker server GET /project/229/apps/postgres/secrets failed (404): {\"message\":\"App not found\"}".to_string(), + }; + + let warning = remote_metadata_warning("postgres", &error, true); + + assert!(warning.contains("GET /project/229/apps/postgres/secrets")); + assert!(warning.contains("App not found")); + } + #[test] fn test_parse_cloud_provider_valid() { assert_eq!( @@ -1342,4 +2278,46 @@ app: "unexpected message: {msg}" ); } + + #[test] + fn test_run_setup_ai_configures_ollama_without_removing_existing_config() { + let dir = tempfile::TempDir::new().unwrap(); + let config_path = write_config( + dir.path(), + r#" +name: ai-app +app: + type: static +deploy: + target: local +env: + KEEP_ME: "true" +"#, + ); + + let applied = run_setup_ai( + &config_path, + AiSetupOptions { + provider: Some("ollama"), + endpoint: Some("http://localhost:11434"), + model: Some("llama3.1"), + timeout: Some(120), + tasks: &["dockerfile,compose".to_string()], + }, + ) + .unwrap(); + + assert!(applied.iter().any(|item| item.contains("ai.provider"))); + let updated = StackerConfig::from_file(Path::new(&config_path)).unwrap(); + assert!(updated.ai.enabled); + assert_eq!(updated.ai.provider, AiProviderType::Ollama); + assert_eq!( + updated.ai.endpoint.as_deref(), + Some("http://localhost:11434") + ); + assert_eq!(updated.ai.model.as_deref(), Some("llama3.1")); + assert_eq!(updated.ai.timeout, 120); + assert_eq!(updated.ai.tasks, vec!["dockerfile", "compose"]); + assert_eq!(updated.env.get("KEEP_ME").map(String::as_str), Some("true")); + } } diff --git a/src/console/commands/cli/deploy.rs b/src/console/commands/cli/deploy.rs index 4b152421..f2a69ecf 100644 --- a/src/console/commands/cli/deploy.rs +++ b/src/console/commands/cli/deploy.rs @@ -1,4 +1,5 @@ use std::convert::TryFrom; +use std::io::IsTerminal; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -19,7 +20,8 @@ use crate::cli::error::CliError; use crate::cli::generator::compose::ComposeDefinition; use crate::cli::generator::dockerfile::DockerfileBuilder; use crate::cli::install_runner::{ - strategy_for, CommandExecutor, DeployContext, DeployResult, ShellExecutor, + resolve_docker_registry_credentials, strategy_for, CommandExecutor, DeployContext, + DeployResult, ShellExecutor, }; use crate::cli::progress; use crate::cli::stacker_client::{self, StackerClient}; @@ -223,36 +225,135 @@ fn extract_missing_image(reason: &str) -> Option { } fn ensure_env_file_if_needed(config: &StackerConfig, project_dir: &Path) -> Result<(), CliError> { - let env_file = match &config.env_file { - Some(path) => path, - None => return Ok(()), + let Some(env_file) = &config.env_file else { + return Ok(()); }; - let env_path = if env_file.is_absolute() { - env_file.clone() + let env_path = resolve_project_relative_path(project_dir, env_file); + ensure_env_file_from_example(&env_path, "stacker.yml env_file") +} + +fn resolve_project_relative_path(project_dir: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() } else { - project_dir.join(env_file) - }; + project_dir.join(path) + } +} +fn ensure_env_file_from_example(env_path: &Path, source: &str) -> Result<(), CliError> { if env_path.exists() { return Ok(()); } - if let Some(parent) = env_path.parent() { - std::fs::create_dir_all(parent)?; + let file_name = env_path.file_name().and_then(|name| name.to_str()); + let example_path = match file_name { + Some(".env") => env_path.with_file_name(".env.example"), + Some(name) => env_path.with_file_name(format!("{name}.example")), + None => env_path.with_extension("example"), + }; + + if example_path.exists() && file_name == Some(".env") { + if let Some(parent) = env_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&example_path, env_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(env_path, std::fs::Permissions::from_mode(0o600))?; + } + eprintln!( + " Created {} from {} for {} (mode 0600 where supported)", + env_path.display(), + example_path.display(), + source + ); + return Ok(()); + } + + Err(CliError::ConfigValidation(format!( + "Missing env file referenced by {source}: {}. Create it or, for the common .env case, add {} and rerun `stacker deploy`.", + env_path.display(), + example_path.display() + ))) +} + +fn collect_compose_env_file_paths(compose_path: &Path) -> Result, CliError> { + let raw = std::fs::read_to_string(compose_path)?; + let doc: serde_yaml::Value = serde_yaml::from_str(&raw) + .map_err(|e| CliError::ConfigValidation(format!("Failed to parse compose file: {e}")))?; + let compose_dir = compose_path.parent().unwrap_or_else(|| Path::new(".")); + let mut paths = Vec::new(); + collect_compose_env_file_paths_from_doc(&doc, compose_dir, &mut paths); + Ok(paths) +} + +fn collect_compose_env_file_paths_from_doc( + doc: &serde_yaml::Value, + compose_dir: &Path, + paths: &mut Vec, +) { + let Some(services) = doc + .as_mapping() + .and_then(|root| root.get(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping) + else { + return; + }; + + for service in services.values() { + let Some(service_map) = service.as_mapping() else { + continue; + }; + let Some(env_file) = service_map.get(serde_yaml::Value::String("env_file".to_string())) + else { + continue; + }; + append_env_file_value_paths(env_file, compose_dir, paths); } +} - let mut content = String::from("# Auto-created by Stacker because env_file was configured\n"); - if !config.env.is_empty() { - let mut keys: Vec<&String> = config.env.keys().collect(); - keys.sort(); - for key in keys { - content.push_str(&format!("{}={}\n", key, config.env[key])); +fn append_env_file_value_paths( + value: &serde_yaml::Value, + compose_dir: &Path, + paths: &mut Vec, +) { + match value { + serde_yaml::Value::String(path) => { + let path = PathBuf::from(path); + paths.push(if path.is_absolute() { + path + } else { + compose_dir.join(path) + }); } + serde_yaml::Value::Sequence(values) => { + for value in values { + append_env_file_value_paths(value, compose_dir, paths); + } + } + serde_yaml::Value::Mapping(map) => { + if let Some(path) = map + .get(serde_yaml::Value::String("path".to_string())) + .and_then(serde_yaml::Value::as_str) + { + let path = PathBuf::from(path); + paths.push(if path.is_absolute() { + path + } else { + compose_dir.join(path) + }); + } + } + _ => {} } +} - std::fs::write(&env_path, content)?; - eprintln!(" Created missing env file: {}", env_path.display()); +fn ensure_compose_env_files_if_needed(compose_path: &Path) -> Result<(), CliError> { + for env_path in collect_compose_env_file_paths(compose_path)? { + ensure_env_file_from_example(&env_path, "compose env_file")?; + } Ok(()) } @@ -578,10 +679,94 @@ impl DockerHubImageTarget { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct RequiredImagePlatform { + os: String, + architecture: String, +} + +impl RequiredImagePlatform { + fn linux_amd64() -> Self { + Self { + os: "linux".to_string(), + architecture: "amd64".to_string(), + } + } + + fn display_name(&self) -> String { + format!("{}/{}", self.os, self.architecture) + } + + fn matches(&self, image: &DockerHubTagImage) -> bool { + image + .os + .as_deref() + .map(|os| os.eq_ignore_ascii_case(&self.os)) + .unwrap_or(false) + && image + .architecture + .as_deref() + .map(|architecture| architecture.eq_ignore_ascii_case(&self.architecture)) + .unwrap_or(false) + } +} + +fn required_image_platform_for_deploy_target( + deploy_target: &DeployTarget, +) -> Option { + match deploy_target { + DeployTarget::Cloud | DeployTarget::Server => Some(RequiredImagePlatform::linux_amd64()), + DeployTarget::Local => None, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DockerHubImageCheckResult { + Available, + Missing, + MissingPlatform { + required: RequiredImagePlatform, + available: Vec, + }, +} + +#[derive(Debug, serde::Deserialize)] +struct DockerHubTagDetails { + #[serde(default)] + images: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct DockerHubTagImage { + architecture: Option, + os: Option, +} + +fn available_docker_hub_platforms(images: &[DockerHubTagImage]) -> Vec { + let mut platforms = std::collections::BTreeSet::new(); + + for image in images { + let Some(os) = image.os.as_deref() else { + continue; + }; + let Some(architecture) = image.architecture.as_deref() else { + continue; + }; + let os = os.trim(); + let architecture = architecture.trim(); + if os.is_empty() || architecture.is_empty() { + continue; + } + platforms.insert(format!("{}/{}", os, architecture)); + } + + platforms.into_iter().collect() +} fn validate_compose_images_for_deploy( compose_path: &Path, registry: Option<&RegistryConfig>, image_env: &std::collections::BTreeMap, + required_platform: Option<&RequiredImagePlatform>, ) -> Result<(), CliError> { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -590,21 +775,31 @@ fn validate_compose_images_for_deploy( CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) })?; - validate_compose_images_for_deploy_with_checker(compose_path, image_env, |target| { - rt.block_on(check_docker_hub_image_exists(target, registry)) - }) + validate_compose_images_for_deploy_with_checker( + compose_path, + image_env, + required_platform, + |target| { + rt.block_on(check_docker_hub_image_exists( + target, + registry, + required_platform, + )) + }, + ) } fn validate_compose_images_for_deploy_with_checker( compose_path: &Path, image_env: &std::collections::BTreeMap, + required_platform: Option<&RequiredImagePlatform>, mut checker: F, ) -> Result<(), CliError> where - F: FnMut(&DockerHubImageTarget) -> Result, + F: FnMut(&DockerHubImageTarget) -> Result, { let images = collect_compose_image_refs(compose_path)?; - let mut missing = Vec::new(); + let mut problems = Vec::new(); for image_ref in images { let resolved_image = @@ -622,13 +817,31 @@ where }; match checker(&target) { - Ok(true) => {} - Ok(false) => missing.push(format!( + Ok(DockerHubImageCheckResult::Available) => {} + Ok(DockerHubImageCheckResult::Missing) => problems.push(format!( "{} (service '{}' in {})", target.display_name(), image_ref.service_name, image_ref.source_path.display() )), + Ok(DockerHubImageCheckResult::MissingPlatform { + required, + available, + }) => { + let available_suffix = if available.is_empty() { + String::new() + } else { + format!("; available platforms: {}", available.join(", ")) + }; + problems.push(format!( + "{} (service '{}' in {}) does not publish required platform {}{}", + target.display_name(), + image_ref.service_name, + image_ref.source_path.display(), + required.display_name(), + available_suffix + )); + } Err(err) => eprintln!( " Warning: could not verify image {} before deploy: {}", target.display_name(), @@ -637,16 +850,88 @@ where } } - if missing.is_empty() { + if problems.is_empty() { Ok(()) + } else if let Some(required_platform) = required_platform { + Err(CliError::ConfigValidation(format!( + "Compose image preflight failed. These images are missing, inaccessible, or incompatible with required platform {}: {}", + required_platform.display_name(), + problems.join("; ") + ))) } else { Err(CliError::ConfigValidation(format!( "Compose image preflight failed. These images are missing or inaccessible: {}", - missing.join("; ") + problems.join("; ") ))) } } +fn print_registry_auth_guidance_if_needed( + compose_path: &Path, + config: &StackerConfig, + image_env: &std::collections::BTreeMap, +) -> Result<(), CliError> { + let registry_creds = resolve_docker_registry_credentials(config); + if registry_creds.contains_key("docker_username") + && registry_creds.contains_key("docker_password") + { + return Ok(()); + } + + let images = collect_registry_auth_candidate_images(compose_path, image_env)?; + if images.is_empty() { + return Ok(()); + } + + eprintln!(" Registry auth: no deploy registry credentials were resolved."); + eprintln!( + " If these images are private, set STACKER_DOCKER_USERNAME, STACKER_DOCKER_PASSWORD, and STACKER_DOCKER_REGISTRY, or configure deploy.registry." + ); + eprintln!(" Candidate image(s): {}", images.join(", ")); + Ok(()) +} + +fn collect_registry_auth_candidate_images( + compose_path: &Path, + image_env: &std::collections::BTreeMap, +) -> Result, CliError> { + let mut candidates = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + + for image_ref in collect_compose_image_refs(compose_path)? { + let resolved_image = + resolve_compose_image_reference(&image_ref.image, image_env).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to resolve image for service '{}' in {}: {}", + image_ref.service_name, + image_ref.source_path.display(), + err + )) + })?; + if image_may_require_registry_auth(&resolved_image) && seen.insert(resolved_image.clone()) { + candidates.push(resolved_image); + } + } + + Ok(candidates) +} + +fn image_may_require_registry_auth(image: &str) -> bool { + let image = image.trim(); + if image.is_empty() { + return false; + } + + let without_digest = image.split('@').next().unwrap_or(image); + let (without_tag, _) = split_image_tag(without_digest); + let parts: Vec<&str> = without_tag.split('/').collect(); + if parts.len() > 1 && is_registry_host(parts[0]) { + return !is_docker_hub_host(parts[0]) || parts.len() > 2; + } + + parts.len() == 2 && parts[0] != "library" +} + fn collect_compose_image_refs(compose_path: &Path) -> Result, CliError> { let mut visited = std::collections::BTreeSet::new(); let mut images = Vec::new(); @@ -859,7 +1144,8 @@ fn docker_hub_auth(registry: Option<&RegistryConfig>) -> Option<(&str, &str)> { async fn check_docker_hub_image_exists( target: &DockerHubImageTarget, registry: Option<&RegistryConfig>, -) -> Result { + required_platform: Option<&RequiredImagePlatform>, +) -> Result { let client = reqwest::Client::new(); let auth_token = if let Some((username, password)) = docker_hub_auth(registry) { Some(login_to_docker_hub(&client, username, password).await?) @@ -884,15 +1170,31 @@ async fn check_docker_hub_image_exists( } let response = request.send().await.map_err(|e| e.to_string())?; - if response.status().is_success() { - Ok(true) - } else if response.status() == reqwest::StatusCode::NOT_FOUND - || response.status() == reqwest::StatusCode::UNAUTHORIZED - || response.status() == reqwest::StatusCode::FORBIDDEN + let status = response.status(); + if status.is_success() { + if let Some(required_platform) = required_platform { + let body: DockerHubTagDetails = response.json().await.map_err(|e| e.to_string())?; + if !body.images.is_empty() + && !body + .images + .iter() + .any(|image| required_platform.matches(image)) + { + return Ok(DockerHubImageCheckResult::MissingPlatform { + required: required_platform.clone(), + available: available_docker_hub_platforms(&body.images), + }); + } + } + + Ok(DockerHubImageCheckResult::Available) + } else if status == reqwest::StatusCode::NOT_FOUND + || status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN { - Ok(false) + Ok(DockerHubImageCheckResult::Missing) } else { - Err(format!("Docker Hub API returned {}", response.status())) + Err(format!("Docker Hub API returned {}", status)) } } @@ -1339,6 +1641,94 @@ fn extract_host_port_from_string(spec: &str) -> Option { .filter(|part| !part.is_empty()) } +/// Detect host-port collisions between stacker.yml `services:` and a user-supplied compose file. +/// +/// `config_with_compose_secret_target_services` merges compose services into the config by name, +/// so two services with different names but the same host port will both survive the merge and +/// cause Docker to fail at runtime. This check catches that case locally before any remote +/// operation is attempted. +fn validate_cross_source_port_collisions( + config: &crate::cli::config_parser::StackerConfig, + compose_path: &Path, +) -> Result<(), CliError> { + // Collect host-port → service-name mapping from stacker.yml services (and app). + let mut stacker_port_owners: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for svc in &config.services { + for spec in &svc.ports { + if let Some(port) = extract_host_port_from_string(spec) { + stacker_port_owners + .entry(port) + .or_insert_with(|| svc.name.clone()); + } + } + } + for spec in &config.app.ports { + if let Some(port) = extract_host_port_from_string(spec) { + stacker_port_owners + .entry(port) + .or_insert_with(|| "app".to_string()); + } + } + + if stacker_port_owners.is_empty() { + return Ok(()); + } + + // Parse the compose file and look for the same host ports. + let raw = std::fs::read_to_string(compose_path)?; + let doc: serde_yaml::Value = serde_yaml::from_str(&raw) + .map_err(|e| CliError::ConfigValidation(format!("Failed to parse compose file: {e}")))?; + + let root = match doc { + serde_yaml::Value::Mapping(m) => m, + _ => return Ok(()), + }; + + let services_key = serde_yaml::Value::String("services".to_string()); + let services = match root.get(&services_key) { + Some(serde_yaml::Value::Mapping(m)) => m, + _ => return Ok(()), + }; + + let mut collisions: Vec = Vec::new(); + for (svc_key, svc_val) in services { + let compose_svc = svc_key.as_str().unwrap_or(""); + let svc_map = match svc_val { + serde_yaml::Value::Mapping(m) => m, + _ => continue, + }; + let ports_key = serde_yaml::Value::String("ports".to_string()); + let Some(serde_yaml::Value::Sequence(ports)) = svc_map.get(&ports_key) else { + continue; + }; + for port in ports { + if let Some(host_port) = extract_published_host_port(port) { + if let Some(stacker_svc) = stacker_port_owners.get(&host_port) { + collisions.push(format!( + "port {} is used by '{}' in stacker.yml and '{}' in {}", + host_port, + stacker_svc, + compose_svc, + compose_path.display(), + )); + } + } + } + } + + if collisions.is_empty() { + Ok(()) + } else { + Err(CliError::ConfigValidation(format!( + "Host-port collision between stacker.yml services and compose file — \ + both sources will be deployed together but share the same host port(s): {}. \ + Remove the duplicate service from one of the two files.", + collisions.join("; ") + ))) + } +} + fn compose_app_build_source(compose_path: &Path) -> Option { let raw = std::fs::read_to_string(compose_path).ok()?; let doc: serde_yaml::Value = serde_yaml::from_str(&raw).ok()?; @@ -1549,11 +1939,10 @@ fn cloud_provider_from_code(code: &str) -> Option { /// - `Ok(Some(cloud_info))` when the user picks an existing credential. /// - `Ok(None)` when the user picks "Connect a new cloud provider". /// - `Err(...)` on I/O or network errors. -fn prompt_select_cloud(access_token: &str) -> Result, CliError> { - let base_url = crate::cli::install_runner::normalize_stacker_server_url( - stacker_client::DEFAULT_STACKER_URL, - ); - +fn prompt_select_cloud( + base_url: &str, + access_token: &str, +) -> Result, CliError> { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -1603,6 +1992,11 @@ fn prompt_select_cloud(access_token: &str) -> Result or --key-id , or configure deploy.cloud with `stacker config setup cloud`."); + return Err(CliError::CloudProviderMissing); + } eprintln!(" Select a saved cloud credential to use for this deployment:"); eprintln!(); @@ -1623,6 +2017,95 @@ fn prompt_select_cloud(access_token: &str) -> Result String { + if let Some(server_url) = creds.server_url.as_deref() { + return crate::cli::install_runner::normalize_stacker_server_url(server_url); + } + if let Ok(server_url) = std::env::var("STACKER_URL") { + if !server_url.trim().is_empty() { + return crate::cli::install_runner::normalize_stacker_server_url(&server_url); + } + } + stacker_client::DEFAULT_STACKER_URL.to_string() +} + +fn cloud_config_from_info(cloud_info: &stacker_client::CloudInfo) -> Result { + merge_cloud_config_from_info(None, cloud_info) +} + +fn merge_cloud_config_from_info( + existing: Option<&CloudConfig>, + cloud_info: &stacker_client::CloudInfo, +) -> Result { + let provider = cloud_provider_from_code(&cloud_info.provider).ok_or_else(|| { + CliError::ConfigValidation(format!( + "Unrecognised cloud provider '{}' for credential '{}'. Supported providers: hetzner (htz), digitalocean (do), aws, linode (lo), vultr (vu).", + cloud_info.provider, cloud_info.name + )) + })?; + + Ok(CloudConfig { + provider, + orchestrator: existing + .map(|cloud| cloud.orchestrator) + .unwrap_or(CloudOrchestrator::Remote), + region: existing.and_then(|cloud| cloud.region.clone()), + size: existing.and_then(|cloud| cloud.size.clone()), + install_image: existing.and_then(|cloud| cloud.install_image.clone()), + remote_payload_file: existing.and_then(|cloud| cloud.remote_payload_file.clone()), + ssh_key: existing.and_then(|cloud| cloud.ssh_key.clone()), + key: Some(cloud_info.name.clone()), + server: existing.and_then(|cloud| cloud.server.clone()), + }) +} + +fn apply_cloud_cli_override( + config: &mut StackerConfig, + remote_overrides: &RemoteDeployOverrides, + creds: &StoredCredentials, +) -> Result<(), CliError> { + if remote_overrides.key_id.is_none() && remote_overrides.key_name.is_none() { + return Ok(()); + } + + let base_url = active_stacker_base_url(creds); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; + let client = StackerClient::new(&base_url, &creds.access_token); + + let cloud_info = if let Some(key_id) = remote_overrides.key_id { + rt.block_on(client.get_cloud(key_id))?.ok_or_else(|| { + CliError::ConfigValidation(format!("No saved cloud credential found with id {key_id}")) + })? + } else { + let key_name = remote_overrides + .key_name + .as_deref() + .expect("key_name checked above"); + rt.block_on(client.find_cloud_by_name(key_name))? + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "No saved cloud credential found with name '{key_name}'" + )) + })? + }; + + eprintln!( + " Using cloud credential override: {} (id={}, provider={})", + cloud_info.name, cloud_info.id, cloud_info.provider + ); + config.deploy.target = DeployTarget::Cloud; + config.deploy.cloud = Some(merge_cloud_config_from_info( + config.deploy.cloud.as_ref(), + &cloud_info, + )?); + Ok(()) +} + /// `stacker deploy [--target local|cloud|server] [--file stacker.yml] [--dry-run] [--force-rebuild]` /// `stacker deploy --project=myapp --target cloud --key devops --server bastion` /// @@ -1636,6 +2119,8 @@ fn prompt_select_cloud(access_token: &str) -> Result, pub target: Option, pub environment: Option, pub file: Option, @@ -1658,6 +2143,10 @@ pub struct DeployCommand { pub force_new: bool, /// Container runtime: "runc" (default) or "kata" (--runtime). pub runtime: String, + /// Generate a read-only deployment plan instead of applying changes. + pub plan: bool, + /// Revalidate and apply a previously generated plan fingerprint. + pub apply_plan: Option, } impl DeployCommand { @@ -1668,6 +2157,7 @@ impl DeployCommand { force_rebuild: bool, ) -> Self { Self { + service: None, target, environment: None, file, @@ -1681,9 +2171,16 @@ impl DeployCommand { lock: false, force_new: false, runtime: "runc".to_string(), + plan: false, + apply_plan: None, } } + pub fn with_service(mut self, service: Option) -> Self { + self.service = service; + self + } + pub fn with_environment(mut self, environment: Option) -> Self { self.environment = environment; self @@ -1747,6 +2244,159 @@ impl DeployCommand { } self } + + pub fn with_plan(mut self, plan: bool) -> Self { + self.plan = plan; + self + } + + pub fn with_apply_plan(mut self, apply_plan: Option) -> Self { + self.apply_plan = apply_plan; + self + } + + /// Surgical single-service deploy: read local compose, inject the named service into the + /// remote deployment's compose, and start only that container. + fn deploy_single_service(&self, service: &str) -> Result<(), Box> { + use crate::cli::stacker_client::AgentEnqueueRequest; + use crate::console::commands::cli::agent::{ + resolve_deployment_hash, resolve_registry_auth_for_agent_deploy, run_agent_command, + }; + + let project_dir = std::env::current_dir()?; + + // Load stacker config once — used for compose path, proxy config, and app registration. + let stacker_config_path = project_dir.join(DEFAULT_CONFIG_FILE); + let stacker_config: Option = if stacker_config_path.exists() { + Some(StackerConfig::from_file(&stacker_config_path)?) + } else { + None + }; + + // Find compose file: prefer deploy.compose_file from stacker.yml, else default. + let compose_path = stacker_config + .as_ref() + .and_then(|c| c.deploy.compose_file.as_deref().map(|f| project_dir.join(f))) + .unwrap_or_else(|| project_dir.join("docker-compose.yml")); + + if !compose_path.exists() { + return Err(Box::new(CliError::ConfigValidation(format!( + "Compose file not found: {}", + compose_path.display() + )))); + } + + let compose_content = std::fs::read_to_string(&compose_path)?; + + // Verify the named service exists in the local compose. + let mut compose_doc: serde_yaml::Value = serde_yaml::from_str(&compose_content) + .map_err(|e| CliError::ConfigValidation(format!("Invalid compose file: {}", e)))?; + let services_exist = compose_doc + .get("services") + .and_then(|s| s.as_mapping()) + .map(|m| m.contains_key(serde_yaml::Value::String(service.to_string()))) + .unwrap_or(false); + if !services_exist { + return Err(Box::new(CliError::ConfigValidation(format!( + "Service '{}' not found in {}", + service, + compose_path.display() + )))); + } + + // Extract the image for the named service (needed for app registration). + let image = compose_doc + .get("services") + .and_then(|s| s.get(service)) + .and_then(|svc| svc.get("image")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + // Auto-inject default_network when the service is an NginxProxyManager upstream. + let compose_content = + if let Some(ref cfg) = stacker_config { + if crate::cli::compose_service_sync::inject_npm_proxy_network( + &mut compose_doc, + service, + &cfg.proxy, + ) { + serde_yaml::to_string(&compose_doc)? + } else { + compose_content + } + } else { + compose_content + }; + + let ctx = crate::cli::runtime::CliRuntime::new("deploy")?; + let hash = resolve_deployment_hash(&None, &ctx)?; + + let params = crate::forms::status_panel::DeployAppCommandRequest { + app_code: service.to_string(), + compose_content: Some(compose_content), + image: None, + env_vars: None, + pull: true, + force_recreate: false, + force_config_overwrite: false, + runtime: self.runtime.clone(), + registry_auth: resolve_registry_auth_for_agent_deploy(&project_dir), + config_files: None, + }; + + let request = AgentEnqueueRequest::new(&hash, "deploy_app") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))? + .with_timeout(300); + + let info = run_agent_command(&ctx, &request, &format!("Deploying {}", service), 300)?; + if let Some(output) = info.result.as_ref().and_then(|r| r.as_str()) { + if !output.is_empty() { + println!("{}", output); + } + } + + // Register the service as a tracked app in the project. + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + if config_path.exists() { + if let Ok(cfg) = StackerConfig::from_file(&config_path) + .and_then(|c| c.with_resolved_deploy_target(None)) + { + if let Some(project_name) = cfg.project.identity.as_deref() { + let registered = ctx.block_on(async { + let project = ctx.client.find_project_by_name(project_name).await?; + if let Some(proj) = project { + ctx.client + .upsert_project_app( + proj.id, + &crate::cli::stacker_client::ProjectAppRegistrationRequest { + code: service.to_string(), + name: Some(service.to_string()), + image: image.clone(), + env: None, + ports: None, + volumes: None, + depends_on: None, + enabled: Some(true), + deploy_order: None, + deployment_hash: Some(hash.clone()), + }, + ) + .await?; + } + Ok::<_, CliError>(()) + }); + if let Err(e) = registered { + eprintln!("Warning: deployed successfully but app registration failed: {}", e); + } + } + } + } + + println!("Service '{}' deployed.", service); + Ok(()) + } } /// Parse a deploy target string into `DeployTarget`. @@ -1983,6 +2633,12 @@ fn run_deploy_with_credentials_manager( None }; + if deploy_target == DeployTarget::Cloud { + if let Some(creds) = cloud_creds.as_ref() { + apply_cloud_cli_override(&mut config, remote_overrides, creds)?; + } + } + if deploy_target == DeployTarget::Server { if let Some(ref server_cfg) = config.deploy.server { eprintln!( @@ -2034,21 +2690,14 @@ fn run_deploy_with_credentials_manager( // 3b. If cloud target but no cloud section in stacker.yml, prompt to select a saved credential. if deploy_target == DeployTarget::Cloud && config.deploy.cloud.is_none() { - let access_token = &cloud_creds + let creds = cloud_creds .as_ref() - .expect("cloud_creds should be set when deploy_target is Cloud (verified in step 3)") - .access_token; + .expect("cloud_creds should be set when deploy_target is Cloud (verified in step 3)"); + let access_token = &creds.access_token; + let base_url = active_stacker_base_url(creds); - match prompt_select_cloud(access_token)? { + match prompt_select_cloud(&base_url, access_token)? { Some(cloud_info) => { - // Map the provider code to a CloudProvider enum value. - let provider = cloud_provider_from_code(&cloud_info.provider) - .ok_or_else(|| CliError::ConfigValidation(format!( - "Unrecognised cloud provider '{}' for credential '{}'. \ - Supported providers: hetzner (htz), digitalocean (do), aws, linode (lo), vultr (vu).", - cloud_info.provider, cloud_info.name - )))?; - eprintln!( " Selected cloud credential: {} (id={}, provider={})", cloud_info.name, cloud_info.id, cloud_info.provider @@ -2056,17 +2705,7 @@ fn run_deploy_with_credentials_manager( // Apply the selected cloud to the in-memory config. config.deploy.target = DeployTarget::Cloud; - config.deploy.cloud = Some(CloudConfig { - provider, - orchestrator: CloudOrchestrator::Remote, - region: None, - size: None, - install_image: None, - remote_payload_file: None, - ssh_key: None, - key: Some(cloud_info.name.clone()), - server: None, - }); + config.deploy.cloud = Some(cloud_config_from_info(&cloud_info)?); // Persist the selection to stacker.yml so subsequent deploys // do not prompt again. @@ -2131,7 +2770,7 @@ fn run_deploy_with_credentials_manager( if needs_dockerfile { if force_rebuild || !dockerfile_path.exists() { - let builder = DockerfileBuilder::from(config.app.app_type); + let builder = DockerfileBuilder::for_project(&project_dir, config.app.app_type); builder.write_to(&dockerfile_path, force_rebuild)?; } else { eprintln!( @@ -2142,49 +2781,59 @@ fn run_deploy_with_credentials_manager( } // 5b. docker-compose.yml - let compose_path = if let Some(ref existing) = config.deploy.compose_file { - let configured_path = project_dir.join(existing); - if configured_path.exists() { - configured_path + let (compose_path, compose_is_user_supplied) = + if let Some(ref existing) = config.deploy.compose_file { + let configured_path = project_dir.join(existing); + if configured_path.exists() { + (configured_path, true) + } else { + let generated_fallback = output_dir.join("docker-compose.yml"); + if generated_fallback.exists() { + eprintln!( + " Configured compose file not found: {}. Falling back to {}", + configured_path.display(), + generated_fallback.display() + ); + (generated_fallback, false) + } else { + return Err(CliError::ConfigValidation(format!( + "Compose file not found: {}", + configured_path.display() + ))); + } + } } else { - let generated_fallback = output_dir.join("docker-compose.yml"); - if generated_fallback.exists() { + let compose_out = output_dir.join("docker-compose.yml"); + if force_rebuild || !compose_out.exists() { + let compose = ComposeDefinition::try_from(&config)?; + compose.write_to(&compose_out, force_rebuild)?; + } else { eprintln!( - " Configured compose file not found: {}. Falling back to {}", - configured_path.display(), - generated_fallback.display() + " Using existing {}/docker-compose.yml (use --force-rebuild to regenerate)", + OUTPUT_DIR ); - generated_fallback - } else { - return Err(CliError::ConfigValidation(format!( - "Compose file not found: {}", - configured_path.display() - ))); } - } - } else { - let compose_out = output_dir.join("docker-compose.yml"); - if force_rebuild || !compose_out.exists() { - let compose = ComposeDefinition::try_from(&config)?; - compose.write_to(&compose_out, force_rebuild)?; - } else { - eprintln!( - " Using existing {}/docker-compose.yml (use --force-rebuild to regenerate)", - OUTPUT_DIR - ); - } - compose_out - }; + (compose_out, false) + }; normalize_generated_compose_paths(&compose_path)?; validate_compose_for_deploy(&compose_path)?; + if compose_is_user_supplied { + validate_cross_source_port_collisions(&config, &compose_path)?; + } + ensure_compose_env_files_if_needed(&compose_path)?; let image_env = build_image_env_lookup(project_dir, &config)?; merge_compose_public_ports_into_app_config(&mut config, &compose_path, &image_env)?; + if matches!(deploy_target, DeployTarget::Cloud | DeployTarget::Server) { + print_registry_auth_guidance_if_needed(&compose_path, &config, &image_env)?; + } + let required_image_platform = required_image_platform_for_deploy_target(&deploy_target); if !dry_run { validate_compose_images_for_deploy( &compose_path, config.deploy.registry.as_ref(), &image_env, + required_image_platform.as_ref(), )?; } @@ -2226,6 +2875,12 @@ fn run_deploy_with_credentials_manager( config.env_file.as_deref(), )?; eprintln!(" Config bundle: {}", bundle.archive_path.display()); + for file in &bundle.manifest.files { + eprintln!( + " Config file: {} -> {}", + file.source_path, file.destination_path + ); + } Some(bundle) } else { None @@ -2258,6 +2913,8 @@ fn run_deploy_with_credentials_manager( server_name_override: remote_overrides.server_name.clone().or(lock_server_name), runtime: runtime.to_string(), config_bundle, + managed_proxy_feature_enabled: true, + force_new, }; let result = strategy.deploy(&config, &context, executor)?; @@ -2267,6 +2924,51 @@ fn run_deploy_with_credentials_manager( impl CallableTrait for DeployCommand { fn call(&self) -> Result<(), Box> { + if let Some(service) = &self.service { + return self.deploy_single_service(service); + } + + if self.plan { + return crate::console::commands::cli::deployment::run_remote_deployment_plan( + None, + crate::services::DeployPlanOperation::Deploy, + None, + None, + None, + ); + } + + if let Some(fingerprint) = self.apply_plan.as_deref() { + let project_dir = std::env::current_dir()?; + let config_path = project_dir.join("stacker.yml"); + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + let ctx = crate::cli::runtime::CliRuntime::new("deploy apply-plan")?; + let validated_plan = ctx.block_on(async { + let base_url = + crate::console::commands::cli::status::resolve_stacker_base_url(&ctx.creds); + crate::console::commands::cli::deployment::fetch_remote_deployment_plan( + &config, + &base_url, + &ctx.client, + None, + crate::services::DeployPlanOperation::Deploy, + None, + None, + Some(fingerprint), + ) + .await + })?; + if !validated_plan.has_changes { + println!( + "Plan already satisfied for {}. Nothing to apply.", + validated_plan.deployment_hash + ); + return Ok(()); + } + } + let project_dir = std::env::current_dir()?; let executor = ShellExecutor; @@ -2325,6 +3027,8 @@ impl CallableTrait for DeployCommand { && (result.deployment_id.is_some() || result.project_id.is_some()) }); + let mut watch_outcome = DeploymentWatchOutcome::Unknown; + match result.target { DeployTarget::Local => { // Always do a quick health check for local deploy unless --no-watch @@ -2337,14 +3041,16 @@ impl CallableTrait for DeployCommand { } } DeployTarget::Cloud | DeployTarget::Server if should_watch => { - watch_cloud_deployment(&result)?; + watch_outcome = watch_cloud_deployment(&result)?; } _ => {} } + let should_fetch_remote_details = !matches!(watch_outcome, DeploymentWatchOutcome::Failed); + // ── Deployment lock: persist deployment context ── - self.save_deployment_lock(&project_dir, &result)?; - if should_install_cloud_backup_key(&result, self.dry_run) { + self.save_deployment_lock(&project_dir, &result, should_fetch_remote_details)?; + if should_fetch_remote_details && should_install_cloud_backup_key(&result, self.dry_run) { self.install_cloud_backup_key(&result); } @@ -2464,6 +3170,7 @@ impl DeployCommand { &self, project_dir: &Path, result: &DeployResult, + fetch_remote_details: bool, ) -> Result<(), Box> { // Build the initial lock from the deploy result let mut lock = match result.target { @@ -2499,24 +3206,26 @@ impl DeployCommand { } } - if let Some(project_id) = result.project_id { - match fetch_server_for_project( - project_id as i32, - DeployTarget::Server, - result.server_name.as_deref(), - ) { - Ok(Some(info)) => { - l = l.with_server_info( - info.srv_ip.clone(), - info.ssh_user.clone(), - info.ssh_port.map(|p| p as u16), - info.name.clone(), - info.cloud_id, - ); - } - Ok(None) => {} - Err(e) => { - eprintln!(" ⚠ Could not fetch server details: {}", e); + if fetch_remote_details { + if let Some(project_id) = result.project_id { + match fetch_server_for_project( + project_id as i32, + DeployTarget::Server, + result.server_name.as_deref(), + ) { + Ok(Some(info)) => { + l = l.with_server_info( + info.srv_ip.clone(), + info.ssh_user.clone(), + info.ssh_port.map(|p| p as u16), + info.name.clone(), + info.cloud_id, + ); + } + Ok(None) => {} + Err(e) => { + eprintln!(" ⚠ Could not fetch server details: {}", e); + } } } } @@ -2551,37 +3260,39 @@ impl DeployCommand { } // Try to fetch provisioned server details from the Stacker API - if let Some(project_id) = result.project_id { - match fetch_server_for_project( - project_id as i32, - DeployTarget::Cloud, - result.server_name.as_deref(), - ) { - Ok(Some(info)) => { - l = l.with_server_info( - info.srv_ip.clone(), - info.ssh_user.clone(), - info.ssh_port.map(|p| p as u16), - info.name.clone(), - info.cloud_id, - ); - if let Some(ref ip) = info.srv_ip { + if fetch_remote_details { + if let Some(project_id) = result.project_id { + match fetch_server_for_project( + project_id as i32, + DeployTarget::Cloud, + result.server_name.as_deref(), + ) { + Ok(Some(info)) => { + l = l.with_server_info( + info.srv_ip.clone(), + info.ssh_user.clone(), + info.ssh_port.map(|p| p as u16), + info.name.clone(), + info.cloud_id, + ); + if let Some(ref ip) = info.srv_ip { + eprintln!( + " Server details: {} ({}@{}:{})", + info.name.as_deref().unwrap_or("unnamed"), + info.ssh_user.as_deref().unwrap_or("root"), + ip, + info.ssh_port.unwrap_or(22), + ); + } + } + Ok(None) => { eprintln!( - " Server details: {} ({}@{}:{})", - info.name.as_deref().unwrap_or("unnamed"), - info.ssh_user.as_deref().unwrap_or("root"), - ip, - info.ssh_port.unwrap_or(22), + " ℹ Server details not yet available (may still be provisioning)." ); } - } - Ok(None) => { - eprintln!( - " ℹ Server details not yet available (may still be provisioning)." - ); - } - Err(e) => { - eprintln!(" ⚠ Could not fetch server details: {}", e); + Err(e) => { + eprintln!(" ⚠ Could not fetch server details: {}", e); + } } } } @@ -2966,8 +3677,17 @@ fn is_terminal(status: &str) -> bool { TERMINAL_STATUSES.iter().any(|s| *s == status) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeploymentWatchOutcome { + Completed, + Failed, + Unknown, +} + /// Watch remote deployment status until it reaches a terminal state. -fn watch_cloud_deployment(result: &DeployResult) -> Result<(), Box> { +fn watch_cloud_deployment( + result: &DeployResult, +) -> Result> { use std::time::Duration; let (base_url, creds) = match resolve_saved_stacker_base_url("deployment status") { @@ -2975,7 +3695,7 @@ fn watch_cloud_deployment(result: &DeployResult) -> Result<(), Box { eprintln!(" Cannot watch deployment status: {}", e); eprintln!(" Run `stacker status --watch` later to check progress."); - return Ok(()); + return Ok(DeploymentWatchOutcome::Unknown); } }; @@ -2983,7 +3703,7 @@ fn watch_cloud_deployment(result: &DeployResult) -> Result<(), Box id as i32, None => { eprintln!(" No project ID — run `stacker status --watch` to check progress."); - return Ok(()); + return Ok(DeploymentWatchOutcome::Unknown); } }; @@ -3034,14 +3754,15 @@ fn watch_cloud_deployment(result: &DeployResult) -> Result<(), Box { @@ -3058,14 +3779,14 @@ fn watch_cloud_deployment(result: &DeployResult) -> Result<(), Box timeout { progress::finish_error(&spin, "Watch timeout (10m) — deployment still in progress"); eprintln!(" Run `stacker status --watch` to continue watching."); - return Ok(()); + return Ok(DeploymentWatchOutcome::Unknown); } tokio::time::sleep(poll_interval).await; @@ -3372,6 +4093,78 @@ services: assert!(!dir.path().join(".stacker/docker-compose.yml").exists()); } + #[test] + fn test_deploy_creates_missing_dotenv_from_example_for_compose_env_file() { + let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n compose_file: docker-compose.yml\n"; + let dir = setup_local_project(&[ + ("index.html", "

hello

"), + ( + "docker-compose.yml", + "services:\n web:\n image: nginx\n env_file: .env\n", + ), + (".env.example", "APP_ENV=production\n"), + ("stacker.yml", config), + ]); + let executor = MockExecutor::success(); + + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + + assert!(result.is_ok()); + let env_path = dir.path().join(".env"); + assert_eq!( + std::fs::read_to_string(&env_path).unwrap(), + "APP_ENV=production\n" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&env_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[test] + fn test_deploy_reports_missing_env_file_without_raw_bundle_error() { + let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n compose_file: docker-compose.yml\n"; + let dir = setup_local_project(&[ + ( + "docker-compose.yml", + "services:\n web:\n image: nginx\n env_file: .env\n", + ), + ("stacker.yml", config), + ]); + let executor = MockExecutor::success(); + + let err = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ) + .unwrap_err(); + + let msg = err.to_string(); + assert!(msg.contains("Missing env file referenced by compose env_file")); + assert!(msg.contains(".env.example")); + } + #[test] fn test_deploy_falls_back_when_configured_compose_missing() { let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n compose_file: stacker/docker-compose.yml\n"; @@ -3908,6 +4701,98 @@ include: ); } + #[test] + fn test_registry_auth_candidates_ignore_official_public_images() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("docker-compose.yml"); + let image_env = std::collections::BTreeMap::new(); + std::fs::write( + &compose_path, + "services:\n db:\n image: postgres:17\n api:\n image: optimum/syncopia-api:latest\n sidecar:\n image: ghcr.io/acme/sidecar:latest\n", + ) + .unwrap(); + + let candidates = collect_registry_auth_candidate_images(&compose_path, &image_env).unwrap(); + + assert_eq!( + candidates, + vec![ + "optimum/syncopia-api:latest".to_string(), + "ghcr.io/acme/sidecar:latest".to_string() + ] + ); + } + + #[test] + fn test_active_stacker_base_url_prefers_logged_in_server_url() { + let creds = StoredCredentials { + access_token: "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://dev.try.direct/server/stacker/api/v1".to_string()), + org: None, + domain: None, + }; + + assert_eq!( + active_stacker_base_url(&creds), + "https://dev.try.direct/server/stacker" + ); + } + + #[test] + fn test_cloud_config_from_cli_override_info_sets_in_memory_key() { + let cloud = stacker_client::CloudInfo { + id: 5, + user_id: "u1".to_string(), + name: "htz-5".to_string(), + provider: "htz".to_string(), + cloud_token: None, + cloud_key: None, + cloud_secret: None, + save_token: None, + }; + + let config = cloud_config_from_info(&cloud).unwrap(); + + assert_eq!(config.provider, CloudProvider::Hetzner); + assert_eq!(config.key.as_deref(), Some("htz-5")); + assert_eq!(config.orchestrator, CloudOrchestrator::Remote); + } + + #[test] + fn test_cloud_config_from_cli_override_preserves_existing_region_and_size() { + let existing = CloudConfig { + provider: CloudProvider::Hetzner, + orchestrator: CloudOrchestrator::Remote, + region: Some("nbg1".to_string()), + size: Some("cpx21".to_string()), + install_image: None, + remote_payload_file: None, + ssh_key: None, + key: None, + server: None, + }; + let cloud = stacker_client::CloudInfo { + id: 5, + user_id: "u1".to_string(), + name: "htz-5".to_string(), + provider: "htz".to_string(), + cloud_token: None, + cloud_key: None, + cloud_secret: None, + save_token: None, + }; + + let config = merge_cloud_config_from_info(Some(&existing), &cloud).unwrap(); + + assert_eq!(config.key.as_deref(), Some("htz-5")); + assert_eq!(config.region.as_deref(), Some("nbg1")); + assert_eq!(config.size.as_deref(), Some("cpx21")); + } + #[test] fn test_validate_compose_images_for_deploy_reports_missing_docker_hub_image_before_deploy() { let dir = TempDir::new().unwrap(); @@ -3919,11 +4804,19 @@ include: ) .unwrap(); - let err = - validate_compose_images_for_deploy_with_checker(&compose_path, &image_env, |target| { - Ok(target.repository != "syncopia-device-api") - }) - .unwrap_err(); + let err = validate_compose_images_for_deploy_with_checker( + &compose_path, + &image_env, + None, + |target| { + Ok(if target.repository == "syncopia-device-api" { + DockerHubImageCheckResult::Missing + } else { + DockerHubImageCheckResult::Available + }) + }, + ) + .unwrap_err(); let message = err.to_string(); assert!(message.contains("Compose image preflight failed")); @@ -3932,6 +4825,64 @@ include: assert!(!message.contains("ghcr.io/example/worker:latest")); } + #[test] + fn test_required_image_platform_for_deploy_target_only_enforces_remote_linux_amd64() { + assert_eq!( + required_image_platform_for_deploy_target(&DeployTarget::Local), + None + ); + assert_eq!( + required_image_platform_for_deploy_target(&DeployTarget::Cloud), + Some(RequiredImagePlatform::linux_amd64()) + ); + assert_eq!( + required_image_platform_for_deploy_target(&DeployTarget::Server), + Some(RequiredImagePlatform::linux_amd64()) + ); + } + + #[test] + fn test_validate_compose_images_for_deploy_reports_missing_required_platform_before_remote_deploy( + ) { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("docker-compose.yml"); + let image_env = std::collections::BTreeMap::new(); + let required_platform = RequiredImagePlatform::linux_amd64(); + std::fs::write( + &compose_path, + "services: + api: + image: optimum/syncopia-device-api:latest + proxy: + image: jc21/nginx-proxy-manager:latest +", + ) + .unwrap(); + + let err = validate_compose_images_for_deploy_with_checker( + &compose_path, + &image_env, + Some(&required_platform), + |target| { + Ok(if target.repository == "syncopia-device-api" { + DockerHubImageCheckResult::MissingPlatform { + required: required_platform.clone(), + available: vec!["linux/arm64".to_string()], + } + } else { + DockerHubImageCheckResult::Available + }) + }, + ) + .unwrap_err(); + + let message = err.to_string(); + assert!(message.contains("required platform linux/amd64")); + assert!(message.contains("available platforms: linux/arm64")); + assert!(message.contains("docker.io/optimum/syncopia-device-api:latest")); + assert!(message.contains("service 'api'")); + } + #[test] fn test_resolve_compose_image_reference_supports_plain_default_and_required_forms() { let mut image_env = std::collections::BTreeMap::new(); @@ -3988,11 +4939,19 @@ include: ) .unwrap(); - let err = - validate_compose_images_for_deploy_with_checker(&compose_path, &image_env, |target| { - Ok(target.repository != "syncopia-website") - }) - .unwrap_err(); + let err = validate_compose_images_for_deploy_with_checker( + &compose_path, + &image_env, + None, + |target| { + Ok(if target.repository == "syncopia-website" { + DockerHubImageCheckResult::Missing + } else { + DockerHubImageCheckResult::Available + }) + }, + ) + .unwrap_err(); let message = err.to_string(); assert!(message.contains("docker.io/optimum/syncopia-website:latest")); @@ -4266,17 +5225,25 @@ monitoring: #[test] fn test_ensure_env_file_is_created_when_missing() { let dir = TempDir::new().unwrap(); - let config = StackerConfig::from_str( - "name: env-app\napp:\n type: static\nenv_file: .env\nenv:\n APP_ENV: production\n", - ) - .unwrap(); + let config = + StackerConfig::from_str("name: env-app\napp:\n type: static\nenv_file: .env\n") + .unwrap(); + std::fs::write(dir.path().join(".env.example"), "APP_ENV=production\n").unwrap(); ensure_env_file_if_needed(&config, dir.path()).unwrap(); let env_path = dir.path().join(".env"); assert!(env_path.exists()); - let content = std::fs::read_to_string(env_path).unwrap(); + let content = std::fs::read_to_string(&env_path).unwrap(); assert!(content.contains("APP_ENV=production")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(env_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } } // ── Progress / health-check helpers ────────────── @@ -4391,4 +5358,72 @@ monitoring: let cmd = DeployCommand::new(None, None, false, false).with_watch(true, true); assert_eq!(cmd.watch, Some(false)); } + + // ── deploy_single_service compose-injection tests ──────────────────────── + // These tests verify the compose-mutation step that deploy_single_service + // performs before sending compose content to the agent. + + use crate::cli::config_parser::{DomainConfig, ProxyConfig, ProxyType, SslMode}; + use crate::cli::compose_service_sync::inject_npm_proxy_network; + + fn npm_proxy_for(upstream: &str) -> ProxyConfig { + ProxyConfig { + proxy_type: ProxyType::NginxProxyManager, + auto_detect: false, + domains: vec![DomainConfig { + domain: "app.example.com".into(), + ssl: SslMode::Auto, + upstream: upstream.to_string(), + }], + config: None, + } + } + + #[test] + fn deploy_single_service_compose_injection_adds_default_network_for_proxied_service() { + let compose_yaml = "services:\n api:\n image: myapp:latest\n ports:\n - \"3000:3000\"\n"; + let mut doc: serde_yaml::Value = serde_yaml::from_str(compose_yaml).unwrap(); + + let changed = inject_npm_proxy_network(&mut doc, "api", &npm_proxy_for("api:3000")); + + assert!(changed, "proxied service should trigger injection"); + let serialized = serde_yaml::to_string(&doc).unwrap(); + assert!( + serialized.contains("default_network"), + "injected compose should contain default_network:\n{serialized}" + ); + assert!( + serialized.contains("external: true") || serialized.contains("external: 'true'"), + "default_network must be declared external:\n{serialized}" + ); + } + + #[test] + fn deploy_single_service_compose_injection_skips_non_proxied_service() { + let compose_yaml = "services:\n smtp:\n image: trydirect/smtp\n"; + let mut doc: serde_yaml::Value = serde_yaml::from_str(compose_yaml).unwrap(); + + // proxy points to "api", not "smtp" + let changed = inject_npm_proxy_network(&mut doc, "smtp", &npm_proxy_for("api:3000")); + + assert!(!changed, "non-proxied service should not trigger injection"); + let serialized = serde_yaml::to_string(&doc).unwrap(); + assert!( + !serialized.contains("default_network"), + "unmodified compose should not contain default_network:\n{serialized}" + ); + } + + #[test] + fn deploy_single_service_compose_injection_skips_when_no_stacker_config() { + // When stacker_config is None (no stacker.yml present), deploy_single_service + // passes the original compose_content unchanged. Verify inject is a no-op + // when the proxy config has no domains. + let proxy = ProxyConfig::default(); // proxy_type: None, no domains + let compose_yaml = "services:\n web:\n image: nginx:latest\n"; + let mut doc: serde_yaml::Value = serde_yaml::from_str(compose_yaml).unwrap(); + + let changed = inject_npm_proxy_network(&mut doc, "web", &proxy); + assert!(!changed); + } } diff --git a/src/console/commands/cli/deployment.rs b/src/console/commands/cli/deployment.rs new file mode 100644 index 00000000..598b7442 --- /dev/null +++ b/src/console/commands/cli/deployment.rs @@ -0,0 +1,515 @@ +use crate::cli::config_parser::StackerConfig; +use crate::cli::credentials::CredentialsManager; +use crate::cli::error::CliError; +use crate::cli::stacker_client::StackerClient; +use crate::console::commands::cli::status::{ + is_remote_deployment, missing_remote_project_reason, resolve_project_name, + resolve_stacker_base_url, +}; +use crate::console::commands::CallableTrait; +use crate::services::{ + DeployPlan, DeployPlanOperation, DeploymentEventFeed, DeploymentState, TypedErrorEnvelope, +}; + +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +/// `stacker deployment state [--json] [--deployment ]` +/// +/// Queries the canonical deployment state payload from the Stacker API. +pub struct DeploymentStateCommand { + pub json: bool, + pub deployment: Option, +} + +/// `stacker deployment events [--json] [--deployment ]` +/// +/// Queries the structured deployment event feed from the Stacker API. +pub struct DeploymentEventsCommand { + pub json: bool, + pub deployment: Option, +} + +/// `stacker deployment rollback --to [--plan] [--apply-plan ] --confirm` +pub struct DeploymentRollbackCommand { + pub to: String, + pub plan: bool, + pub apply_plan: Option, + pub confirm: bool, + pub deployment: Option, +} + +impl DeploymentRollbackCommand { + pub fn new( + to: String, + plan: bool, + apply_plan: Option, + confirm: bool, + deployment: Option, + ) -> Self { + Self { + to, + plan, + apply_plan, + confirm, + deployment, + } + } +} + +impl DeploymentEventsCommand { + pub fn new(json: bool, deployment: Option) -> Self { + Self { json, deployment } + } +} + +impl DeploymentStateCommand { + pub fn new(json: bool, deployment: Option) -> Self { + Self { json, deployment } + } +} + +fn print_state(state: &DeploymentState, json: bool) { + if json { + println!( + "{}", + serde_json::to_string_pretty(state).expect("deployment state should serialize") + ); + return; + } + + println!("Deployment: {}", state.deployment.deployment_hash); + println!("Status: {}", state.deployment.status); + println!("Runtime: {}", state.deployment.runtime); + println!("Project: {}", state.project.name); + println!("Agent: {}", state.agent.status); + println!("Compose: {}", state.runtime.compose_path); + println!("Env: {}", state.runtime.env_path); + + if !state.apps.is_empty() { + println!("\nApps:"); + for app in &state.apps { + println!( + " - {} ({}) cfg={} vault_sync={}", + app.name, app.code, app.config_version, app.vault_sync_version + ); + } + } + + if let Some(last_command) = &state.last_command { + println!( + "\nLast command: {} [{}] at {}", + last_command.r#type, last_command.status, last_command.finished_at + ); + } +} + +fn print_plan(plan: &DeployPlan) -> Result<(), CliError> { + println!( + "{}", + serde_json::to_string_pretty(plan).map_err(|err| CliError::ConfigValidation(format!( + "Failed to serialize deployment plan: {err}" + )))?, + ); + Ok(()) +} + +fn print_events(feed: &DeploymentEventFeed, json: bool) -> Result<(), CliError> { + if json { + println!( + "{}", + serde_json::to_string_pretty(feed).map_err(|err| CliError::ConfigValidation( + format!("Failed to serialize deployment events: {err}") + ))? + ); + return Ok(()); + } + + println!("Deployment: {}", feed.deployment_hash); + if feed.events.is_empty() { + println!("No deployment events recorded."); + return Ok(()); + } + + for event in &feed.events { + let status = event.status.as_deref().unwrap_or("-"); + println!( + "{:>2}. {} [{} / {}] {}", + event.sequence, + event.occurred_at, + serde_json::to_string(&event.kind) + .unwrap_or_default() + .trim_matches('"'), + status, + event.summary + ); + } + + Ok(()) +} + +pub(crate) async fn fetch_remote_deployment_plan( + config: &StackerConfig, + base_url: &str, + client: &StackerClient, + requested_hash: Option<&str>, + operation: DeployPlanOperation, + app_code: Option<&str>, + rollback_target: Option<&str>, + expected_fingerprint: Option<&str>, +) -> Result { + let deployment_hash = resolve_deployment_hash(config, base_url, client, requested_hash).await?; + client + .get_deployment_plan_by_hash( + &deployment_hash, + operation, + &config.deploy.target.to_string(), + app_code, + rollback_target, + expected_fingerprint, + ) + .await? + .ok_or_else(|| { + CliError::from( + TypedErrorEnvelope::deployment_not_found(format!( + "No deployment plan found for hash '{}'", + deployment_hash + )) + .with_context("deploymentHash", deployment_hash), + ) + }) +} + +async fn resolve_deployment_hash( + config: &StackerConfig, + base_url: &str, + client: &StackerClient, + requested_hash: Option<&str>, +) -> Result { + if let Some(hash) = requested_hash + .map(str::trim) + .filter(|hash| !hash.is_empty()) + .map(ToOwned::to_owned) + { + return Ok(hash); + } + + if let Some(hash) = config + .deploy + .deployment_hash + .as_ref() + .map(|hash| hash.trim()) + .filter(|hash| !hash.is_empty()) + .map(ToOwned::to_owned) + { + return Ok(hash); + } + + let project_name = resolve_project_name(config); + let deploy_target = config.deploy.target; + let project = client.find_project_by_name(&project_name).await?; + let project = project.ok_or_else(|| CliError::DeployFailed { + target: deploy_target, + reason: missing_remote_project_reason(&project_name, base_url, deploy_target), + })?; + + let latest = client.get_deployment_status_by_project(project.id).await?; + latest + .map(|deployment| deployment.deployment_hash) + .ok_or_else(|| CliError::DeployFailed { + target: deploy_target, + reason: format!( + "No deployments found for project '{}' on {}", + project_name, base_url + ), + }) +} + +fn run_remote_deployment_state( + json: bool, + requested_hash: Option<&str>, +) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + + if !config_path.exists() { + return Err(Box::new(CliError::ConfigValidation( + "No stacker.yml found. Run 'stacker init' first.".to_string(), + ))); + } + + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + let deploy_target = config.deploy.target; + + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("deployment state")?; + let base_url = resolve_stacker_base_url(&creds); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CliError::DeployFailed { + target: deploy_target, + reason: format!("Failed to initialize async runtime: {}", e), + })?; + + rt.block_on(async { + let client = StackerClient::new(&base_url, &creds.access_token); + let deployment_hash = + resolve_deployment_hash(&config, &base_url, &client, requested_hash).await?; + let state = client + .get_deployment_state_by_hash(&deployment_hash) + .await? + .ok_or_else(|| { + CliError::from( + TypedErrorEnvelope::deployment_not_found(format!( + "No deployment state found for hash '{}'", + deployment_hash + )) + .with_context("deploymentHash", deployment_hash.clone()), + ) + })?; + + print_state(&state, json); + Ok(()) + }) +} + +fn run_remote_deployment_events( + json: bool, + requested_hash: Option<&str>, +) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + + if !config_path.exists() { + return Err(Box::new(CliError::ConfigValidation( + "No stacker.yml found. Run 'stacker init' first.".to_string(), + ))); + } + + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + let deploy_target = config.deploy.target; + + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("deployment events")?; + let base_url = resolve_stacker_base_url(&creds); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CliError::DeployFailed { + target: deploy_target, + reason: format!("Failed to initialize async runtime: {}", e), + })?; + + rt.block_on(async { + let client = StackerClient::new(&base_url, &creds.access_token); + let deployment_hash = + resolve_deployment_hash(&config, &base_url, &client, requested_hash).await?; + let events = client + .get_deployment_events_by_hash(&deployment_hash) + .await? + .ok_or_else(|| { + CliError::from( + TypedErrorEnvelope::deployment_not_found(format!( + "No deployment events found for hash '{}'", + deployment_hash + )) + .with_context("deploymentHash", deployment_hash.clone()), + ) + })?; + + print_events(&events, json)?; + Ok(()) + }) +} + +pub(crate) fn run_remote_deployment_plan( + requested_hash: Option<&str>, + operation: DeployPlanOperation, + app_code: Option<&str>, + rollback_target: Option<&str>, + expected_fingerprint: Option<&str>, +) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + + if !config_path.exists() { + return Err(Box::new(CliError::ConfigValidation( + "No stacker.yml found. Run 'stacker init' first.".to_string(), + ))); + } + + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + let deploy_target = config.deploy.target; + + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("deployment plan")?; + let base_url = resolve_stacker_base_url(&creds); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CliError::DeployFailed { + target: deploy_target, + reason: format!("Failed to initialize async runtime: {}", e), + })?; + + rt.block_on(async { + let client = StackerClient::new(&base_url, &creds.access_token); + let plan = fetch_remote_deployment_plan( + &config, + &base_url, + &client, + requested_hash, + operation, + app_code, + rollback_target, + expected_fingerprint, + ) + .await?; + print_plan(&plan)?; + Ok(()) + }) +} + +impl CallableTrait for DeploymentStateCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + if !is_remote_deployment(&project_dir) { + return Err(Box::new(CliError::ConfigValidation( + "Deployment state is only available for cloud or server targets.".to_string(), + ))); + } + + run_remote_deployment_state(self.json, self.deployment.as_deref()) + } +} + +impl CallableTrait for DeploymentEventsCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + if !is_remote_deployment(&project_dir) { + return Err(Box::new(CliError::ConfigValidation( + "Deployment events are only available for cloud or server targets.".to_string(), + ))); + } + + run_remote_deployment_events(self.json, self.deployment.as_deref()) + } +} + +impl CallableTrait for DeploymentRollbackCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + if !is_remote_deployment(&project_dir) { + return Err(Box::new(CliError::ConfigValidation( + "Deployment rollback is only available for cloud or server targets.".to_string(), + ))); + } + + if self.plan { + return run_remote_deployment_plan( + self.deployment.as_deref(), + DeployPlanOperation::RollbackDeploy, + None, + Some(&self.to), + None, + ); + } + + let fingerprint = self.apply_plan.as_deref().ok_or_else(|| { + Box::new(CliError::ConfigValidation( + "Use --plan to preview rollback or --apply-plan to execute it." + .to_string(), + )) as Box + })?; + + if !self.confirm { + return Err(Box::new(CliError::ConfigValidation( + "Rollback apply requires --confirm (-y).".to_string(), + ))); + } + + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + if !config_path.exists() { + return Err(Box::new(CliError::ConfigValidation( + "No stacker.yml found. Run 'stacker init' first.".to_string(), + ))); + } + + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + let project_name = resolve_project_name(&config); + let deploy_target = config.deploy.target; + + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("deployment rollback")?; + let base_url = resolve_stacker_base_url(&creds); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CliError::DeployFailed { + target: deploy_target, + reason: format!("Failed to initialize async runtime: {}", e), + })?; + + rt.block_on(async { + let client = StackerClient::new(&base_url, &creds.access_token); + let plan = fetch_remote_deployment_plan( + &config, + &base_url, + &client, + self.deployment.as_deref(), + DeployPlanOperation::RollbackDeploy, + None, + Some(&self.to), + Some(fingerprint), + ) + .await?; + + if !plan.has_changes { + println!( + "Rollback already satisfied for {}. Nothing to apply.", + plan.deployment_hash + ); + return Ok::<(), CliError>(()); + } + + let resolved_version = plan + .rollback + .as_ref() + .map(|rollback| rollback.resolved_version.clone()) + .ok_or_else(|| { + CliError::from(TypedErrorEnvelope::internal_error( + "Rollback plan did not include a resolved target version", + )) + })?; + + let project = client.find_project_by_name(&project_name).await?; + let project = project.ok_or_else(|| CliError::DeployFailed { + target: deploy_target, + reason: format!("Project '{}' not found on server.", project_name), + })?; + + eprintln!( + "Rolling back deployment '{}' to version '{}'...", + plan.deployment_hash, resolved_version + ); + client + .rollback_project(project.id, &resolved_version) + .await?; + Ok::<(), CliError>(()) + })?; + + Ok(()) + } +} diff --git a/src/console/commands/cli/explain.rs b/src/console/commands/cli/explain.rs new file mode 100644 index 00000000..19351600 --- /dev/null +++ b/src/console/commands/cli/explain.rs @@ -0,0 +1,207 @@ +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::services::config_renderer::EnvRenderInput; +use crate::services::{ + build_explain_env, build_explain_topology, ExplainTopologyService, TypedErrorEnvelope, +}; + +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +pub struct ExplainEnvCommand { + pub app: String, + pub json: bool, +} + +impl ExplainEnvCommand { + pub fn new(app: String, json: bool) -> Self { + Self { app, json } + } +} + +pub struct ExplainTopologyCommand { + pub json: bool, +} + +impl ExplainTopologyCommand { + pub fn new(json: bool) -> Self { + Self { json } + } +} + +fn load_config(project_dir: &Path) -> Result { + StackerConfig::from_file(&project_dir.join(DEFAULT_CONFIG_FILE))? + .with_resolved_deploy_target(None) +} + +fn resolve_local_env_path(project_dir: &Path, config: &StackerConfig) -> Result { + let env_file = config + .resolve_environment_config(None)? + .and_then(|(_, env)| env.env_file) + .or_else(|| config.env_file.clone()) + .unwrap_or_else(|| PathBuf::from(".env")); + Ok(if env_file.is_absolute() { + env_file + } else { + project_dir.join(env_file) + }) +} + +fn resolve_local_compose_path( + project_dir: &Path, + config: &StackerConfig, +) -> Result { + let compose_file = config + .resolve_environment_config(None)? + .and_then(|(_, env)| env.compose_file) + .or_else(|| config.deploy.compose_file.clone()) + .unwrap_or_else(|| PathBuf::from(".stacker/docker-compose.yml")); + Ok(if compose_file.is_absolute() { + compose_file + } else { + project_dir.join(compose_file) + }) +} + +fn main_app_code(config: &StackerConfig) -> String { + config + .project + .identity + .clone() + .unwrap_or_else(|| "app".to_string()) +} + +fn resolve_service<'a>( + config: &'a StackerConfig, + app_code: &str, +) -> Result, CliError> { + if app_code == "app" || app_code == main_app_code(config) { + return Ok(None); + } + + config + .services + .iter() + .find(|service| service.name == app_code) + .map(Some) + .ok_or_else(|| { + TypedErrorEnvelope::invalid_request(format!( + "App or service '{app_code}' was not found in stacker.yml" + )) + .with_context("appCode", app_code) + .into() + }) +} + +fn build_env_input(config: &StackerConfig, app_code: &str) -> Result { + let service = resolve_service(config, app_code)?; + let mut input = EnvRenderInput { + base: config.env.clone(), + ..EnvRenderInput::default() + }; + if let Some(service) = service { + input.service = service.environment.clone(); + } else { + input.service = config.app.environment.clone(); + } + Ok(input) +} + +fn print_json(value: &T) -> Result<(), CliError> { + let rendered = serde_json::to_string_pretty(value).map_err(|err| { + CliError::ConfigValidation(format!("Failed to serialize explain output: {err}")) + })?; + println!("{rendered}"); + Ok(()) +} + +impl CallableTrait for ExplainEnvCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let config = load_config(&project_dir)?; + let deployment_hash = config + .deploy + .deployment_hash + .clone() + .unwrap_or_else(|| "unbound".to_string()); + let local_env_path = resolve_local_env_path(&project_dir, &config)?; + let explain = build_explain_env( + &deployment_hash, + &self.app, + &local_env_path.to_string_lossy(), + remote_runtime_env_path(), + remote_runtime_compose_path(), + build_env_input(&config, &self.app)?, + ) + .map_err(|err| CliError::ConfigValidation(err.to_string()))?; + + if self.json { + print_json(&explain)?; + } else { + println!("Explain env for {}", explain.app_code); + println!( + " local authoring env: {}", + explain.local_authoring_env_path + ); + println!(" runtime env: {}", explain.runtime_env_path); + println!(" runtime compose: {}", explain.runtime_compose_path); + } + + Ok(()) + } +} + +impl CallableTrait for ExplainTopologyCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let config = load_config(&project_dir)?; + let deployment_hash = config + .deploy + .deployment_hash + .clone() + .unwrap_or_else(|| "unbound".to_string()); + let local_env_path = resolve_local_env_path(&project_dir, &config)?; + let local_compose_path = resolve_local_compose_path(&project_dir, &config)?; + + let mut services = vec![ExplainTopologyService { + code: main_app_code(&config), + name: config.name.clone(), + enabled: true, + }]; + services.extend( + config + .services + .iter() + .map(|service| ExplainTopologyService { + code: service.name.clone(), + name: service.name.clone(), + enabled: true, + }), + ); + + let topology = build_explain_topology( + &deployment_hash, + &config.deploy.target.to_string(), + &local_compose_path.to_string_lossy(), + remote_runtime_compose_path(), + &local_env_path.to_string_lossy(), + remote_runtime_env_path(), + services, + ); + + if self.json { + print_json(&topology)?; + } else { + println!("Explain topology for {}", topology.deployment_hash); + println!(" local compose: {}", topology.local_compose_path); + println!(" runtime compose: {}", topology.runtime_compose_path); + println!(" local env: {}", topology.local_authoring_env_path); + println!(" runtime env: {}", topology.runtime_env_path); + } + + Ok(()) + } +} diff --git a/src/console/commands/cli/init.rs b/src/console/commands/cli/init.rs index 5c065c6f..108ecb27 100644 --- a/src/console/commands/cli/init.rs +++ b/src/console/commands/cli/init.rs @@ -1,10 +1,16 @@ use std::convert::TryFrom; +use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; use crate::cli::ai_client::{create_provider, ollama_complete_streaming, AiProvider}; use crate::cli::ai_scanner::{ build_generation_request, generate_config_with_ai, strip_code_fences, }; +use crate::cli::ai_scenarios::{ + detect_website_project_kind, is_qwen_website_scenario_model, load_scenario_manifest, + missing_required_vars, next_step_id, save_scenario_state, seed_website_scenario_state, + ScenarioSelection, ScenarioState, WEBSITE_DEPLOY_SCENARIO, +}; use crate::cli::config_parser::{ AiConfig, AiProviderType, AppType, ConfigBuilder, DomainConfig, ProxyConfig, ProxyType, ServiceDefinition, SslMode, StackerConfig, @@ -15,6 +21,7 @@ use crate::cli::detector::{ use crate::cli::error::CliError; use crate::cli::generator::compose::ComposeDefinition; use crate::cli::generator::dockerfile::DockerfileBuilder; +use crate::console::commands::cli::ai::{build_system_prompt_base, run_ai_ask_with_system_prompt}; use crate::console::commands::CallableTrait; /// Default config filename generated by `stacker init`. @@ -190,6 +197,239 @@ fn parse_app_type(s: &str) -> Result { }) } +fn prompt_line(prompt: &str) -> Result { + print!("{}", prompt); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(input.trim().to_string()) +} + +fn prompt_with_default(prompt: &str, default: &str) -> Result { + let line = prompt_line(&format!("{} [{}]: ", prompt, default))?; + if line.is_empty() { + Ok(default.to_string()) + } else { + Ok(line) + } +} + +fn prompt_optional(prompt: &str, default: Option<&str>) -> Result, CliError> { + let formatted = match default { + Some(value) if !value.trim().is_empty() => format!("{} [{}]: ", prompt, value), + _ => format!("{}: ", prompt), + }; + let line = prompt_line(&formatted)?; + if line.is_empty() { + Ok(default + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned)) + } else { + Ok(Some(line)) + } +} + +fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result { + let suffix = if default_yes { "[Y/n]" } else { "[y/N]" }; + let line = prompt_line(&format!("{} {}: ", prompt, suffix))?; + if line.is_empty() { + return Ok(default_yes); + } + + match line.to_ascii_lowercase().as_str() { + "y" | "yes" => Ok(true), + "n" | "no" => Ok(false), + _ => Ok(default_yes), + } +} + +fn default_public_domain(config: &StackerConfig) -> String { + format!( + "{}.example.com", + config.name.replace(' ', "-").to_ascii_lowercase() + ) +} + +fn default_image_repository(state: &ScenarioState) -> String { + state + .vars + .get("image_repository") + .cloned() + .unwrap_or_else(|| { + let project_name = state + .vars + .get("project_name") + .cloned() + .unwrap_or_else(|| "website".to_string()) + .replace(' ', "-") + .to_ascii_lowercase(); + format!("ghcr.io/your-org/{project_name}") + }) +} + +fn default_cloud_region(provider: &str) -> &'static str { + match provider { + "hetzner" => "nbg1", + "digitalocean" => "fra1", + "linode" => "eu-central", + "vultr" => "fra", + "aws" => "eu-central-1", + _ => "nbg1", + } +} + +fn default_cloud_size(provider: &str) -> &'static str { + match provider { + "hetzner" => "cpx11", + "digitalocean" => "s-1vcpu-1gb", + "linode" => "g6-nanode-1", + "vultr" => "vc2-1c-1gb", + "aws" => "t3.micro", + _ => "cpx11", + } +} + +fn collect_website_scenario_inputs( + project_dir: &Path, + config: &StackerConfig, + state: &mut ScenarioState, +) -> Result<(), CliError> { + let manifest = load_scenario_manifest(project_dir, WEBSITE_DEPLOY_SCENARIO)?; + + if !state.vars.contains_key("repo_url") { + if let Some(repo_url) = prompt_optional( + "Git repository URL (optional, used for transcript/image hints)", + None, + )? { + state.vars.insert("repo_url".to_string(), repo_url); + } + } + + if !state.vars.contains_key("image_repository") { + let default_repository = default_image_repository(state); + state + .vars + .entry("image_repository".to_string()) + .or_insert(default_repository); + } + + for field in missing_required_vars(&manifest, state) { + match field.as_str() { + "public_domain" => { + let value = prompt_with_default("Public domain", &default_public_domain(config))?; + state.vars.insert(field, value); + } + "image_repository" => { + let value = + prompt_with_default("Image repository", &default_image_repository(state))?; + state.vars.insert(field, value); + } + "image_tag" => { + let value = prompt_with_default("Image tag", "latest")?; + state.vars.insert(field, value); + } + "cloud_provider" => { + let value = prompt_with_default( + "Cloud provider (hetzner|digitalocean|aws|linode|vultr)", + "hetzner", + )?; + state.vars.insert(field, value); + } + "cloud_region" => { + let provider = state + .vars + .get("cloud_provider") + .map(String::as_str) + .unwrap_or("hetzner"); + let value = prompt_with_default("Cloud region", default_cloud_region(provider))?; + state.vars.insert(field, value); + } + "cloud_size" => { + let provider = state + .vars + .get("cloud_provider") + .map(String::as_str) + .unwrap_or("hetzner"); + let value = prompt_with_default("Cloud size", default_cloud_size(provider))?; + state.vars.insert(field, value); + } + _ => {} + } + } + + Ok(()) +} + +fn maybe_bootstrap_website_scenario( + project_dir: &Path, + config_path: &Path, + config: &StackerConfig, + ai_runtime: Option<(&AiConfig, &dyn AiProvider)>, +) -> Result<(), CliError> { + let Some((ai_config, provider)) = ai_runtime else { + return Ok(()); + }; + + let Some(project_kind) = detect_website_project_kind(project_dir, config) else { + return Ok(()); + }; + + if !is_qwen_website_scenario_model(ai_config) { + eprintln!( + "💡 Website deployment scenario available for Ollama qwen2.5-code/qwen2.5-coder via `stacker ai ask \"continue\" --scenario website-deploy --step init-validate`." + ); + return Ok(()); + } + + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + eprintln!( + "💡 Qwen website deployment scenario available: `stacker ai ask \"continue\" --scenario website-deploy --step init-validate`." + ); + return Ok(()); + } + + if !prompt_yes_no( + &format!( + "Start the {} deployment scenario now?", + project_kind.display_name() + ), + true, + )? { + eprintln!( + "💡 Continue later with: `stacker ai ask \"continue\" --scenario website-deploy --step init-validate`." + ); + return Ok(()); + } + + let mut state = + seed_website_scenario_state(project_dir, config_path, config, ai_config, &project_kind); + collect_website_scenario_inputs(project_dir, config, &mut state)?; + let state_path = save_scenario_state(project_dir, &state)?; + let selection = + ScenarioSelection::new(WEBSITE_DEPLOY_SCENARIO, Some(state.current_step.clone())); + let system_prompt = build_system_prompt_base(project_dir, ai_config, Some(&selection), true)?; + let question = format!( + "Start the {} deployment scenario at the current step. Use the saved scenario variables and current project files. Give the exact next commands, validations, and the next step only. If any required value is still missing, ask for it explicitly.", + project_kind.display_name() + ); + let response = run_ai_ask_with_system_prompt(&question, None, provider, &system_prompt)?; + + eprintln!("💾 Saved AI scenario state to {}", state_path.display()); + println!("\n{}", response); + + if let Some(next_step) = + next_step_id(project_dir, WEBSITE_DEPLOY_SCENARIO, &state.current_step)? + { + eprintln!( + "💡 Continue later with: `stacker ai ask \"continue\" --scenario website-deploy --step {}`.", + next_step + ); + } + + Ok(()) +} + /// Build an `AiConfig` from CLI flags and/or environment variables. /// /// Priority: CLI flag > environment variable > defaults. @@ -482,9 +722,8 @@ fn generate_config_template_path( config.deploy.compose_file = workspace_detection.recommended_compose_file.clone(); } - // Serialize to YAML - let yaml = serde_yaml::to_string(&config) - .map_err(|e| CliError::GeneratorError(format!("Failed to serialize config: {e}")))?; + // Serialize to YAML, keeping generated configs compact and validation-clean. + let yaml = serialize_generated_config(&config)?; let scan_summary = render_scan_summary( project_dir, @@ -507,6 +746,70 @@ fn generate_config_template_path( Ok(config_path.to_path_buf()) } +fn serialize_generated_config(config: &StackerConfig) -> Result { + let mut value = serde_yaml::to_value(config) + .map_err(|e| CliError::GeneratorError(format!("Failed to serialize config: {e}")))?; + + prune_generated_config_value(&mut value); + remove_disabled_generated_ai_section(&mut value); + + serde_yaml::to_string(&value) + .map_err(|e| CliError::GeneratorError(format!("Failed to serialize config: {e}"))) +} + +fn prune_generated_config_value(value: &mut serde_yaml::Value) { + match value { + serde_yaml::Value::Mapping(map) => { + let keys = map.keys().cloned().collect::>(); + for key in &keys { + if let Some(child) = map.get_mut(key) { + prune_generated_config_value(child); + } + } + + for key in keys { + if map.get(&key).is_some_and(is_noise_generated_config_value) { + map.remove(&key); + } + } + } + serde_yaml::Value::Sequence(items) => { + for item in items.iter_mut() { + prune_generated_config_value(item); + } + items.retain(|item| !is_noise_generated_config_value(item)); + } + _ => {} + } +} + +fn is_noise_generated_config_value(value: &serde_yaml::Value) -> bool { + match value { + serde_yaml::Value::Null => true, + serde_yaml::Value::Sequence(items) => items.is_empty(), + serde_yaml::Value::Mapping(map) => map.is_empty(), + _ => false, + } +} + +fn remove_disabled_generated_ai_section(value: &mut serde_yaml::Value) { + let serde_yaml::Value::Mapping(root) = value else { + return; + }; + + let ai_key = serde_yaml::Value::String("ai".to_string()); + let remove_ai = root + .get(&ai_key) + .and_then(serde_yaml::Value::as_mapping) + .and_then(|ai| ai.get(serde_yaml::Value::String("enabled".to_string()))) + .and_then(serde_yaml::Value::as_bool) + == Some(false); + + if remove_ai { + root.remove(&ai_key); + } +} + fn choose_primary_app(workspace_detection: &WorkspaceDetection) -> Option<&DiscoveredApp> { if workspace_detection.apps.is_empty() { return None; @@ -1064,13 +1367,13 @@ impl CallableTrait for InitCommand { } if !generated { - let builder = DockerfileBuilder::from(config.app.app_type); + let builder = DockerfileBuilder::for_project(&project_dir, config.app.app_type); builder.write_to(&dockerfile_path, true)?; eprintln!("✓ Regenerated {}/Dockerfile (template)", OUTPUT_DIR); } } else if needs_dockerfile { let dockerfile_path = output_dir.join("Dockerfile"); - let builder = DockerfileBuilder::from(config.app.app_type); + let builder = DockerfileBuilder::for_project(&project_dir, config.app.app_type); builder.write_to(&dockerfile_path, false)?; eprintln!("✓ Generated {}/Dockerfile", OUTPUT_DIR); } @@ -1118,6 +1421,17 @@ impl CallableTrait for InitCommand { eprintln!(" stacker deploy --target local --dry-run # Preview deployment"); eprintln!(" stacker deploy --target local # Deploy locally"); + if self.with_ai { + maybe_bootstrap_website_scenario( + &project_dir, + &config_path, + &config, + ai_runtime + .as_ref() + .map(|(cfg, provider)| (cfg, provider.as_ref())), + )?; + } + Ok(()) } } @@ -1260,6 +1574,34 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_init_output_from_compose_validates_without_empty_path_noise() { + let dir = setup_dir_with_nested_files(&[ + ("package.json", r#"{"name":"web","version":"0.1.0"}"#), + ( + "docker-compose.yml", + "services:\n status-panel-web:\n image: trydirect/status-panel-web:latest\n ports:\n - \"3000:3000\"\n environment:\n NEXT_PUBLIC_SITE_URL: https://status.stacker.my\n INTENTIONAL_EMPTY: null\n NODE_ENV: production\n", + ), + ]); + let path = generate_config(dir.path(), None, false, false).unwrap(); + let rendered = std::fs::read_to_string(&path).unwrap(); + let issues = + crate::console::commands::cli::config::run_validate(&path.to_string_lossy()).unwrap(); + + assert_eq!(issues, Vec::::new()); + assert!(rendered.contains("target: local")); + assert!(rendered.contains("type: none")); + assert!(rendered.contains("status_panel: false")); + assert!(rendered.contains("INTENTIONAL_EMPTY: ''")); + assert!(!rendered.contains(": null")); + assert!(!rendered.contains("dockerfile:")); + assert!(!rendered.contains("env_file:")); + assert!(!rendered.contains("hooks:")); + assert!(!rendered.contains("ports: []")); + assert!(!rendered.contains("environment: {}")); + assert!(!rendered.contains("depends_on: []")); + } + #[test] fn test_init_empty_dir_defaults_to_custom() { let dir = TempDir::new().unwrap(); diff --git a/src/console/commands/cli/login.rs b/src/console/commands/cli/login.rs index 44d9f947..fd36afbb 100644 --- a/src/console/commands/cli/login.rs +++ b/src/console/commands/cli/login.rs @@ -1,20 +1,22 @@ use std::io::{self, IsTerminal}; -use crate::cli::credentials::{login, CredentialsManager, HttpOAuthClient, LoginRequest}; +use crate::cli::credentials::{ + browser_login, login, CredentialsManager, HttpOAuthClient, LoginRequest, +}; +use crate::cli::user_config::UserConfig; use crate::console::commands::CallableTrait; -use dialoguer::Password; - -/// `stacker login [--org ] [--domain ] [--auth-url ]` -/// -/// Authenticates with the TryDirect platform via OAuth2 and stores -/// credentials in `~/.config/stacker/credentials.json`. -/// -/// Prompts for email on stdin and masks password input when interactive. +use dialoguer::{Password, Select}; + pub struct LoginCommand { pub org: Option, pub domain: Option, pub auth_url: Option, pub server_url: Option, + pub browser: bool, + /// Explicit --provider value; None means "ask interactively". + pub provider: Option, + /// Pre-filled email for username/password flow; also disables browser flow. + pub user: Option, } impl LoginCommand { @@ -23,16 +25,53 @@ impl LoginCommand { domain: Option, auth_url: Option, server_url: Option, + browser: bool, + provider: Option, + user: Option, ) -> Self { Self { org, domain, auth_url, server_url, + browser, // raw flag only; browser_default() is applied in call() + provider, + user, + } + } + + /// Returns the OAuth provider code to use, or `None` if the user chose + /// username/password from the interactive menu. + /// + /// Resolution order: + /// 1. `--provider ` flag → use it directly, no prompt. + /// 2. Interactive tty → show a Select menu. + /// 3. Non-interactive → fall back to the config/default value. + fn prompt_provider(&self) -> Result, Box> { + if let Some(ref p) = self.provider { + return Ok(Some(p.clone())); + } + + if io::stdin().is_terminal() { + const PROVIDERS: &[(&str, Option<&str>)] = &[ + ("Google", Some("gc")), + ("GitHub", Some("gh")), + ("Microsoft", Some("azu")), + ("Username / Password", None), + ]; + let labels: Vec<&str> = PROVIDERS.iter().map(|(l, _)| *l).collect(); + let idx = Select::new() + .with_prompt("Select auth provider") + .items(&labels) + .default(0) + .interact() + .map_err(|e| format!("Prompt error: {e}"))?; + return Ok(PROVIDERS[idx].1.map(str::to_string)); } + + Ok(Some(UserConfig::load().provider_default())) } - /// Read a line from stdin (used for email/password prompts). fn read_line(prompt: &str) -> Result> { eprint!("{}", prompt); let mut input = String::new(); @@ -52,15 +91,88 @@ impl LoginCommand { Self::read_line(prompt) } } + + fn resolve_auth_url(&self) -> Result> { + let cfg = UserConfig::load(); + self.auth_url + .clone() + .or_else(|| std::env::var("STACKER_AUTH_URL").ok()) + .or_else(|| std::env::var("STACKER_API_URL").ok()) + .or_else(|| cfg.auth_url) + .ok_or_else(|| { + "Missing auth URL. Pass --auth-url or set STACKER_AUTH_URL." + .into() + }) + } + + fn resolve_server_url(&self) -> Result> { + let cfg = UserConfig::load(); + self.server_url + .clone() + .or_else(|| std::env::var("STACKER_URL").ok()) + .or_else(|| cfg.server_url) + .ok_or_else(|| { + "Missing Stacker API URL. Pass --server-url or set STACKER_URL." + .into() + }) + } } impl CallableTrait for LoginCommand { fn call(&self) -> Result<(), Box> { - let email = Self::read_line("Email: ")?; - if email.is_empty() { - return Err("Email cannot be empty".into()); + // --user/-u always means email/password; skip prompt and browser. + // --browser flag forces browser; on a tty, browser_default() from config applies. + // Piped stdin always falls back to email/password regardless of config. + let use_browser = self.user.is_none() + && (self.browser + || (io::stdin().is_terminal() && UserConfig::load().browser_default())); + + if use_browser { + // Resolve provider — may show an interactive menu. + let provider = self.prompt_provider()?; + + if let Some(provider) = provider { + let auth_url = self.resolve_auth_url()?; + let server_url = self.resolve_server_url()?; + let manager = CredentialsManager::with_default_store(); + + let creds = browser_login( + &manager, + &auth_url, + &server_url, + &provider, + self.org.as_deref(), + self.domain.as_deref(), + )?; + + eprintln!("✓ {}", creds); + if let Some(org) = &creds.org { + eprintln!(" Organization: {}", org); + } + if let Some(domain) = &creds.domain { + eprintln!(" Domain: {}", domain); + } + if let Some(server_url) = &creds.server_url { + eprintln!(" Stacker API: {}", server_url); + } + return Ok(()); + } + // provider == None → user chose "Username / Password" from the menu; + // fall through to the email/password path below. } + // Email/password flow — email may be pre-filled via --user/-u. + let email = match self.user.as_deref() { + Some(e) if !e.is_empty() => e.to_string(), + _ => { + let e = Self::read_line("Email: ")?; + if e.is_empty() { + return Err("Email cannot be empty".into()); + } + e + } + }; + let password = Self::read_password("Password: ")?; if password.is_empty() { return Err("Password cannot be empty".into()); diff --git a/src/console/commands/cli/logs.rs b/src/console/commands/cli/logs.rs index 22d4add9..b15c7acc 100644 --- a/src/console/commands/cli/logs.rs +++ b/src/console/commands/cli/logs.rs @@ -245,26 +245,32 @@ fn run_remote_logs( let app_codes: Vec = if let Some(svc) = service { vec![svc.to_string()] } else { - // Fetch snapshot to discover all running containers - let pb = progress::spinner("Discovering containers"); - match ctx.block_on(ctx.client.agent_snapshot(&hash)) { - Ok(snap) => { - progress::finish_success(&pb, "Containers discovered"); - snap.get("containers") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|c| c.get("name").and_then(|n| n.as_str())) - .map(|s| s.to_string()) - .collect() - }) - .unwrap_or_default() - } - Err(e) => { - progress::finish_error(&pb, &format!("Could not discover containers: {}", e)); - return Err(Box::new(e)); - } + // Discover running containers via a live list_containers command. + let params = crate::forms::status_panel::ListContainersCommandRequest { + include_health: false, + include_logs: false, + log_lines: 0, + }; + let request = AgentEnqueueRequest::new(&hash, "list_containers") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; + let info = run_remote_agent_command(&ctx, &request, "Discovering containers", REMOTE_TIMEOUT_SECS)?; + if info.status != "completed" { + let (summary, tip) = no_containers_messages(&hash); + eprintln!("{}", summary); + eprintln!("{}", tip); + return Ok(()); } + info.result + .as_ref() + .and_then(|r| r.get("containers").and_then(|v| v.as_array())) + .map(|arr| { + arr.iter() + .filter_map(|c| c.get("name").and_then(|n| n.as_str())) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default() }; if app_codes.is_empty() { @@ -395,20 +401,20 @@ fn print_logs_result(app_code: &str, info: &AgentCommandInfo, multi: bool) { } if let Some(ref result) = info.result { - // Try to extract log lines from the result JSON - if let Some(logs) = result.get("logs").and_then(|v| v.as_str()) { - print!("{}", logs); - } else if let Some(lines) = result.get("lines").and_then(|v| v.as_array()) { - for line in lines { - if let Some(s) = line.as_str() { - println!("{}", s); + if let Some(lines) = result.get("lines").and_then(|v| v.as_array()) { + if lines.is_empty() { + println!("(no log output)"); + } else { + for line in lines { + let msg = line + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or(""); + println!("{}", msg); } } - } else if let Some(output) = result.get("output").and_then(|v| v.as_str()) { - print!("{}", output); } else { - // Fallback: pretty-print the whole result - println!("{}", fmt::pretty_json(result)); + println!("(no log output)"); } } else { println!("(no log output)"); diff --git a/src/console/commands/cli/mod.rs b/src/console/commands/cli/mod.rs index 8505dab4..e269aa78 100644 --- a/src/console/commands/cli/mod.rs +++ b/src/console/commands/cli/mod.rs @@ -5,7 +5,9 @@ pub mod cloud_firewall; pub mod config; pub mod connect; pub mod deploy; +pub mod deployment; pub mod destroy; +pub mod explain; pub mod init; pub mod list; pub mod login; diff --git a/src/console/commands/cli/pipe.rs b/src/console/commands/cli/pipe.rs index 4c2ba25b..07aa357c 100644 --- a/src/console/commands/cli/pipe.rs +++ b/src/console/commands/cli/pipe.rs @@ -10,20 +10,36 @@ use crate::cli::error::CliError; use crate::cli::field_matcher::{DeterministicFieldMatcher, FieldMatcher}; use crate::cli::fmt; +use crate::cli::local_pipe_store::{ + LocalPipeBinding, LocalPipeDiagnostics, LocalPipeDocument, LocalPipeInstance, LocalPipeStore, + LocalPipeTemplate, NewLocalPipeDocument, +}; use crate::cli::progress; use crate::cli::runtime::CliRuntime; +use crate::cli::service_catalog::ServiceCatalog; use crate::cli::stacker_client::{ AgentCommandInfo, AgentEnqueueRequest, CreatePipeInstanceApiRequest, - CreatePipeTemplateApiRequest, + CreatePipeTemplateApiRequest, DeploymentCapabilitiesInfo, PipeTemplateInfo, }; use crate::console::commands::CallableTrait; use crate::forms::status_panel::{ - ProbeContainer, ProbeEndpoint, ProbeEndpointsCommandReport, ProbeForm, ProbeOperation, - ProbeResource, ProbeResourceItem, + ProbeAttempt, ProbeContainer, ProbeEndpoint, ProbeEndpointsCommandReport, ProbeForm, + ProbeOperation, ProbeResource, ProbeResourceItem, }; use chrono::Utc; +use dialoguer::Password; +use pipe_adapter_mail::SmtpTargetAdapter; +use pipe_adapter_sdk::{ + builtin_registry, selector_matches_builtin_kind, PipeAdapterCatalog, PipeAdapterKind, + PipeAdapterMetadata, PipeAdapterPayload, PipeAdapterReference, PipeAdapterRole, + PipeTargetAdapter, +}; use regex::Regex; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; +use std::io::{self, IsTerminal}; +use std::path::{Path, PathBuf}; /// Default poll timeout for pipe probe commands (seconds). const PROBE_TIMEOUT_SECS: u64 = 90; @@ -31,6 +47,9 @@ const PROBE_TIMEOUT_SECS: u64 = 90; /// Default poll interval (seconds). const DEFAULT_POLL_INTERVAL_SECS: u64 = 2; +/// Current on-disk schema version for cached pipe discovery results. +const PIPE_SCAN_CACHE_VERSION: u32 = 1; + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Deployment hash resolution (mirrors agent module) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -163,6 +182,40 @@ fn resolve_deployment_context( )) } +fn resolve_local_deployment_context( + explicit: &Option, + project_dir: &Path, +) -> Result, CliError> { + if explicit + .as_ref() + .map(|hash| !hash.trim().is_empty()) + .unwrap_or(false) + { + return Ok(None); + } + + if let Some(target) = + crate::cli::deployment_lock::DeploymentLock::read_active_target(project_dir)? + { + if target == "local" { + return Ok(Some(DeploymentContext::Local)); + } + } + + let config_path = project_dir.join("stacker.yml"); + if config_path.exists() { + if let Ok(config) = crate::cli::config_parser::StackerConfig::from_file(&config_path) + .and_then(|config| config.with_resolved_deploy_target(None)) + { + if config.deploy.target == crate::cli::config_parser::DeployTarget::Local { + return Ok(Some(DeploymentContext::Local)); + } + } + } + + Ok(None) +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Shared agent command execution // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -226,6 +279,42 @@ fn run_agent_command( result } +fn validate_pipe_command_capabilities( + capabilities: &DeploymentCapabilitiesInfo, +) -> Result<(), CliError> { + if capabilities.features.pipes { + return Ok(()); + } + + let capabilities_list = if capabilities.capabilities.is_empty() { + "(none)".to_string() + } else { + capabilities.capabilities.join(", ") + }; + + Err(CliError::ConfigValidation(format!( + "The active agent for deployment '{}' does not support pipe commands.\n\ + Agent status: {}\n\ + Capabilities: {}\n\ + Update or relink the Status Panel agent so it advertises 'pipes', then retry.", + capabilities.deployment_hash, + if capabilities.status.is_empty() { + "unknown" + } else { + &capabilities.status + }, + capabilities_list + ))) +} + +fn ensure_remote_pipe_command_capability( + ctx: &CliRuntime, + deployment_hash: &str, +) -> Result<(), CliError> { + let capabilities = ctx.block_on(ctx.client.deployment_capabilities(deployment_hash))?; + validate_pipe_command_capabilities(&capabilities) +} + fn print_command_result(info: &AgentCommandInfo, json_output: bool) { if json_output { if let Ok(j) = serde_json::to_string_pretty(info) { @@ -259,6 +348,7 @@ fn print_command_result(info: &AgentCommandInfo, json_output: bool) { struct LocalPortBinding { container_port: u16, host_port: Option, + host_ip: Option, protocol: String, } @@ -292,6 +382,322 @@ fn default_local_probe_protocols() -> Vec { ] } +fn default_pipe_create_protocols() -> Vec { + vec![ + "openapi".to_string(), + "html_forms".to_string(), + "rest".to_string(), + ] +} + +fn normalize_protocols(protocols: &[String]) -> Vec { + protocols + .iter() + .map(|protocol| protocol.trim().to_ascii_lowercase()) + .filter(|protocol| !protocol.is_empty()) + .collect::>() + .into_iter() + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct PipeDiscoverySelector { + mode: String, + selector_kind: String, + selector: String, + deployment_hash: Option, + container: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct PipeDiscoveryRequest { + selector: PipeDiscoverySelector, + protocols_requested: Vec, + capture_samples: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedPipeDiscovery { + version: u32, + selector: PipeDiscoverySelector, + protocols_requested: Vec, + capture_samples: bool, + cached_at: String, + report: ProbeEndpointsCommandReport, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DiscoverySource { + Cached, + Fresh, + Synthetic, +} + +#[derive(Debug, Clone)] +struct DiscoveryRun { + info: AgentCommandInfo, + source: DiscoverySource, +} + +impl PipeDiscoveryRequest { + fn local(selector: &str, protocols: &[String], capture_samples: bool) -> Self { + Self { + selector: PipeDiscoverySelector { + mode: "local".to_string(), + selector_kind: "containers".to_string(), + selector: selector.to_string(), + deployment_hash: None, + container: None, + }, + protocols_requested: normalize_protocols(protocols), + capture_samples, + } + } + + fn remote( + deployment_hash: &str, + app: &str, + container: Option<&str>, + protocols: &[String], + capture_samples: bool, + ) -> Self { + Self { + selector: PipeDiscoverySelector { + mode: "remote".to_string(), + selector_kind: "app".to_string(), + selector: app.to_string(), + deployment_hash: Some(deployment_hash.to_string()), + container: container.map(str::to_string), + }, + protocols_requested: normalize_protocols(protocols), + capture_samples, + } + } +} + +fn pipe_scan_cache_dir(project_dir: &Path) -> PathBuf { + project_dir.join(".stacker").join("pipe-scan-cache") +} + +fn encode_cache_key(value: &T) -> Result { + let payload = serde_json::to_vec(value).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to serialize pipe discovery cache key: {err}" + )) + })?; + let digest = Sha256::digest(payload); + Ok(digest.iter().map(|byte| format!("{:02x}", byte)).collect()) +} + +fn exact_pipe_scan_cache_path( + project_dir: &Path, + request: &PipeDiscoveryRequest, +) -> Result { + Ok(pipe_scan_cache_dir(project_dir).join(format!("{}.json", encode_cache_key(request)?))) +} + +fn latest_pipe_scan_cache_path( + project_dir: &Path, + selector: &PipeDiscoverySelector, +) -> Result { + Ok(pipe_scan_cache_dir(project_dir) + .join(format!("latest-{}.json", encode_cache_key(selector)?))) +} + +fn read_cached_pipe_discovery(path: &Path) -> Result, CliError> { + if !path.exists() { + return Ok(None); + } + + let bytes = std::fs::read(path).map_err(CliError::Io)?; + let cached: CachedPipeDiscovery = serde_json::from_slice(&bytes).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to parse pipe discovery cache ({}): {}", + path.display(), + err + )) + })?; + + Ok((cached.version == PIPE_SCAN_CACHE_VERSION).then_some(cached)) +} + +fn cache_entry_to_agent_info(entry: &CachedPipeDiscovery) -> Result { + Ok(AgentCommandInfo { + command_id: format!("cached-{}", encode_cache_key(&entry.selector)?), + deployment_hash: entry.report.deployment_hash.clone(), + command_type: "probe_endpoints".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: Some(serde_json::json!({ + "app_code": entry.selector.selector.clone(), + "container": entry.selector.container.clone(), + "protocols": entry.protocols_requested.clone(), + "capture_samples": entry.capture_samples, + })), + result: Some(serde_json::to_value(&entry.report).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to serialize cached pipe discovery report: {}", + err + )) + })?), + error: None, + created_at: entry.cached_at.clone(), + updated_at: entry.cached_at.clone(), + }) +} + +fn write_pipe_scan_cache( + project_dir: &Path, + request: &PipeDiscoveryRequest, + report: &ProbeEndpointsCommandReport, +) -> Result<(), CliError> { + let cache_dir = pipe_scan_cache_dir(project_dir); + std::fs::create_dir_all(&cache_dir).map_err(CliError::Io)?; + + let entry = CachedPipeDiscovery { + version: PIPE_SCAN_CACHE_VERSION, + selector: request.selector.clone(), + protocols_requested: request.protocols_requested.clone(), + capture_samples: request.capture_samples, + cached_at: Utc::now().to_rfc3339(), + report: report.clone(), + }; + let payload = serde_json::to_vec_pretty(&entry).map_err(|err| { + CliError::ConfigValidation(format!("Failed to serialize pipe discovery cache: {}", err)) + })?; + + let exact_path = exact_pipe_scan_cache_path(project_dir, request)?; + std::fs::write(&exact_path, &payload).map_err(CliError::Io)?; + + let latest_path = latest_pipe_scan_cache_path(project_dir, &request.selector)?; + std::fs::write(&latest_path, &payload).map_err(CliError::Io)?; + Ok(()) +} + +fn load_latest_pipe_scan_cache( + project_dir: &Path, + selector: &PipeDiscoverySelector, +) -> Result, CliError> { + let path = latest_pipe_scan_cache_path(project_dir, selector)?; + let Some(entry) = read_cached_pipe_discovery(&path)? else { + return Ok(None); + }; + cache_entry_to_agent_info(&entry).map(Some) +} + +fn load_exact_pipe_scan_cache( + project_dir: &Path, + request: &PipeDiscoveryRequest, +) -> Result, CliError> { + let path = exact_pipe_scan_cache_path(project_dir, request)?; + let Some(entry) = read_cached_pipe_discovery(&path)? else { + return Ok(None); + }; + cache_entry_to_agent_info(&entry).map(Some) +} + +fn detection_outcome( + endpoints: &[ProbeEndpoint], + forms: &[ProbeForm], + resources: &[ProbeResource], +) -> String { + if !endpoints.is_empty() || !forms.is_empty() || !resources.is_empty() { + "detected".to_string() + } else { + "empty".to_string() + } +} + +fn selector_is_smtpish(selector: &str) -> bool { + let canonical = ServiceCatalog::resolve_alias(selector); + selector_matches_builtin_kind(&canonical, PipeAdapterKind::SmtpTarget) + || selector_matches_builtin_kind(selector, PipeAdapterKind::SmtpTarget) +} + +fn classify_probe_target_kind(selector: &str, report: &ProbeEndpointsCommandReport) -> String { + if !report.endpoints.is_empty() { + return "http_endpoint".to_string(); + } + if !report.forms.is_empty() { + return "html_form".to_string(); + } + + let resource_protocols = report + .resources + .iter() + .map(|resource| resource.protocol.to_ascii_lowercase()) + .collect::>(); + if resource_protocols.contains("smtp") || selector_is_smtpish(selector) { + return "smtp".to_string(); + } + if resource_protocols.len() == 1 { + return resource_protocols + .iter() + .next() + .cloned() + .unwrap_or_else(|| "resource".to_string()); + } + if resource_protocols.len() > 1 { + return "resource".to_string(); + } + if selector_is_smtpish(selector) { + return "smtp".to_string(); + } + + "unknown".to_string() +} + +fn enrich_probe_report_metadata( + report: &mut ProbeEndpointsCommandReport, + selector: &str, + container: Option<&str>, + request: &PipeDiscoveryRequest, +) { + if report.protocols_requested.is_empty() { + report.protocols_requested = request.protocols_requested.clone(); + } + + if report.probe_attempts.is_empty() { + report.probe_attempts.push(ProbeAttempt { + scope: if request.selector.mode == "local" { + "local_selector".to_string() + } else { + "remote_app".to_string() + }, + selector: Some(selector.to_string()), + container: container.map(str::to_string), + protocols: report.protocols_requested.clone(), + outcome: detection_outcome(&report.endpoints, &report.forms, &report.resources), + }); + } + + if report.target_kind.is_none() { + report.target_kind = Some(classify_probe_target_kind(selector, report)); + } +} + +fn decode_probe_report(info: &AgentCommandInfo) -> Result { + let result = info.result.clone().ok_or_else(|| { + CliError::ConfigValidation("probe_endpoints result payload is missing".to_string()) + })?; + serde_json::from_value(result).map_err(|err| { + CliError::ConfigValidation(format!("Invalid probe_endpoints result payload: {}", err)) + }) +} + +fn with_probe_report( + mut info: AgentCommandInfo, + report: &ProbeEndpointsCommandReport, +) -> Result { + info.result = Some(serde_json::to_value(report).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to encode probe_endpoints result payload: {}", + err + )) + })?); + Ok(info) +} + fn parse_port_key(key: &str) -> Option<(u16, String)> { let mut parts = key.split('/'); let port = parts.next()?.parse::().ok()?; @@ -365,24 +771,28 @@ fn parse_local_container_inspect( if let Some(port_map) = value["NetworkSettings"]["Ports"].as_object() { for (key, host_bindings) in port_map { if let Some((container_port, protocol)) = parse_port_key(key) { - let host_ports: Vec> = if let Some(bindings) = host_bindings.as_array() - { - bindings - .iter() - .map(|binding| { - binding["HostPort"] - .as_str() - .and_then(|v| v.parse::().ok()) - }) - .collect() - } else { - vec![None] - }; - for host_port in host_ports { + let binding_targets: Vec<(Option, Option)> = + if let Some(bindings) = host_bindings.as_array() { + bindings + .iter() + .map(|binding| { + ( + binding["HostPort"] + .as_str() + .and_then(|v| v.parse::().ok()), + binding["HostIp"].as_str().map(str::to_string), + ) + }) + .collect() + } else { + vec![(None, None)] + }; + for (host_port, host_ip) in binding_targets { if seen.insert((container_port, host_port, protocol.clone())) { ports.push(LocalPortBinding { container_port, host_port, + host_ip, protocol: protocol.clone(), }); } @@ -398,6 +808,7 @@ fn parse_local_container_inspect( ports.push(LocalPortBinding { container_port, host_port: None, + host_ip: None, protocol, }); } @@ -411,6 +822,7 @@ fn parse_local_container_inspect( ports.push(LocalPortBinding { container_port: value, host_port: None, + host_ip: None, protocol: "tcp".to_string(), }); } @@ -430,6 +842,22 @@ fn parse_local_container_inspect( }) } +fn docker_host_http_target(host_ip: Option<&str>, host_port: u16) -> String { + match host_ip.unwrap_or_default() { + "::" => format!("http://[::1]:{}", host_port), + "" | "0.0.0.0" => format!("http://localhost:{}", host_port), + ip if ip.contains(':') => format!("http://[{}]:{}", ip, host_port), + ip => format!("http://{}:{}", ip, host_port), + } +} + +fn docker_host_target(host_ip: Option<&str>) -> String { + match host_ip.unwrap_or_default() { + "::" | "" | "0.0.0.0" => "127.0.0.1".to_string(), + ip => ip.to_string(), + } +} + fn local_http_candidate_urls(container: &LocalContainerInfo) -> Vec { let mut urls = Vec::new(); let mut seen = BTreeSet::new(); @@ -438,7 +866,7 @@ fn local_http_candidate_urls(container: &LocalContainerInfo) -> Vec { continue; } if let Some(host_port) = port.host_port { - let url = format!("http://127.0.0.1:{}", host_port); + let url = docker_host_http_target(port.host_ip.as_deref(), host_port); if seen.insert(url.clone()) { urls.push(url); } @@ -453,6 +881,25 @@ fn local_http_candidate_urls(container: &LocalContainerInfo) -> Vec { urls } +fn docker_exec(container: &str, args: &[String]) -> Option { + let output = std::process::Command::new("docker") + .arg("exec") + .arg(container) + .args(args) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8(output.stdout).ok()?; + let trimmed = stdout.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + fn local_resource_probe_plan(container: &LocalContainerInfo) -> Vec { let identity = format!( "{} {}", @@ -553,25 +1000,6 @@ fn try_parse_json(value: &str) -> Option { serde_json::from_str(value).ok() } -fn docker_exec(container: &str, args: &[String]) -> Option { - let output = std::process::Command::new("docker") - .arg("exec") - .arg(container) - .args(args) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let stdout = String::from_utf8(output.stdout).ok()?; - let trimmed = stdout.trim().to_string(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } -} - fn local_http_probe( container: &LocalContainerInfo, protocols: &[String], @@ -800,7 +1228,9 @@ fn first_container_address(container: &LocalContainerInfo, default_port: u16) -> .find(|port| port.container_port == default_port) { if let Some(host_port) = port.host_port { - return format!("127.0.0.1:{}", host_port); + return docker_host_http_target(port.host_ip.as_deref(), host_port) + .trim_start_matches("http://") + .to_string(); } } container @@ -1176,7 +1606,9 @@ where let mut forms = Vec::new(); let mut resources = Vec::new(); let mut containers_out = Vec::new(); + let mut probe_attempts = Vec::new(); let mut protocols_detected = BTreeSet::new(); + let protocols_requested = normalize_protocols(protocols); let total = containers.len(); for (index, container) in containers.iter().enumerate() { @@ -1184,6 +1616,7 @@ where let (http_endpoints, http_forms, http_detected) = local_http_probe(container, protocols, capture_samples, 3); let (resource_items, resource_detected) = local_resource_probe(container, protocols); + let attempt_outcome = detection_outcome(&http_endpoints, &http_forms, &resource_items); for protocol in http_detected .into_iter() @@ -1191,6 +1624,13 @@ where { protocols_detected.insert(protocol); } + probe_attempts.push(ProbeAttempt { + scope: "local_container".to_string(), + selector: Some(app_code.to_string()), + container: Some(container.name.clone()), + protocols: protocols_requested.clone(), + outcome: attempt_outcome, + }); endpoints.extend(http_endpoints); forms.extend(http_forms); resources.extend(resource_items); @@ -1229,29 +1669,46 @@ where }); } - ProbeEndpointsCommandReport { + let mut report = ProbeEndpointsCommandReport { command_type: "probe_endpoints".to_string(), deployment_hash: "local".to_string(), app_code: app_code.to_string(), protocols_detected: protocols_detected.into_iter().collect(), + protocols_requested, containers: containers_out, endpoints, resources, forms, + probe_attempts, + target_kind: None, probed_at: Utc::now().to_rfc3339(), - } + }; + report.target_kind = Some(classify_probe_target_kind(app_code, &report)); + report } fn local_report_to_agent_info( report: &ProbeEndpointsCommandReport, + capture_samples: bool, +) -> Result> { + probe_report_to_agent_info(report, capture_samples) +} + +fn probe_report_to_agent_info( + report: &ProbeEndpointsCommandReport, + capture_samples: bool, ) -> Result> { Ok(AgentCommandInfo { - command_id: "local-scan".to_string(), - deployment_hash: "local".to_string(), + command_id: format!("synthetic-{}", report.app_code), + deployment_hash: report.deployment_hash.clone(), command_type: "probe_endpoints".to_string(), status: "completed".to_string(), priority: "normal".to_string(), - parameters: None, + parameters: Some(serde_json::json!({ + "app_code": report.app_code, + "protocols": report.protocols_requested, + "capture_samples": capture_samples, + })), result: Some(serde_json::to_value(report)?), error: None, created_at: report.probed_at.clone(), @@ -1352,6 +1809,7 @@ impl PipeScanCommand { /// Local scan: discover containers via `docker ps`. fn scan_local( &self, + project_dir: &Path, prefix: &str, filter: Option<&str>, ) -> Result<(), Box> { @@ -1401,10 +1859,17 @@ impl PipeScanCommand { ); progress::finish_success(&probe_pb, &format!("{}Probe stage complete", prefix)); + let request = PipeDiscoveryRequest::local( + filter.unwrap_or("local"), + &protocols, + self.capture_samples, + ); + write_pipe_scan_cache(project_dir, &request, &report)?; + if self.json { println!("{}", serde_json::to_string_pretty(&report)?); } else { - let info = local_report_to_agent_info(&report)?; + let info = local_report_to_agent_info(&report, self.capture_samples)?; print_scan_result(&info); println!(" Use these container names with 'stacker pipe create '"); } @@ -1414,29 +1879,23 @@ impl PipeScanCommand { fn scan_remote( &self, + project_dir: &Path, ctx: &CliRuntime, hash: &str, app: &str, container: Option<&str>, ) -> Result<(), Box> { let protocols = if self.protocols.is_empty() { - vec!["openapi".to_string(), "rest".to_string()] + vec![ + "openapi".to_string(), + "html_forms".to_string(), + "rest".to_string(), + ] } else { self.protocols.clone() }; - - let params = crate::forms::status_panel::ProbeEndpointsCommandRequest { - app_code: app.to_string(), - container: container.map(|value| value.to_string()), - protocols, - probe_timeout: 5, - capture_samples: self.capture_samples, - }; - - let request = AgentEnqueueRequest::new(hash, "probe_endpoints") - .with_parameters(¶ms) - .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; - + let request = + PipeDiscoveryRequest::remote(hash, app, container, &protocols, self.capture_samples); let description = match container { Some(container_name) => { format!( @@ -1447,7 +1906,8 @@ impl PipeScanCommand { None => format!("Scanning app {} for endpoints", app), }; - let info = run_agent_command(ctx, &request, &description, PROBE_TIMEOUT_SECS)?; + let info = run_remote_probe(ctx, &request, &description)?; + cache_probe_if_completed(project_dir, &request, &info)?; if self.json { print_command_result(&info, true); @@ -1463,12 +1923,13 @@ impl CallableTrait for PipeScanCommand { fn call(&self) -> Result<(), Box> { let ctx = CliRuntime::new("pipe scan")?; let deploy_ctx = resolve_deployment_context(&self.deployment, &ctx)?; + let project_dir = std::env::current_dir().map_err(CliError::Io)?; let prefix = mode_prefix(&deploy_ctx); if deploy_ctx.is_local() { self.request.maybe_print_legacy_hint(true); let filter = self.request.local_filter()?; - return self.scan_local(prefix, filter); + return self.scan_local(&project_dir, prefix, filter); } let hash = match &deploy_ctx { @@ -1477,7 +1938,7 @@ impl CallableTrait for PipeScanCommand { }; self.request.maybe_print_legacy_hint(false); let (app, container) = self.request.remote_selector()?; - self.scan_remote(&ctx, &hash, app, container) + self.scan_remote(&project_dir, &ctx, &hash, app, container) } } @@ -1715,6 +2176,7 @@ impl PipeCreateCommand { #[derive(Debug, Clone)] struct SelectableOperation { container: Option, + adapter: Option, method: String, path: String, summary: String, @@ -1746,6 +2208,7 @@ fn extract_operations(info: &AgentCommandInfo) -> Vec { let sample = op.get("sample_response").filter(|v| !v.is_null()).cloned(); ops.push(SelectableOperation { container: container.clone(), + adapter: None, method, path, summary, @@ -1770,6 +2233,7 @@ fn extract_operations(info: &AgentCommandInfo) -> Vec { .unwrap_or_default(); ops.push(SelectableOperation { container: form["container"].as_str().map(String::from), + adapter: None, method, path, summary: format!("HTML form {}", form["id"].as_str().unwrap_or("?")), @@ -1790,61 +2254,528 @@ fn result_has_resources(info: &AgentCommandInfo) -> bool { .unwrap_or(false) } -fn build_local_scan_info( +fn builtin_adapter_for_selector( selector: &str, - protocols: &[String], - capture_samples: bool, -) -> Result> { - let containers = discover_local_containers(Some(selector))?; - let report = build_local_probe_report(selector, &containers, protocols, capture_samples); - local_report_to_agent_info(&report) + role: PipeAdapterRole, +) -> Option { + let registry = builtin_registry(); + let canonical = ServiceCatalog::resolve_alias(selector); + let candidates = [canonical, selector.to_string()]; + candidates + .iter() + .find_map(|candidate| { + registry.find(candidate).or_else(|| { + registry + .adapters() + .into_iter() + .find(|metadata| selector_matches_builtin_kind(candidate, metadata.kind)) + }) + }) + .filter(|metadata| metadata.supports_role(role)) } -fn local_container_for_operation(operation: &SelectableOperation, fallback: &str) -> String { - operation - .container - .clone() - .unwrap_or_else(|| fallback.to_string()) +fn adapter_fields(kind: PipeAdapterKind) -> Vec { + match kind { + PipeAdapterKind::SmtpTarget => vec![ + "from_email".to_string(), + "reply_to_email".to_string(), + "subject".to_string(), + "body_text".to_string(), + "body_html".to_string(), + ], + PipeAdapterKind::WebhookBridge + | PipeAdapterKind::HttpEndpoint + | PipeAdapterKind::HtmlForm => { + vec![] + } + PipeAdapterKind::Pop3Source | PipeAdapterKind::ImapSource => vec![ + "subject".to_string(), + "from_email".to_string(), + "to_email".to_string(), + "body_text".to_string(), + "body_html".to_string(), + ], + } } -fn operation_label(operation: &SelectableOperation) -> String { - let prefix = operation - .container - .as_ref() - .map(|container| format!("[{}] ", container)) - .unwrap_or_default(); - if operation.summary.is_empty() { - format!("{}{:>6} {}", prefix, operation.method, operation.path) - } else { - format!( - "{}{:>6} {} — {}", - prefix, operation.method, operation.path, operation.summary - ) +fn adapter_sample(kind: PipeAdapterKind) -> Option { + match kind { + PipeAdapterKind::Pop3Source | PipeAdapterKind::ImapSource => Some(serde_json::json!({ + "subject": "Incident opened", + "from_email": "alerts@example.com", + "to_email": "ops@example.com", + "body_text": "CPU usage exceeded threshold" + })), + _ => None, } } -fn operation_labels(operations: &[SelectableOperation]) -> Vec { +fn synthetic_adapter_operation(metadata: &PipeAdapterMetadata) -> SelectableOperation { + let method = match metadata.kind { + PipeAdapterKind::SmtpTarget => "SEND", + PipeAdapterKind::Pop3Source | PipeAdapterKind::ImapSource => "POLL", + PipeAdapterKind::WebhookBridge => "POST", + PipeAdapterKind::HttpEndpoint => "HTTP", + PipeAdapterKind::HtmlForm => "FORM", + }; + SelectableOperation { + container: None, + adapter: Some(metadata.clone()), + method: method.to_string(), + path: format!("adapter:{}", metadata.code), + summary: format!("{} adapter", metadata.display_name), + fields: adapter_fields(metadata.kind), + sample: adapter_sample(metadata.kind), + } +} + +fn template_endpoint_for_operation(operation: &SelectableOperation) -> serde_json::Value { + if let Some(adapter) = &operation.adapter { + serde_json::json!({ + "mode": "adapter", + "adapter": adapter.code, + "display_name": adapter.display_name, + }) + } else { + serde_json::json!({ + "path": operation.path, + "method": operation.method, + }) + } +} + +fn prompt_text( + prompt: &str, + default: Option<&str>, + allow_empty: bool, +) -> Result> { + let mut input = dialoguer::Input::::new().with_prompt(prompt.to_string()); + if let Some(default) = default { + input = input.default(default.to_string()); + } + if allow_empty { + input = input.allow_empty(true); + } + Ok(input.interact_text()?.trim().to_string()) +} + +fn prompt_optional_text( + prompt: &str, + default: Option<&str>, +) -> Result, Box> { + let value = prompt_text(prompt, default, true)?; + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value)) + } +} + +fn prompt_secret(prompt: &str) -> Result, Box> { + let value = if io::stdin().is_terminal() { + Password::new() + .with_prompt(prompt) + .allow_empty_password(true) + .interact()? + } else { + prompt_text(prompt, None, true)? + }; + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed)) + } +} + +fn prompt_port(prompt: &str, default: u16) -> Result> { + let value = prompt_text(prompt, Some(&default.to_string()), false)?; + value + .parse::() + .map_err(|err| format!("Invalid port '{}': {}", value, err).into()) +} + +fn prompt_adapter_reference( + metadata: &PipeAdapterMetadata, +) -> Result<(PipeAdapterReference, Option), Box> { + let mut reference = PipeAdapterReference::new(metadata.code.clone()); + let role = metadata.roles.first().copied(); + if let Some(role) = role { + reference = reference.with_role(role); + } + + let mut config = serde_json::Map::new(); + let mut target_url = None; + match metadata.kind { + PipeAdapterKind::SmtpTarget => { + let host = prompt_text("SMTP host", None, false)?; + let port = prompt_port("SMTP port", 587)?; + let username = prompt_optional_text("SMTP username", None)?; + let password = prompt_secret("SMTP password")?; + let from = prompt_optional_text("SMTP from address", None)?; + let to = prompt_text("SMTP recipient address", None, false)?; + let tls = dialoguer::Confirm::new() + .with_prompt("Use TLS for SMTP") + .default(true) + .interact()?; + config.insert("host".to_string(), serde_json::json!(host)); + config.insert("port".to_string(), serde_json::json!(port)); + config.insert("to".to_string(), serde_json::json!([to])); + config.insert("tls".to_string(), serde_json::json!(tls)); + if let Some(username) = username { + config.insert("username".to_string(), serde_json::json!(username)); + } + if let Some(password) = password { + config.insert("password".to_string(), serde_json::json!(password)); + } + if let Some(from) = from { + config.insert("from".to_string(), serde_json::json!(from)); + } + } + PipeAdapterKind::ImapSource => { + let host = prompt_text("IMAP host", None, false)?; + let port = prompt_port("IMAP port", 993)?; + let username = prompt_text("IMAP username", None, false)?; + let password = prompt_secret("IMAP password")?; + let mailbox = prompt_text("IMAP mailbox", Some("INBOX"), false)?; + let tls = dialoguer::Confirm::new() + .with_prompt("Use TLS for IMAP") + .default(true) + .interact()?; + config.insert("host".to_string(), serde_json::json!(host)); + config.insert("port".to_string(), serde_json::json!(port)); + config.insert("username".to_string(), serde_json::json!(username)); + config.insert("mailbox".to_string(), serde_json::json!(mailbox)); + config.insert("tls".to_string(), serde_json::json!(tls)); + if let Some(password) = password { + config.insert("password".to_string(), serde_json::json!(password)); + } + } + PipeAdapterKind::Pop3Source => { + let host = prompt_text("POP3 host", None, false)?; + let port = prompt_port("POP3 port", 995)?; + let username = prompt_text("POP3 username", None, false)?; + let password = prompt_secret("POP3 password")?; + let tls = dialoguer::Confirm::new() + .with_prompt("Use TLS for POP3") + .default(true) + .interact()?; + config.insert("host".to_string(), serde_json::json!(host)); + config.insert("port".to_string(), serde_json::json!(port)); + config.insert("username".to_string(), serde_json::json!(username)); + config.insert("tls".to_string(), serde_json::json!(tls)); + if let Some(password) = password { + config.insert("password".to_string(), serde_json::json!(password)); + } + } + PipeAdapterKind::WebhookBridge => { + let url = prompt_text("Webhook URL", None, false)?; + target_url = Some(url.clone()); + config.insert("url".to_string(), serde_json::json!(url)); + } + PipeAdapterKind::HttpEndpoint | PipeAdapterKind::HtmlForm => {} + } + + if !config.is_empty() { + reference = reference.with_config(serde_json::Value::Object(config)); + } + + Ok((reference, target_url)) +} + +fn is_sensitive_adapter_key(key: &str) -> bool { + let lowered = key.trim().to_ascii_lowercase(); + lowered.contains("password") + || lowered.contains("secret") + || lowered.contains("token") + || lowered.contains("credential") + || lowered == "auth" + || lowered.ends_with("_auth") + || lowered.contains("api_key") + || lowered.ends_with("_key") +} + +fn redact_sensitive_json(value: &mut serde_json::Value) { + match value { + serde_json::Value::Array(values) => { + for item in values { + redact_sensitive_json(item); + } + } + serde_json::Value::Object(map) => { + for (key, value) in map.iter_mut() { + if is_sensitive_adapter_key(key) { + *value = serde_json::Value::String("[REDACTED]".to_string()); + } else { + redact_sensitive_json(value); + } + } + } + _ => {} + } +} + +fn scan_inspection_hint(name: &str, deploy_ctx: &DeploymentContext) -> String { + match deploy_ctx { + DeploymentContext::Local => { + format!( + "Run `stacker pipe scan --containers {}` to inspect discovery results.", + name + ) + } + DeploymentContext::Remote(_) => { + format!( + "Run `stacker pipe scan --app {}` to inspect discovery results.", + name + ) + } + } +} + +fn local_container_for_operation(operation: &SelectableOperation, fallback: &str) -> String { + operation + .container + .clone() + .unwrap_or_else(|| fallback.to_string()) +} + +fn operation_label(operation: &SelectableOperation) -> String { + let prefix = operation + .container + .as_ref() + .map(|container| format!("[{}] ", container)) + .unwrap_or_default(); + if operation.summary.is_empty() { + format!("{}{:>6} {}", prefix, operation.method, operation.path) + } else { + format!( + "{}{:>6} {} — {}", + prefix, operation.method, operation.path, operation.summary + ) + } +} + +fn operation_labels(operations: &[SelectableOperation]) -> Vec { operations.iter().map(operation_label).collect() } -fn explain_no_local_operations(name: &str, info: &AgentCommandInfo) -> String { +fn explain_no_selectable_operations( + name: &str, + info: &AgentCommandInfo, + deploy_ctx: &DeploymentContext, + role: &str, +) -> String { + let target_kind = decode_probe_report(info) + .ok() + .and_then(|report| report.target_kind) + .unwrap_or_else(|| { + if selector_is_smtpish(name) { + "smtp".to_string() + } else if result_has_resources(info) { + "resource".to_string() + } else { + "unknown".to_string() + } + }); + + if target_kind == "smtp" { + return format!( + "'{}' looks like an SMTP {}. `stacker pipe create` currently supports HTTP endpoints and HTML forms only.\nUse an HTTP-capable bridge/webhook target first, validate locally, then export remotely.\n{}", + name, + role, + scan_inspection_hint(name, deploy_ctx) + ); + } + if result_has_resources(info) { format!( - "Resources were discovered for '{}', but `pipe create` currently supports HTTP endpoints and HTML forms only.\nRun `stacker pipe scan --containers {}` to inspect the discovered resources.", - name, name + "Resources were discovered for '{}', but `pipe create` currently supports HTTP endpoints and HTML forms only.\n{}", + name, + scan_inspection_hint(name, deploy_ctx) ) } else { format!( - "No selectable HTTP endpoints or HTML forms were discovered for '{}'.\nRun `stacker pipe scan --containers {}` to inspect discovery results.", - name, name + "No selectable HTTP endpoints or HTML forms were discovered for '{}'.\n{}", + name, + scan_inspection_hint(name, deploy_ctx) ) } } +fn describe_discovery_run(run: &DiscoveryRun) -> String { + let report = decode_probe_report(&run.info).ok(); + let requested = report + .as_ref() + .map(|report| { + if report.protocols_requested.is_empty() { + "default".to_string() + } else { + report.protocols_requested.join(",") + } + }) + .unwrap_or_else(|| "default".to_string()); + let capture_samples = run + .info + .parameters + .as_ref() + .and_then(|parameters| parameters.get("capture_samples")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + + let source = match run.source { + DiscoverySource::Cached => "cached", + DiscoverySource::Fresh => "fresh", + DiscoverySource::Synthetic => "synthetic", + }; + + format!( + "{} result (protocols: {}, capture_samples: {})", + source, requested, capture_samples + ) +} + +fn prepare_local_discovery( + _project_dir: &Path, + request: &PipeDiscoveryRequest, +) -> Result> { + let selector = request.selector.selector.as_str(); + let mut report = build_local_probe_report( + selector, + &discover_local_containers(Some(selector))?, + &request.protocols_requested, + request.capture_samples, + ); + enrich_probe_report_metadata(&mut report, selector, None, request); + local_report_to_agent_info(&report, request.capture_samples) +} + +fn run_remote_probe( + ctx: &CliRuntime, + request: &PipeDiscoveryRequest, + description: &str, +) -> Result> { + let deployment_hash = request.selector.deployment_hash.as_deref().ok_or_else(|| { + CliError::ConfigValidation("Remote discovery requires a deployment hash".to_string()) + })?; + let params = crate::forms::status_panel::ProbeEndpointsCommandRequest { + app_code: request.selector.selector.clone(), + container: request.selector.container.clone(), + protocols: request.protocols_requested.clone(), + probe_timeout: 5, + capture_samples: request.capture_samples, + }; + + let agent_request = AgentEnqueueRequest::new(deployment_hash, "probe_endpoints") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; + let info = run_agent_command(ctx, &agent_request, description, PROBE_TIMEOUT_SECS)?; + if info.status != "completed" { + return Ok(info); + } + + let mut report = decode_probe_report(&info)?; + enrich_probe_report_metadata( + &mut report, + &request.selector.selector, + request.selector.container.as_deref(), + request, + ); + with_probe_report(info, &report).map_err(Into::into) +} + +fn synthetic_transport_target_report( + request: &PipeDiscoveryRequest, +) -> ProbeEndpointsCommandReport { + let deployment_hash = request + .selector + .deployment_hash + .clone() + .unwrap_or_else(|| "local".to_string()); + + ProbeEndpointsCommandReport { + command_type: "probe_endpoints".to_string(), + deployment_hash, + app_code: request.selector.selector.clone(), + protocols_detected: Vec::new(), + protocols_requested: request.protocols_requested.clone(), + containers: Vec::new(), + endpoints: Vec::new(), + resources: Vec::new(), + forms: Vec::new(), + probe_attempts: vec![ProbeAttempt { + scope: if request.selector.mode == "local" { + "local_selector".to_string() + } else { + "remote_app".to_string() + }, + selector: Some(request.selector.selector.clone()), + container: request.selector.container.clone(), + protocols: request.protocols_requested.clone(), + outcome: "skipped_transport_target".to_string(), + }], + target_kind: Some("smtp".to_string()), + probed_at: Utc::now().to_rfc3339(), + } +} + +fn synthetic_transport_target_discovery( + request: &PipeDiscoveryRequest, +) -> Result> { + let report = synthetic_transport_target_report(request); + let info = probe_report_to_agent_info(&report, request.capture_samples)?; + Ok(DiscoveryRun { + info, + source: DiscoverySource::Synthetic, + }) +} + +fn cache_probe_if_completed( + project_dir: &Path, + request: &PipeDiscoveryRequest, + info: &AgentCommandInfo, +) -> Result<(), Box> { + if info.status != "completed" { + return Ok(()); + } + let report = decode_probe_report(info)?; + write_pipe_scan_cache(project_dir, request, &report)?; + Ok(()) +} + +fn load_or_run_discovery( + project_dir: &Path, + request: &PipeDiscoveryRequest, + fresh_scan: F, +) -> Result> +where + F: FnOnce() -> Result>, +{ + if let Some(info) = load_exact_pipe_scan_cache(project_dir, request)? { + return Ok(DiscoveryRun { + info, + source: DiscoverySource::Cached, + }); + } + if let Some(info) = load_latest_pipe_scan_cache(project_dir, &request.selector)? { + return Ok(DiscoveryRun { + info, + source: DiscoverySource::Cached, + }); + } + + let info = fresh_scan()?; + cache_probe_if_completed(project_dir, request, &info)?; + Ok(DiscoveryRun { + info, + source: DiscoverySource::Fresh, + }) +} + #[cfg(test)] mod selectable_operation_tests { use super::*; use serde_json::json; + use tempfile::tempdir; #[test] fn extract_operations_includes_html_forms_and_container() { @@ -1879,134 +2810,390 @@ mod selectable_operation_tests { assert_eq!(ops[0].path, "/contact"); assert_eq!(ops[0].fields, vec!["name".to_string(), "email".to_string()]); } + + fn sample_report(protocols_requested: Vec) -> ProbeEndpointsCommandReport { + ProbeEndpointsCommandReport { + command_type: "probe_endpoints".to_string(), + deployment_hash: "local".to_string(), + app_code: "status-panel-web".to_string(), + protocols_detected: protocols_requested.clone(), + protocols_requested, + containers: vec![], + endpoints: vec![], + resources: vec![], + forms: vec![ProbeForm { + container: Some("status-panel-web".to_string()), + id: "contact".to_string(), + action: "/contact".to_string(), + method: "POST".to_string(), + fields: vec!["name".to_string(), "email".to_string()], + }], + probe_attempts: vec![ProbeAttempt { + scope: "local_selector".to_string(), + selector: Some("status-panel-web".to_string()), + container: Some("status-panel-web".to_string()), + protocols: vec!["html_forms".to_string()], + outcome: "detected".to_string(), + }], + target_kind: Some("html_form".to_string()), + probed_at: "2026-05-01T00:00:00Z".to_string(), + } + } + + #[test] + fn exact_cache_round_trip_reuses_discovery_result() { + let dir = tempdir().expect("temp dir"); + let request = + PipeDiscoveryRequest::local("status-panel-web", &["html_forms".to_string()], true); + let report = sample_report(vec!["html_forms".to_string()]); + + write_pipe_scan_cache(dir.path(), &request, &report).expect("cache write should succeed"); + let cached = load_exact_pipe_scan_cache(dir.path(), &request) + .expect("cache read should succeed") + .expect("exact cache entry should exist"); + + assert_eq!(cached.status, "completed"); + let cached_report = decode_probe_report(&cached).expect("cached report should decode"); + assert_eq!(cached_report.protocols_requested, vec!["html_forms"]); + assert_eq!(cached_report.forms.len(), 1); + } + + #[test] + fn selector_cache_preserves_narrow_protocol_scope_for_create() { + let dir = tempdir().expect("temp dir"); + let narrow_request = + PipeDiscoveryRequest::local("status-panel-web", &["html_forms".to_string()], true); + let report = sample_report(vec!["html_forms".to_string()]); + write_pipe_scan_cache(dir.path(), &narrow_request, &report) + .expect("cache write should succeed"); + + let default_request = + PipeDiscoveryRequest::local("status-panel-web", &default_pipe_create_protocols(), true); + let cached_run = load_or_run_discovery(dir.path(), &default_request, || { + panic!("fresh scan should not run when selector cache exists") + }) + .expect("selector cache should be reused"); + + assert_eq!(cached_run.source, DiscoverySource::Cached); + let cached_report = + decode_probe_report(&cached_run.info).expect("cached selector report should decode"); + assert_eq!(cached_report.protocols_requested, vec!["html_forms"]); + assert_eq!(cached_report.forms[0].action, "/contact"); + } + + #[test] + fn unsupported_smtp_target_returns_actionable_guidance() { + let info = AgentCommandInfo { + command_id: "cached-smtp".to_string(), + deployment_hash: "local".to_string(), + command_type: "probe_endpoints".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: Some(json!({ + "app_code": "smtp", + "protocols": ["html_forms"], + "capture_samples": true + })), + result: Some(json!({ + "type": "probe_endpoints", + "deployment_hash": "local", + "app_code": "smtp", + "protocols_detected": [], + "protocols_requested": ["html_forms"], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [], + "probe_attempts": [{ + "scope": "local_selector", + "selector": "smtp", + "protocols": ["html_forms"], + "outcome": "empty" + }], + "target_kind": "smtp", + "probed_at": "2026-05-01T00:00:00Z" + })), + error: None, + created_at: String::new(), + updated_at: String::new(), + }; + + let message = + explain_no_selectable_operations("smtp", &info, &DeploymentContext::Local, "target"); + + assert!(message.contains("SMTP target")); + assert!(message.contains("HTTP-capable bridge/webhook target")); + assert!(message.contains("stacker pipe scan --containers smtp")); + } + + #[test] + fn smtp_target_discovery_is_synthetic_for_remote_targets() { + let request = PipeDiscoveryRequest::remote( + "deployment-123", + "smtp", + None, + &default_pipe_create_protocols(), + true, + ); + + let run = synthetic_transport_target_discovery(&request) + .expect("smtp transport target should synthesize discovery"); + let report = decode_probe_report(&run.info).expect("synthetic report should decode"); + + assert_eq!(run.source, DiscoverySource::Synthetic); + assert_eq!(run.info.status, "completed"); + assert_eq!(report.app_code, "smtp"); + assert_eq!(report.target_kind.as_deref(), Some("smtp")); + assert!(report.endpoints.is_empty()); + assert!(report.forms.is_empty()); + assert_eq!(report.probe_attempts[0].outcome, "skipped_transport_target"); + } + + #[test] + fn describe_discovery_run_labels_synthetic_results() { + let request = PipeDiscoveryRequest::local("smtp", &default_pipe_create_protocols(), true); + let run = synthetic_transport_target_discovery(&request) + .expect("smtp transport target should synthesize discovery"); + + let description = describe_discovery_run(&run); + + assert!(description.contains("synthetic")); + assert!(description.contains("protocols:")); + } + + #[test] + fn builtin_adapter_detection_resolves_source_and_target_roles() { + let source = builtin_adapter_for_selector("imap", PipeAdapterRole::Source) + .expect("imap source adapter should resolve"); + let target = builtin_adapter_for_selector("mailhog", PipeAdapterRole::Target) + .expect("mailhog smtp target adapter should resolve"); + + assert_eq!(source.code, "imap"); + assert_eq!(source.kind, PipeAdapterKind::ImapSource); + assert_eq!(target.code, "mailhog"); + assert_eq!(target.kind, PipeAdapterKind::SmtpTarget); + } + + #[test] + fn synthetic_adapter_operation_exposes_expected_mail_fields() { + let metadata = builtin_adapter_for_selector("smtp", PipeAdapterRole::Target) + .expect("smtp target adapter should resolve"); + let operation = synthetic_adapter_operation(&metadata); + + assert!(operation.adapter.is_some()); + assert_eq!(operation.method, "SEND"); + assert_eq!(operation.path, "adapter:smtp"); + assert!(operation.fields.contains(&"subject".to_string())); + assert!(operation.fields.contains(&"body_text".to_string())); + } + + #[test] + fn redact_sensitive_json_masks_adapter_secrets() { + let mut value = serde_json::json!({ + "instance": { + "target_adapter": { + "code": "smtp", + "config": { + "host": "smtp.example.com", + "password": "supersecret", + "api_key": "key-123" + } + } + } + }); + + redact_sensitive_json(&mut value); + + assert_eq!( + value["instance"]["target_adapter"]["config"]["password"], + "[REDACTED]" + ); + assert_eq!( + value["instance"]["target_adapter"]["config"]["api_key"], + "[REDACTED]" + ); + assert_eq!( + value["instance"]["target_adapter"]["config"]["host"], + "smtp.example.com" + ); + } } impl CallableTrait for PipeCreateCommand { fn call(&self) -> Result<(), Box> { - let ctx = CliRuntime::new("pipe create")?; - let deploy_ctx = resolve_deployment_context(&self.deployment, &ctx)?; + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let (ctx, deploy_ctx) = + match resolve_local_deployment_context(&self.deployment, &project_dir)? { + Some(local_ctx) => (None, local_ctx), + None => { + let ctx = CliRuntime::new("pipe create")?; + let deploy_ctx = resolve_deployment_context(&self.deployment, &ctx)?; + (Some(ctx), deploy_ctx) + } + }; let prefix = mode_prefix(&deploy_ctx); let local_mode = deploy_ctx.is_local(); let hash = match &deploy_ctx { DeploymentContext::Remote(h) => Some(h.clone()), DeploymentContext::Local => None, }; + let create_protocols = default_pipe_create_protocols(); - let (source_info, target_info) = if local_mode { - println!( - "{}Scanning local source '{}' and target '{}'...", - prefix, self.source, self.target - ); - ( - build_local_scan_info( + 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() { + Some(if local_mode { + println!( + "{}Preparing local discovery for source '{}'...", + prefix, self.source + ); + let source_request = + PipeDiscoveryRequest::local(&self.source, &create_protocols, true); + load_or_run_discovery(&project_dir, &source_request, || { + prepare_local_discovery(&project_dir, &source_request) + })? + } else { + let ctx = ctx.as_ref().expect("remote runtime"); + let remote_hash = hash.clone().expect("remote hash"); + println!("Preparing discovery for source app '{}'...", self.source); + let source_request = PipeDiscoveryRequest::remote( + &remote_hash, &self.source, - &[ - "openapi".to_string(), - "html_forms".to_string(), - "rest".to_string(), - "graphql".to_string(), - ], + None, + &create_protocols, true, - )?, - build_local_scan_info( + ); + load_or_run_discovery(&project_dir, &source_request, || { + run_remote_probe( + &ctx, + &source_request, + &format!("Scanning source: {}", self.source), + ) + })? + }) + } else { + None + }; + let target_run = if target_adapter_meta.is_none() { + Some(if local_mode { + println!( + "{}Preparing local discovery for target '{}'...", + prefix, self.target + ); + let target_request = + PipeDiscoveryRequest::local(&self.target, &create_protocols, true); + load_or_run_discovery(&project_dir, &target_request, || { + prepare_local_discovery(&project_dir, &target_request) + })? + } else { + let ctx = ctx.as_ref().expect("remote runtime"); + let remote_hash = hash.clone().expect("remote hash"); + println!("Preparing discovery for target app '{}'...", self.target); + let target_request = PipeDiscoveryRequest::remote( + &remote_hash, &self.target, - &[ - "openapi".to_string(), - "html_forms".to_string(), - "rest".to_string(), - "graphql".to_string(), - ], + None, + &create_protocols, true, - )?, - ) + ); + load_or_run_discovery(&project_dir, &target_request, || { + run_remote_probe( + &ctx, + &target_request, + &format!("Scanning target: {}", self.target), + ) + })? + }) } else { - let hash = hash.clone().expect("remote hash"); + None + }; + + if let Some(run) = &source_run { + println!(" Source discovery: {}", describe_discovery_run(run)); + } else if let Some(metadata) = &source_adapter_meta { println!( - "Scanning source app '{}' and target app '{}'...", - self.source, self.target + " Source adapter: {} ({})", + metadata.display_name, metadata.code ); + } + if let Some(run) = &target_run { + println!(" Target discovery: {}", describe_discovery_run(run)); + } else if let Some(metadata) = &target_adapter_meta { + println!( + " Target adapter: {} ({})", + metadata.display_name, metadata.code + ); + } - let source_params = crate::forms::status_panel::ProbeEndpointsCommandRequest { - app_code: self.source.clone(), - container: None, - protocols: vec![ - "openapi".to_string(), - "html_forms".to_string(), - "rest".to_string(), - ], - probe_timeout: 5, - capture_samples: true, - }; - - let source_request = AgentEnqueueRequest::new(&hash, "probe_endpoints") - .with_parameters(&source_params) - .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; - - let source_info = run_agent_command( - &ctx, - &source_request, - &format!("Scanning source: {}", self.source), - PROBE_TIMEOUT_SECS, - )?; - - let target_params = crate::forms::status_panel::ProbeEndpointsCommandRequest { - app_code: self.target.clone(), - container: None, - protocols: vec![ - "openapi".to_string(), - "html_forms".to_string(), - "rest".to_string(), - ], - probe_timeout: 5, - capture_samples: true, - }; - - let target_request = AgentEnqueueRequest::new(&hash, "probe_endpoints") - .with_parameters(&target_params) - .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; - - let target_info = run_agent_command( - &ctx, - &target_request, - &format!("Scanning target: {}", self.target), - PROBE_TIMEOUT_SECS, - )?; - - (source_info, target_info) - }; - - if source_info.status != "completed" || target_info.status != "completed" { + if source_run + .as_ref() + .is_some_and(|run| run.info.status != "completed") + || target_run + .as_ref() + .is_some_and(|run| run.info.status != "completed") + { eprintln!("Scan failed for one or both apps. Cannot create pipe."); - if source_info.status != "completed" { - eprintln!(" Source '{}': {}", self.source, source_info.status); + if let Some(run) = &source_run { + if run.info.status != "completed" { + eprintln!(" Source '{}': {}", self.source, run.info.status); + } } - if target_info.status != "completed" { - eprintln!(" Target '{}': {}", self.target, target_info.status); + if let Some(run) = &target_run { + if run.info.status != "completed" { + eprintln!(" Target '{}': {}", self.target, run.info.status); + } } return Ok(()); } // Step 2: Extract discovered endpoints - let source_ops = extract_operations(&source_info); - let target_ops = extract_operations(&target_info); + let source_ops = 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 { + vec![synthetic_adapter_operation(metadata)] + } else { + extract_operations(&target_run.as_ref().expect("target discovery").info) + }; if source_ops.is_empty() { eprintln!( "{}", - explain_no_local_operations(&self.source, &source_info) + explain_no_selectable_operations( + &self.source, + &source_run.as_ref().expect("source discovery").info, + &deploy_ctx, + "source", + ) ); return Ok(()); } if target_ops.is_empty() { eprintln!( "{}", - explain_no_local_operations(&self.target, &target_info) + explain_no_selectable_operations( + &self.target, + &target_run.as_ref().expect("target discovery").info, + &deploy_ctx, + "target", + ) ); return Ok(()); } // Step 3: Let user select source endpoint - let source_idx = { + let source_idx = if source_ops.len() == 1 { + println!( + "\n Using source {}", + operation_label(source_ops.first().expect("single source op")) + ); + 0 + } else { let source_labels = operation_labels(&source_ops); println!("\n Select source endpoint (data comes FROM here):"); dialoguer::Select::new() @@ -2021,7 +3208,13 @@ impl CallableTrait for PipeCreateCommand { let src_sample = &src_op.sample; // Step 4: Let user select target endpoint - let target_idx = { + let target_idx = if target_ops.len() == 1 { + println!( + "\n Using target {}", + operation_label(target_ops.first().expect("single target op")) + ); + 0 + } else { let target_labels = operation_labels(&target_ops); println!("\n Select target endpoint (data goes TO here):"); dialoguer::Select::new() @@ -2161,21 +3354,139 @@ impl CallableTrait for PipeCreateCommand { src_method, src_path, tgt_method, tgt_path )), source_app_type: self.source.clone(), - source_endpoint: serde_json::json!({ - "path": src_path, - "method": src_method, - }), + source_endpoint: template_endpoint_for_operation(src_op), target_app_type: self.target.clone(), - target_endpoint: serde_json::json!({ - "path": tgt_path, - "method": tgt_method, - }), + target_endpoint: template_endpoint_for_operation(tgt_op), target_external_url: None, field_mapping: field_mapping.clone(), config: Some(config), is_public: Some(false), }; + let source_container_name = if let Some(adapter) = &src_op.adapter { + adapter.code.clone() + } else if local_mode { + local_container_for_operation(src_op, &self.source) + } else { + self.source.clone() + }; + let local_target_container_name = if tgt_op.adapter.is_some() { + None + } else if local_mode { + Some(local_container_for_operation(tgt_op, &self.target)) + } else { + Some(self.target.clone()) + }; + let (source_adapter, _) = if let Some(metadata) = &src_op.adapter { + prompt_adapter_reference(metadata)? + } else { + (PipeAdapterReference::new(""), None) + }; + let source_adapter = src_op.adapter.as_ref().map(|_| source_adapter); + let (target_adapter, adapter_target_url) = if let Some(metadata) = &tgt_op.adapter { + prompt_adapter_reference(metadata)? + } else { + (PipeAdapterReference::new(""), None) + }; + let target_adapter = tgt_op.adapter.as_ref().map(|_| target_adapter); + let target_url = if target_adapter.is_some() { + adapter_target_url + } else { + None + }; + + if local_mode { + let store = LocalPipeStore::new(&project_dir); + let mut notes = Vec::new(); + if let Some(run) = &source_run { + notes.push(format!("source discovery: {}", describe_discovery_run(run))); + } + if let Some(run) = &target_run { + notes.push(format!("target discovery: {}", describe_discovery_run(run))); + } + + let local_pipe = LocalPipeDocument::draft(NewLocalPipeDocument { + name: pipe_name.clone(), + source: LocalPipeBinding { + selector: self.source.clone(), + container: src_op.container.clone(), + adapter: source_adapter.clone(), + method: src_method.clone(), + path: src_path.clone(), + fields: src_fields.clone(), + }, + target: LocalPipeBinding { + selector: self.target.clone(), + container: tgt_op.container.clone(), + adapter: target_adapter.clone(), + method: tgt_method.clone(), + path: tgt_path.clone(), + fields: tgt_fields.clone(), + }, + template: LocalPipeTemplate { + description: template_request.description.clone(), + source_app_type: template_request.source_app_type.clone(), + source_endpoint: template_request.source_endpoint.clone(), + target_app_type: template_request.target_app_type.clone(), + target_endpoint: template_request.target_endpoint.clone(), + target_external_url: template_request.target_external_url.clone(), + field_mapping: template_request.field_mapping.clone(), + config: template_request.config.clone(), + is_public: template_request.is_public.unwrap_or(false), + }, + instance: LocalPipeInstance { + source_adapter: source_adapter.clone(), + source_container: source_container_name.clone(), + target_adapter: target_adapter.clone(), + target_container: local_target_container_name.clone(), + target_url: target_url.clone(), + field_mapping_override: None, + config_override: None, + trigger_count: 0, + error_count: 0, + last_triggered_at: None, + }, + diagnostics: LocalPipeDiagnostics { notes }, + })?; + let path = store.save_new(&local_pipe)?; + + if self.json { + let mut output = serde_json::json!({ + "local": true, + "path": path.display().to_string(), + "pipe": local_pipe, + }); + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!( + "\n {}✓ Local pipe '{}' created successfully", + prefix, pipe_name + ); + println!(" Local ID: {}", local_pipe.id); + println!(" File: {}", path.display()); + println!( + " Source: {} ({})", + local_pipe.source_display(), + src_path + ); + println!( + " Target: {} ({})", + local_pipe.target_display(), + tgt_path + ); + println!(" Status: {}", local_pipe.status); + println!(" Mapping: {}", serde_json::to_string(&field_mapping)?); + println!( + " Promote: stacker pipe deploy {} ", + local_pipe.id + ); + } + + return Ok(()); + } + + let ctx = ctx.as_ref().expect("remote runtime"); let pb = progress::spinner("Creating pipe template..."); let template = ctx .block_on(ctx.client.create_pipe_template(&template_request)) @@ -2185,23 +3496,13 @@ impl CallableTrait for PipeCreateCommand { })?; progress::finish_success(&pb, "Template created"); - let source_container_name = if local_mode { - local_container_for_operation(src_op, &self.source) - } else { - self.source.clone() - }; - let target_container_name = if local_mode { - local_container_for_operation(tgt_op, &self.target) - } else { - self.target.clone() - }; - - // Step 8: Create instance linked to this deployment let instance_request = CreatePipeInstanceApiRequest { deployment_hash: hash.clone(), + source_adapter, source_container: source_container_name.clone(), - target_container: Some(target_container_name.clone()), - target_url: None, + target_adapter, + target_container: local_target_container_name.clone(), + target_url: target_url.clone(), template_id: Some(template.id.clone()), field_mapping_override: None, config_override: None, @@ -2217,28 +3518,31 @@ impl CallableTrait for PipeCreateCommand { progress::finish_success(&pb, "Pipe instance created"); if self.json { - let output = serde_json::json!({ + let mut output = serde_json::json!({ "template": template, "instance": instance, - "local": local_mode, + "local": false, }); + redact_sensitive_json(&mut output); println!("{}", serde_json::to_string_pretty(&output)?); } else { - if local_mode { - println!( - "\n {}✓ Local pipe '{}' created successfully", - prefix, pipe_name - ); - } else { - println!("\n ✓ Pipe '{}' created successfully", pipe_name); - } + println!("\n ✓ Pipe '{}' created successfully", pipe_name); println!(" Template ID: {}", template.id); println!(" Instance ID: {}", instance.id); - println!(" Source: {} ({})", source_container_name, src_path); - println!(" Target: {} ({})", target_container_name, tgt_path); - if local_mode { - println!(" Mode: local (no deployment required)"); - } + let source_display = src_op + .adapter + .as_ref() + .map(|metadata| format!("{} adapter", metadata.code)) + .unwrap_or_else(|| source_container_name.clone()); + let target_display = tgt_op + .adapter + .as_ref() + .map(|metadata| format!("{} adapter", metadata.code)) + .or_else(|| local_target_container_name.clone()) + .or(target_url.clone()) + .unwrap_or_else(|| self.target.clone()); + println!(" Source: {} ({})", source_display, src_path); + println!(" Target: {} ({})", target_display, tgt_path); println!( " Status: {} (use 'stacker pipe activate {}' to start)", instance.status, instance.id @@ -2267,25 +3571,81 @@ impl PipeListCommand { impl CallableTrait for PipeListCommand { fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + if let Some(deploy_ctx) = resolve_local_deployment_context(&self.deployment, &project_dir)? + { + let prefix = mode_prefix(&deploy_ctx); + let pb = progress::spinner(&format!("{}Fetching pipes...", prefix)); + let store = LocalPipeStore::new(&project_dir); + let pipes = store.list().map_err(|e| { + progress::finish_error(&pb, "Failed to fetch local pipes"); + e + })?; + progress::finish_success(&pb, &format!("{}{} pipe(s) found", prefix, pipes.len())); + + if pipes.is_empty() { + println!("No pipes configured for this deployment."); + println!("Use 'stacker pipe create ' to create a pipe."); + return Ok(()); + } + + if self.json { + let mut output = serde_json::to_value(&pipes)?; + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + + println!( + "\n{:<38} {:<15} {:<15} {:<10} {:>8} {:>8} {}", + "ID", "SOURCE", "TARGET", "STATUS", "TRIGGERS", "ERRORS", "LAST TRIGGERED" + ); + println!("{}", "─".repeat(120)); + + for pipe in &pipes { + let last = pipe + .instance + .last_triggered_at + .as_deref() + .unwrap_or("never"); + let status_icon = match pipe.status.as_str() { + "active" => "● active", + "paused" => "◉ paused", + "error" => "✗ error", + _ => "○ draft", + }; + + println!( + "{:<38} {:<15} {:<15} {:<10} {:>8} {:>8} {}", + &pipe.id, + truncate_str(pipe.source_display(), 14), + truncate_str(pipe.target_display(), 14), + status_icon, + pipe.instance.trigger_count, + pipe.instance.error_count, + last, + ); + } + + println!("\n{} pipe(s) total.", pipes.len()); + return Ok(()); + } + let ctx = CliRuntime::new("pipe list")?; let deploy_ctx = resolve_deployment_context(&self.deployment, &ctx)?; let prefix = mode_prefix(&deploy_ctx); + let hash = match &deploy_ctx { + DeploymentContext::Remote(hash) => hash, + DeploymentContext::Local => unreachable!("local mode handled above"), + }; let pb = progress::spinner(&format!("{}Fetching pipes...", prefix)); - let pipes = match &deploy_ctx { - DeploymentContext::Local => ctx - .block_on(ctx.client.list_local_pipe_instances()) - .map_err(|e| { - progress::finish_error(&pb, "Failed to fetch local pipes"); - e - })?, - DeploymentContext::Remote(hash) => ctx - .block_on(ctx.client.list_pipe_instances(hash)) - .map_err(|e| { + let pipes = ctx + .block_on(ctx.client.list_pipe_instances(hash)) + .map_err(|e| { progress::finish_error(&pb, "Failed to fetch pipes"); e - })?, - }; + })?; progress::finish_success(&pb, &format!("{}{} pipe(s) found", prefix, pipes.len())); if pipes.is_empty() { @@ -2295,11 +3655,12 @@ impl CallableTrait for PipeListCommand { } if self.json { - println!("{}", serde_json::to_string_pretty(&pipes)?); + let mut output = serde_json::to_value(&pipes)?; + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); return Ok(()); } - // Table header println!( "\n{:<38} {:<15} {:<15} {:<10} {:>8} {:>8} {}", "ID", "SOURCE", "TARGET", "STATUS", "TRIGGERS", "ERRORS", "LAST TRIGGERED" @@ -2307,9 +3668,16 @@ impl CallableTrait for PipeListCommand { println!("{}", "─".repeat(120)); for pipe in &pipes { + let source = pipe + .source_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()) + .unwrap_or(&pipe.source_container); let target = pipe - .target_container - .as_deref() + .target_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()) + .or(pipe.target_container.as_deref()) .or(pipe.target_url.as_deref()) .unwrap_or("-"); let last = pipe.last_triggered_at.as_deref().unwrap_or("never"); @@ -2323,7 +3691,7 @@ impl CallableTrait for PipeListCommand { println!( "{:<38} {:<15} {:<15} {:<10} {:>8} {:>8} {}", &pipe.id, - truncate_str(&pipe.source_container, 14), + truncate_str(source, 14), truncate_str(target, 14), status_icon, pipe.trigger_count, @@ -2345,6 +3713,42 @@ fn truncate_str(s: &str, max: usize) -> String { } } +fn pipe_template_matches_request( + existing: &CreatePipeTemplateApiRequest, + candidate: &PipeTemplateInfo, +) -> bool { + candidate.description == existing.description + && candidate.source_app_type == existing.source_app_type + && candidate.source_endpoint == existing.source_endpoint + && candidate.target_app_type == existing.target_app_type + && candidate.target_endpoint == existing.target_endpoint + && candidate.target_external_url == existing.target_external_url + && candidate.field_mapping == existing.field_mapping + && candidate.config == existing.config + && candidate.is_public.unwrap_or(false) == existing.is_public.unwrap_or(false) +} + +fn find_reusable_pipe_template( + templates: &[PipeTemplateInfo], + request: &CreatePipeTemplateApiRequest, +) -> Result, CliError> { + let Some(existing) = templates + .iter() + .find(|template| template.name == request.name) + else { + return Ok(None); + }; + + if pipe_template_matches_request(request, existing) { + Ok(Some(existing.clone())) + } else { + Err(CliError::ConfigValidation(format!( + "Remote pipe template '{}' already exists with a different definition. Rename the local pipe or update the remote template before deploying.", + request.name + ))) + } +} + /// Select the appropriate field matcher based on CLI flags and stacker.yml config. /// /// Priority: @@ -2412,6 +3816,309 @@ fn select_field_matcher( Box::new(DeterministicFieldMatcher) } +fn example_local_trigger_command(pipe_id: &str) -> String { + format!( + "stacker pipe trigger {} --data '{}'", + pipe_id, + r#"{"email":"person@example.com","subject":"Local pipe test","message":"Hello from the local contact form"}"# + ) +} + +fn source_field_names(source_data: &serde_json::Value) -> Vec { + source_data + .as_object() + .map(|object| object.keys().cloned().collect()) + .unwrap_or_default() +} + +fn lookup_json_path<'a>( + source_data: &'a serde_json::Value, + expression: &str, +) -> Option<&'a serde_json::Value> { + if expression == "$" { + return Some(source_data); + } + + let mut current = source_data; + for segment in expression.strip_prefix("$.")?.split('.') { + if segment.is_empty() { + return None; + } + current = current.get(segment)?; + } + + Some(current) +} + +fn apply_field_mapping( + source_data: &serde_json::Value, + mapping: &serde_json::Value, +) -> serde_json::Value { + let mut output = serde_json::Map::new(); + + if let Some(mapping_object) = mapping.as_object() { + for (target_field, expression) in mapping_object { + if let Some(path) = expression.as_str() { + if let Some(value) = lookup_json_path(source_data, path) { + output.insert(target_field.clone(), value.clone()); + } + } + } + } + + serde_json::Value::Object(output) +} + +fn infer_local_mapping( + pipe: &LocalPipeDocument, + source_data: &serde_json::Value, +) -> Option { + let source_fields = source_field_names(source_data); + if source_fields.is_empty() || pipe.target.fields.is_empty() { + return None; + } + + let matcher = select_field_matcher(false, false, false); + let result = matcher.match_fields(&source_fields, &pipe.target.fields, Some(source_data)); + let mapping = result.mapping.as_object()?; + if mapping.is_empty() { + return None; + } + + Some(apply_field_mapping(source_data, &result.mapping)) +} + +fn apply_smtp_defaults(source_data: &serde_json::Value, payload: &mut serde_json::Value) { + let Some(source_object) = source_data.as_object() else { + return; + }; + let Some(payload_object) = payload.as_object_mut() else { + return; + }; + + if !payload_object.contains_key("subject") { + if let Some(subject) = source_object.get("subject") { + payload_object.insert("subject".to_string(), subject.clone()); + } + } + + if !payload_object.contains_key("from_email") { + if let Some(email) = source_object.get("email") { + payload_object.insert("from_email".to_string(), email.clone()); + } + } + + if !payload_object.contains_key("reply_to_email") { + if let Some(email) = source_object.get("email") { + payload_object.insert("reply_to_email".to_string(), email.clone()); + } + } + + if !payload_object.contains_key("body_text") { + if let Some(message) = source_object.get("message") { + payload_object.insert("body_text".to_string(), message.clone()); + } + } +} + +fn build_local_trigger_payload( + pipe: &LocalPipeDocument, + source_data: &serde_json::Value, +) -> serde_json::Value { + let mut payload = if pipe + .effective_field_mapping() + .as_object() + .map(|mapping| !mapping.is_empty()) + .unwrap_or(false) + { + apply_field_mapping(source_data, pipe.effective_field_mapping()) + } else { + infer_local_mapping(pipe, source_data).unwrap_or_else(|| source_data.clone()) + }; + + if pipe + .instance + .target_adapter + .as_ref() + .map(|adapter| adapter.code == "smtp") + .unwrap_or(false) + { + apply_smtp_defaults(source_data, &mut payload); + } + + payload +} + +fn is_loopback_host(host: &str) -> bool { + matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]") +} + +fn local_target_aliases(container: &LocalContainerInfo) -> Vec<&str> { + let mut aliases = vec![container.name.as_str()]; + if let Some(service) = container.labels.get("com.docker.compose.service") { + aliases.push(service.as_str()); + } + aliases +} + +fn matches_local_target_alias(container: &LocalContainerInfo, candidate: &str) -> bool { + let candidate = candidate.trim(); + if candidate.is_empty() { + return false; + } + local_target_aliases(container).into_iter().any(|alias| { + alias.eq_ignore_ascii_case(candidate) + || alias + .to_ascii_lowercase() + .contains(&candidate.to_ascii_lowercase()) + }) +} + +fn find_local_target_container( + pipe: &LocalPipeDocument, + configured_host: Option<&str>, +) -> Result, CliError> { + let explicit_container = pipe + .instance + .target_container + .as_deref() + .or(pipe.target.container.as_deref()); + let selector = pipe.target.selector.as_str(); + let containers = discover_local_containers(None).map_err(|error| { + CliError::ConfigValidation(format!( + "Failed to inspect local Docker containers for pipe '{}': {}", + pipe.id, error + )) + })?; + + Ok(containers.into_iter().find(|container| { + explicit_container + .map(|name| matches_local_target_alias(container, name)) + .unwrap_or(false) + || matches_local_target_alias(container, selector) + || configured_host + .filter(|host| !is_loopback_host(host)) + .map(|host| matches_local_target_alias(container, host)) + .unwrap_or(false) + })) +} + +fn remap_smtp_target_reference_for_container( + mut target_adapter: PipeAdapterReference, + target_selector: &str, + container: &LocalContainerInfo, +) -> Result { + let Some(config) = target_adapter + .config + .as_mut() + .and_then(|value| value.as_object_mut()) + else { + return Ok(target_adapter); + }; + + let configured_host = config + .get("host") + .and_then(|value| value.as_str()) + .map(str::to_string); + let configured_port = config + .get("port") + .and_then(|value| value.as_u64()) + .and_then(|value| u16::try_from(value).ok()) + .unwrap_or(587); + + if let Some(binding) = container.ports.iter().find(|port| { + port.protocol == "tcp" + && (port.container_port == configured_port || port.host_port == Some(configured_port)) + }) { + if let Some(host_port) = binding.host_port { + config.insert( + "host".to_string(), + serde_json::json!(docker_host_target(binding.host_ip.as_deref())), + ); + config.insert("port".to_string(), serde_json::json!(host_port)); + return Ok(target_adapter); + } + } + + if configured_host + .as_deref() + .map(is_loopback_host) + .unwrap_or(false) + { + return Err(CliError::ConfigValidation(format!( + "Local SMTP target '{}' is configured for {}:{}, but the matching container '{}' does not publish that port to the host.\n\ + Publish the SMTP port in Docker Compose / stacker.yml or recreate the pipe with a host-reachable endpoint.", + target_selector, + configured_host.unwrap_or_else(|| "localhost".to_string()), + configured_port, + container.name + ))); + } + + if let Some(address) = container.addresses.first() { + config.insert("host".to_string(), serde_json::json!(address)); + return Ok(target_adapter); + } + + Ok(target_adapter) +} + +fn normalize_local_smtp_target_reference( + pipe: &LocalPipeDocument, + target_adapter: PipeAdapterReference, +) -> Result { + let configured_host = target_adapter + .config + .as_ref() + .and_then(|value| value.get("host")) + .and_then(|value| value.as_str()) + .map(str::to_string); + + let Some(container) = find_local_target_container(pipe, configured_host.as_deref())? else { + return Ok(target_adapter); + }; + + remap_smtp_target_reference_for_container(target_adapter, &pipe.target.selector, &container) +} + +async fn run_local_target_adapter( + pipe: &LocalPipeDocument, + payload: serde_json::Value, +) -> Result { + let target_adapter = pipe.instance.target_adapter.clone().ok_or_else(|| { + CliError::ConfigValidation(format!( + "Local trigger requires a target adapter. Pipe '{}' does not have one.", + pipe.id + )) + })?; + + match target_adapter.code.as_str() { + "smtp" => { + let resolved_adapter = normalize_local_smtp_target_reference(pipe, target_adapter)?; + let adapter = SmtpTargetAdapter::from_reference(resolved_adapter).map_err(|error| { + CliError::ConfigValidation(format!( + "Invalid SMTP adapter configuration for local pipe '{}': {}", + pipe.id, error + )) + })?; + + adapter + .deliver(PipeAdapterPayload::Json(payload)) + .await + .map_err(|error| { + CliError::ConfigValidation(format!( + "Local SMTP delivery failed for pipe '{}': {}", + pipe.id, error + )) + }) + } + other => Err(CliError::ConfigValidation(format!( + "Local trigger currently supports only the smtp target adapter. Pipe '{}' targets '{}'.", + pipe.id, other + ))), + } +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // stacker pipe activate — activate a pipe instance // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -2444,8 +4151,39 @@ impl PipeActivateCommand { impl CallableTrait for PipeActivateCommand { fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + if let Some(deploy_ctx) = resolve_local_deployment_context(&self.deployment, &project_dir)? + { + let prefix = mode_prefix(&deploy_ctx); + let store = LocalPipeStore::new(&project_dir); + let mut pipe = store.resolve(&self.pipe_id)?; + pipe.set_status("active"); + let local_path = store.save(&pipe)?; + + if self.json { + let mut output = serde_json::json!({ + "local": true, + "pipe": pipe, + "file": local_path, + "note": "Local activate marks the pipe active in .stacker/pipes. Use pipe trigger for one-shot execution." + }); + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + + println!("\n {}✓ Local pipe '{}' marked active", prefix, pipe.id); + println!(" File: {}", local_path.display()); + println!( + " Note: local background listeners are not implemented yet for file-backed pipes." + ); + println!(" Test now: {}", example_local_trigger_command(&pipe.id)); + return Ok(()); + } + let ctx = CliRuntime::new("pipe activate")?; let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + ensure_remote_pipe_command_capability(&ctx, &hash)?; // Fetch pipe instance details to get source/target info let pb = progress::spinner("Fetching pipe details..."); @@ -2519,11 +4257,13 @@ impl CallableTrait for PipeActivateCommand { // 2. Send activate_pipe command to agent let params = serde_json::json!({ "pipe_instance_id": self.pipe_id, - "source_container": pipe.source_container, + "source_adapter": pipe.source_adapter.clone(), + "source_container": pipe.source_container.clone(), "source_endpoint": source_endpoint, "source_method": source_method, - "target_container": pipe.target_container, - "target_url": pipe.target_url, + "target_adapter": pipe.target_adapter.clone(), + "target_container": pipe.target_container.clone(), + "target_url": pipe.target_url.clone(), "target_endpoint": target_endpoint, "target_method": target_method, "field_mapping": field_mapping, @@ -2576,8 +4316,34 @@ impl PipeDeactivateCommand { impl CallableTrait for PipeDeactivateCommand { fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + if let Some(deploy_ctx) = resolve_local_deployment_context(&self.deployment, &project_dir)? + { + let prefix = mode_prefix(&deploy_ctx); + let store = LocalPipeStore::new(&project_dir); + let mut pipe = store.resolve(&self.pipe_id)?; + pipe.set_status("paused"); + let local_path = store.save(&pipe)?; + + if self.json { + let mut output = serde_json::json!({ + "local": true, + "pipe": pipe, + "file": local_path + }); + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + + println!("\n {}✓ Local pipe '{}' paused", prefix, pipe.id); + println!(" File: {}", local_path.display()); + return Ok(()); + } + let ctx = CliRuntime::new("pipe deactivate")?; let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + ensure_remote_pipe_command_capability(&ctx, &hash)?; // 1. Update status to "paused" via API let pb = progress::spinner("Setting pipe status to paused..."); @@ -2642,8 +4408,15 @@ impl PipeTriggerCommand { impl CallableTrait for PipeTriggerCommand { fn call(&self) -> Result<(), Box> { - let ctx = CliRuntime::new("pipe trigger")?; - let deploy_ctx = resolve_deployment_context(&self.deployment, &ctx)?; + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let deploy_ctx = if let Some(local_ctx) = + resolve_local_deployment_context(&self.deployment, &project_dir)? + { + local_ctx + } else { + let ctx = CliRuntime::new("pipe trigger")?; + resolve_deployment_context(&self.deployment, &ctx)? + }; let prefix = mode_prefix(&deploy_ctx); let input_data = match &self.data { @@ -2655,93 +4428,74 @@ impl CallableTrait for PipeTriggerCommand { None => None, }; - // Local mode: execute locally via docker exec if deploy_ctx.is_local() { + let store = LocalPipeStore::new(&project_dir); + let mut pipe = store.resolve(&self.pipe_id)?; + let source_data = input_data.ok_or_else(|| { + CliError::ConfigValidation(format!( + "Local trigger requires --data '' for now.\nExample: {}", + example_local_trigger_command(&pipe.id) + )) + })?; + let effective_payload = build_local_trigger_payload(&pipe, &source_data); let pb = progress::spinner(&format!( "{}Triggering pipe '{}' locally...", prefix, self.pipe_id )); - - // Fetch the pipe instance to get source/target info - let instance = ctx - .block_on(ctx.client.get_pipe_instance(&self.pipe_id)) - .map_err(|e| { - progress::finish_error(&pb, "Failed to fetch pipe instance"); - e - })? - .ok_or_else(|| { - progress::finish_error(&pb, "Pipe not found"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| { + progress::finish_error(&pb, "Local runtime initialization failed"); CliError::ConfigValidation(format!( - "Pipe instance '{}' not found", - self.pipe_id + "Cannot start local pipe runtime: {}", + error )) })?; - // For local trigger, we attempt docker exec on the source container - let data_json = input_data - .as_ref() - .map(|d| d.to_string()) - .unwrap_or_else(|| "{}".to_string()); - - let output = std::process::Command::new("docker") - .args([ - "exec", - &instance.source_container, - "curl", - "-s", - "-X", - "POST", - "-H", - "Content-Type: application/json", - "-d", - &data_json, - "http://localhost:80/", - ]) - .output(); - - match output { - Ok(o) if o.status.success() => { + match runtime.block_on(run_local_target_adapter(&pipe, effective_payload.clone())) { + Ok(result) => { + pipe.record_trigger_success(); + let local_path = store.save(&pipe)?; progress::finish_success(&pb, &format!("{}Pipe triggered locally", prefix)); - let stdout = String::from_utf8_lossy(&o.stdout); if self.json { + let mut output = serde_json::json!({ + "status": "completed", + "local": true, + "pipe_id": pipe.id, + "file": local_path, + "input_data": source_data, + "effective_payload": effective_payload, + "result": result, + }); + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!("\n {}✓ Pipe '{}' triggered locally", prefix, pipe.id); + println!(" File: {}", local_path.display()); println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "completed", - "local": true, - "pipe_id": self.pipe_id, - "output": stdout.trim(), - }))? + " Trigger count: {} Errors: {}", + pipe.instance.trigger_count, pipe.instance.error_count ); - } else { - println!("\n {}✓ Pipe '{}' triggered locally", prefix, self.pipe_id); - if !stdout.trim().is_empty() { - println!(" Output: {}", stdout.trim()); - } } } - Ok(o) => { + Err(error) => { + pipe.record_trigger_failure(); + let _ = store.save(&pipe); progress::finish_error(&pb, "Local trigger failed"); - let stderr = String::from_utf8_lossy(&o.stderr); - eprintln!( - "{}Docker exec failed on container '{}': {}", - prefix, instance.source_container, stderr - ); - } - Err(e) => { - progress::finish_error(&pb, "Docker not available"); - eprintln!("Cannot run docker exec: {}", e); + return Err(Box::new(error)); } } return Ok(()); } - // Remote mode + let ctx = CliRuntime::new("pipe trigger")?; let hash = match &deploy_ctx { DeploymentContext::Remote(h) => h.clone(), _ => unreachable!(), }; + ensure_remote_pipe_command_capability(&ctx, &hash)?; let params = serde_json::json!({ "pipe_instance_id": self.pipe_id, @@ -2936,35 +4690,85 @@ impl PipeDeployCommand { impl CallableTrait for PipeDeployCommand { fn call(&self) -> Result<(), Box> { let ctx = CliRuntime::new("pipe deploy")?; - - let pb = progress::spinner(&format!( - "Deploying local pipe {} → {}...", - &self.instance_id, &self.deployment_hash + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let store = LocalPipeStore::new(&project_dir); + let local_pipe = store.resolve(&self.instance_id)?; + let template_request = local_pipe.to_template_request(); + + let template_pb = progress::spinner(&format!( + "Resolving remote template for {} → {}...", + &local_pipe.id, &self.deployment_hash )); + let templates = ctx + .block_on(ctx.client.list_pipe_templates( + Some(&template_request.source_app_type), + Some(&template_request.target_app_type), + )) + .map_err(|e| { + progress::finish_error(&template_pb, "Template lookup failed"); + e + })?; + let template = + match find_reusable_pipe_template(&templates, &template_request).map_err(|e| { + progress::finish_error(&template_pb, "Template promotion failed"); + e + })? { + Some(existing) => { + progress::finish_success(&template_pb, "Using existing template"); + existing + } + None => { + let created = ctx + .block_on(ctx.client.create_pipe_template(&template_request)) + .map_err(|e| { + progress::finish_error(&template_pb, "Template promotion failed"); + e + })?; + progress::finish_success(&template_pb, "Template promoted"); + created + } + }; + + let instance_request = + local_pipe.to_instance_request(self.deployment_hash.clone(), template.id.clone()); + + let instance_pb = progress::spinner("Creating remote pipe instance..."); let remote = ctx - .block_on( - ctx.client - .deploy_pipe(&self.instance_id, &self.deployment_hash), - ) + .block_on(ctx.client.create_pipe_instance(&instance_request)) .map_err(|e| { - progress::finish_error(&pb, "Deploy failed"); + progress::finish_error(&instance_pb, "Remote instance creation failed"); e })?; - progress::finish_success(&pb, "Pipe deployed to remote"); + progress::finish_success(&instance_pb, "Pipe deployed to remote"); + + let mut updated_pipe = local_pipe.clone(); + updated_pipe.record_promotion(&self.deployment_hash, &template.id, &remote.id); + let local_path = store.save(&updated_pipe)?; if self.json { - println!("{}", serde_json::to_string_pretty(&remote)?); + let mut output = serde_json::json!({ + "local_pipe": updated_pipe, + "remote_template_id": template.id, + "remote_instance": remote, + }); + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); return Ok(()); } println!("\n ✓ Local pipe promoted to remote deployment"); - println!(" Remote instance ID: {}", remote.id); - println!(" Deployment: {}", &remote.deployment_hash); - println!(" Source: {}", remote.source_container); + println!(" Local pipe ID: {}", updated_pipe.id); + println!(" Local file: {}", local_path.display()); + println!(" Remote template ID: {}", template.id); + println!(" Remote instance ID: {}", remote.id); + println!(" Deployment: {}", &remote.deployment_hash); + println!(" Source: {}", remote.source_container); if let Some(ref t) = remote.target_container { - println!(" Target: {}", t); + println!(" Target: {}", t); + } else if let Some(ref adapter) = remote.target_adapter { + println!(" Target: {} adapter", adapter.code); } - println!(" Status: {}", remote.status); + println!(" Status: {}", remote.status); println!( "\n Use 'stacker pipe activate {}' to start the remote pipe.", remote.id @@ -2980,6 +4784,182 @@ mod tests { use crate::cli::field_matcher::{DeterministicFieldMatcher, FieldMatcher}; use serde_json::json; + fn sample_local_smtp_pipe(mapping: serde_json::Value) -> LocalPipeDocument { + LocalPipeDocument::draft(NewLocalPipeDocument { + name: "status-panel-web-to-smtp".to_string(), + source: LocalPipeBinding { + selector: "status-panel-web".to_string(), + container: Some("status-panel-web".to_string()), + adapter: None, + method: "POST".to_string(), + path: "/contact".to_string(), + fields: vec![ + "name".to_string(), + "email".to_string(), + "subject".to_string(), + "message".to_string(), + ], + }, + target: LocalPipeBinding { + selector: "smtp".to_string(), + container: None, + adapter: Some( + PipeAdapterReference::new("smtp") + .with_role(PipeAdapterRole::Target) + .with_config(json!({ + "host": "smtp", + "port": 1025, + "from": "info@example.com", + "to": ["ops@example.com"], + "tls": false + })), + ), + method: "SEND".to_string(), + path: "adapter:smtp".to_string(), + fields: vec![ + "from_email".to_string(), + "reply_to_email".to_string(), + "subject".to_string(), + "body_text".to_string(), + "body_html".to_string(), + ], + }, + template: LocalPipeTemplate { + description: Some("POST /contact -> SEND adapter:smtp".to_string()), + source_app_type: "status-panel-web".to_string(), + source_endpoint: json!({"method":"POST","path":"/contact"}), + target_app_type: "smtp".to_string(), + target_endpoint: json!({"adapter":"smtp","mode":"adapter"}), + target_external_url: None, + field_mapping: mapping, + config: Some(json!({"retry_count": 3})), + is_public: false, + }, + instance: LocalPipeInstance { + source_adapter: None, + source_container: "status-panel-web".to_string(), + target_adapter: Some( + PipeAdapterReference::new("smtp") + .with_role(PipeAdapterRole::Target) + .with_config(json!({ + "host": "smtp", + "port": 1025, + "from": "info@example.com", + "to": ["ops@example.com"], + "tls": false + })), + ), + target_container: None, + target_url: None, + field_mapping_override: None, + config_override: None, + trigger_count: 0, + error_count: 0, + last_triggered_at: None, + }, + diagnostics: LocalPipeDiagnostics::default(), + }) + .expect("sample local pipe should be valid") + } + + fn sample_smtp_container(host_port: Option) -> LocalContainerInfo { + LocalContainerInfo { + id: "abc123".to_string(), + name: "status-smtp-1".to_string(), + image: "mailpit/mailpit:latest".to_string(), + network: "status_default".to_string(), + addresses: vec!["172.18.0.30".to_string()], + ports: vec![LocalPortBinding { + container_port: 1025, + host_port, + host_ip: Some("0.0.0.0".to_string()), + protocol: "tcp".to_string(), + }], + status: "running".to_string(), + env: BTreeMap::new(), + labels: BTreeMap::from([( + "com.docker.compose.service".to_string(), + "smtp".to_string(), + )]), + } + } + + fn sample_exim_container(host_port: Option) -> LocalContainerInfo { + LocalContainerInfo { + id: "def456".to_string(), + name: "status-smtp-1".to_string(), + image: "exim:latest".to_string(), + network: "status_default".to_string(), + addresses: vec!["172.18.0.31".to_string()], + ports: vec![LocalPortBinding { + container_port: 25, + host_port, + host_ip: Some("0.0.0.0".to_string()), + protocol: "tcp".to_string(), + }], + status: "running".to_string(), + env: BTreeMap::new(), + labels: BTreeMap::from([( + "com.docker.compose.service".to_string(), + "smtp".to_string(), + )]), + } + } + + fn sample_template_request() -> CreatePipeTemplateApiRequest { + sample_local_smtp_pipe(json!({"subject": "$.subject"})).to_template_request() + } + + fn sample_template_info() -> PipeTemplateInfo { + let request = sample_template_request(); + PipeTemplateInfo { + id: "tmpl-1".to_string(), + name: request.name, + description: request.description, + source_app_type: request.source_app_type, + source_endpoint: request.source_endpoint, + target_app_type: request.target_app_type, + target_endpoint: request.target_endpoint, + target_external_url: request.target_external_url, + field_mapping: request.field_mapping, + config: request.config, + is_public: request.is_public, + created_by: "user-1".to_string(), + created_at: "2026-05-23T00:00:00Z".to_string(), + updated_at: "2026-05-23T00:00:00Z".to_string(), + } + } + + #[test] + fn test_find_reusable_pipe_template_reuses_identical_template() { + let request = sample_template_request(); + let existing = sample_template_info(); + + let resolved = find_reusable_pipe_template(&[existing.clone()], &request) + .expect("lookup should succeed"); + + let resolved = resolved.expect("template should be reused"); + assert_eq!(resolved.id, existing.id); + assert_eq!(resolved.name, existing.name); + } + + #[test] + fn test_find_reusable_pipe_template_rejects_conflicting_template() { + let request = sample_template_request(); + let mut existing = sample_template_info(); + existing.target_endpoint = json!({"adapter":"smtp","mode":"adapter","variant":"other"}); + + let error = find_reusable_pipe_template(&[existing], &request) + .expect_err("conflicting template should be rejected"); + + assert!( + error + .to_string() + .contains("already exists with a different definition"), + "unexpected error: {error}" + ); + } + #[test] fn test_smart_field_match_exact() { let matcher = DeterministicFieldMatcher; @@ -3086,6 +5066,121 @@ mod tests { assert!(prefix.is_empty()); } + #[test] + fn test_apply_field_mapping_supports_nested_paths() { + let mapped = apply_field_mapping( + &json!({ + "contact": { + "email": "person@example.com" + }, + "message": "hello" + }), + &json!({ + "reply_to_email": "$.contact.email", + "body_text": "$.message" + }), + ); + + assert_eq!( + mapped, + json!({ + "reply_to_email": "person@example.com", + "body_text": "hello" + }) + ); + } + + #[test] + fn test_build_local_trigger_payload_applies_smtp_defaults() { + let pipe = sample_local_smtp_pipe(json!({ + "subject": "$.subject" + })); + + let payload = build_local_trigger_payload( + &pipe, + &json!({ + "name": "Alice", + "email": "alice@example.com", + "subject": "Status question", + "message": "Hello from the contact form" + }), + ); + + assert_eq!( + payload, + json!({ + "subject": "Status question", + "from_email": "alice@example.com", + "reply_to_email": "alice@example.com", + "body_text": "Hello from the contact form" + }) + ); + } + + #[test] + fn test_example_local_trigger_command_uses_expected_shape() { + let command = example_local_trigger_command("pipe-123"); + assert!(command.contains("stacker pipe trigger pipe-123 --data")); + assert!(command.contains("\"email\":\"person@example.com\"")); + } + + #[test] + fn test_remap_smtp_target_reference_prefers_published_host_port() { + let pipe = sample_local_smtp_pipe(json!({})); + let adapter = pipe.instance.target_adapter.clone().expect("smtp adapter"); + let remapped = remap_smtp_target_reference_for_container( + adapter, + "smtp", + &sample_smtp_container(Some(11025)), + ) + .expect("smtp target should be remapped"); + let config = remapped.config.expect("smtp config"); + + assert_eq!(config["host"], serde_json::json!("127.0.0.1")); + assert_eq!(config["port"], serde_json::json!(11025)); + } + + #[test] + fn test_remap_smtp_target_reference_accepts_host_published_port_mapping() { + let pipe = sample_local_smtp_pipe(json!({})); + let adapter = pipe.instance.target_adapter.clone().expect("smtp adapter"); + let remapped = remap_smtp_target_reference_for_container( + adapter, + "smtp", + &sample_exim_container(Some(1025)), + ) + .expect("smtp target should use the published host port"); + let config = remapped.config.expect("smtp config"); + + assert_eq!(config["host"], serde_json::json!("127.0.0.1")); + assert_eq!(config["port"], serde_json::json!(1025)); + } + + #[test] + fn test_remap_smtp_target_reference_rejects_unpublished_localhost_target() { + let mut pipe = sample_local_smtp_pipe(json!({})); + if let Some(adapter) = pipe.instance.target_adapter.as_mut() { + if let Some(config) = adapter + .config + .as_mut() + .and_then(|value| value.as_object_mut()) + { + config.insert("host".to_string(), serde_json::json!("localhost")); + } + } + + let adapter = pipe.instance.target_adapter.clone().expect("smtp adapter"); + let error = remap_smtp_target_reference_for_container( + adapter, + "smtp", + &sample_smtp_container(None), + ) + .expect_err("localhost target should require a published port"); + + let message = error.to_string(); + assert!(message.contains("does not publish that port to the host")); + } + #[test] fn test_deployment_context_equality() { assert_eq!(DeploymentContext::Local, DeploymentContext::Local); @@ -3182,11 +5277,13 @@ mod tests { LocalPortBinding { container_port: 5050, host_port: None, + host_ip: None, protocol: "tcp".to_string(), }, LocalPortBinding { container_port: 8080, host_port: Some(18080), + host_ip: Some("::".to_string()), protocol: "tcp".to_string(), }, ], @@ -3197,7 +5294,7 @@ mod tests { let urls = local_http_candidate_urls(&container); assert!(urls.contains(&"http://172.18.0.20:5050".to_string())); - assert!(urls.contains(&"http://127.0.0.1:18080".to_string())); + assert!(urls.contains(&"http://[::1]:18080".to_string())); } #[test] @@ -3247,6 +5344,7 @@ mod tests { ports: vec![LocalPortBinding { container_port: 5432, host_port: None, + host_ip: None, protocol: "tcp".to_string(), }], status: "running".to_string(), @@ -3262,6 +5360,7 @@ mod tests { ports: vec![LocalPortBinding { container_port: 5672, host_port: None, + host_ip: None, protocol: "tcp".to_string(), }], status: "running".to_string(), @@ -3322,4 +5421,41 @@ mod tests { ] ); } + + #[test] + fn test_validate_pipe_command_capabilities_accepts_pipes_feature() { + let capabilities = DeploymentCapabilitiesInfo { + deployment_hash: "dep-123".to_string(), + status: "online".to_string(), + capabilities: vec!["docker".to_string(), "pipes".to_string()], + features: crate::cli::stacker_client::DeploymentCapabilityFeatures { + pipes: true, + ..Default::default() + }, + }; + + assert!(validate_pipe_command_capabilities(&capabilities).is_ok()); + } + + #[test] + fn test_validate_pipe_command_capabilities_rejects_missing_pipes_feature() { + let capabilities = DeploymentCapabilitiesInfo { + deployment_hash: "dep-456".to_string(), + status: "online".to_string(), + capabilities: vec![ + "docker".to_string(), + "compose".to_string(), + "logs".to_string(), + ], + features: crate::cli::stacker_client::DeploymentCapabilityFeatures::default(), + }; + + let error = validate_pipe_command_capabilities(&capabilities) + .expect_err("missing pipes capability should be rejected"); + + let message = error.to_string(); + assert!(message.contains("does not support pipe commands")); + assert!(message.contains("dep-456")); + assert!(message.contains("docker, compose, logs")); + } } diff --git a/src/console/commands/cli/proxy.rs b/src/console/commands/cli/proxy.rs index 6ba9a71b..421adc5e 100644 --- a/src/console/commands/cli/proxy.rs +++ b/src/console/commands/cli/proxy.rs @@ -1,5 +1,5 @@ use crate::cli::config_parser::{ - CloudOrchestrator, DeployTarget, DomainConfig, SslMode, StackerConfig, + CloudOrchestrator, DeployTarget, DomainConfig, ProxyType, SslMode, StackerConfig, }; use crate::cli::deployment_lock::DeploymentLock; use crate::cli::error::CliError; @@ -8,13 +8,59 @@ use crate::cli::proxy_manager::{ DockerCliRuntime, ProxyDetection, }; use crate::cli::runtime::CliRuntime; +use crate::cli::stacker_client::AgentEnqueueRequest; +use crate::console::commands::cli::agent::{run_agent_command, AgentConfigureProxyCommand}; use crate::console::commands::CallableTrait; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxyProviderKind { + NginxProxyManager, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProxyProviderMetadata { + pub kind: ProxyProviderKind, + pub canonical_name: &'static str, + pub service_catalog_name: &'static str, + pub internal_api_url: &'static str, +} + +impl ProxyProviderKind { + pub fn from_alias(alias: &str) -> Option { + match normalize_proxy_provider_alias(alias).as_str() { + "npm" | "nginxproxymanager" => Some(Self::NginxProxyManager), + _ => None, + } + } + + pub fn metadata(self) -> ProxyProviderMetadata { + match self { + Self::NginxProxyManager => ProxyProviderMetadata { + kind: self, + canonical_name: "nginx-proxy-manager", + service_catalog_name: "nginx_proxy_manager", + internal_api_url: "http://nginx-proxy-manager:81", + }, + } + } +} + +fn normalize_proxy_provider_alias(alias: &str) -> String { + alias + .trim() + .to_ascii_lowercase() + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .collect() +} /// Parse SSL mode string to `SslMode` enum. pub fn parse_ssl_mode(s: Option<&str>) -> SslMode { match s.map(|v| v.to_lowercase()).as_deref() { - Some("auto") => SslMode::Auto, + Some("auto") | Some("true") | Some("yes") | Some("on") | Some("1") => SslMode::Auto, Some("manual") => SslMode::Manual, + Some("off") | Some("false") | Some("no") | Some("0") => SslMode::Off, _ => SslMode::Off, } } @@ -32,37 +78,228 @@ pub fn build_domain_config( } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProxyConfigPersistence { + config_path: PathBuf, + backup_path: PathBuf, + changed: bool, +} + +fn upsert_proxy_domain_config( + config: &mut StackerConfig, + proxy_type: ProxyType, + domain_config: DomainConfig, +) -> bool { + let mut changed = false; + + if config.proxy.proxy_type != proxy_type { + config.proxy.proxy_type = proxy_type; + changed = true; + } + + if let Some(existing) = config + .proxy + .domains + .iter_mut() + .find(|entry| entry.domain.eq_ignore_ascii_case(&domain_config.domain)) + { + if existing.ssl != domain_config.ssl || existing.upstream != domain_config.upstream { + existing.ssl = domain_config.ssl; + existing.upstream = domain_config.upstream; + changed = true; + } + return changed; + } + + config.proxy.domains.push(domain_config); + true +} + +fn persist_proxy_config_to_stacker_yml( + project_dir: &Path, + proxy_type: ProxyType, + domain_config: DomainConfig, +) -> Result, CliError> { + let config_path = project_dir.join("stacker.yml"); + if !config_path.exists() { + 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())); + + if !changed { + return Ok(Some(ProxyConfigPersistence { + config_path, + backup_path, + changed, + })); + } + + let yaml = serde_yaml::to_string(&config) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + std::fs::copy(&config_path, &backup_path)?; + std::fs::write(&config_path, yaml)?; + + Ok(Some(ProxyConfigPersistence { + config_path, + backup_path, + changed, + })) +} + +fn print_proxy_config_persistence(result: Option<&ProxyConfigPersistence>) { + let Some(result) = result else { + eprintln!("⚠ No stacker.yml found; proxy config was not persisted locally."); + return; + }; + + if result.changed { + eprintln!("✓ Updated proxy config in {}", result.config_path.display()); + eprintln!(" Backup written to {}", result.backup_path.display()); + } else { + eprintln!( + "✓ Proxy config already up to date in {}", + result.config_path.display() + ); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyUpstreamTarget { + pub app_code: String, + pub port: u16, +} + +pub fn parse_proxy_upstream(upstream: &str) -> Result { + let upstream = upstream + .strip_prefix("http://") + .or_else(|| upstream.strip_prefix("https://")) + .unwrap_or(upstream); + + if upstream.contains('/') { + return Err(CliError::ConfigValidation(format!( + "Invalid upstream '{}': paths are not supported; use host:port", + upstream + ))); + } + + let (host, port) = upstream.rsplit_once(':').ok_or_else(|| { + CliError::ConfigValidation(format!( + "Invalid upstream '{}': must match [http://]host:port format", + upstream + )) + })?; + + if host.trim().is_empty() { + return Err(CliError::ConfigValidation(format!( + "Invalid upstream '{}': host is required", + upstream + ))); + } + + let port = port.parse::().map_err(|_| { + CliError::ConfigValidation(format!( + "Invalid upstream '{}': port must be between 1 and 65535", + upstream + )) + })?; + + if port == 0 { + return Err(CliError::ConfigValidation(format!( + "Invalid upstream '{}': port must be between 1 and 65535", + upstream + ))); + } + + Ok(ProxyUpstreamTarget { + app_code: host.to_string(), + port, + }) +} + /// Run proxy detection using a `ContainerRuntime` (DIP). pub fn run_detect(runtime: &dyn ContainerRuntime) -> Result { detect_proxy(runtime) } -/// `stacker proxy add [--upstream ] [--ssl auto|manual|off]` +/// `stacker proxy add [--upstream ] [--ssl[=auto|manual|off]]` /// /// Adds a reverse-proxy entry for the given domain. pub struct ProxyAddCommand { pub domain: String, pub upstream: Option, pub ssl: Option, + pub force: bool, + pub json: bool, + pub deployment: Option, } impl ProxyAddCommand { - pub fn new(domain: String, upstream: Option, ssl: Option) -> Self { + pub fn new( + domain: String, + upstream: Option, + ssl: Option, + force: bool, + json: bool, + deployment: Option, + ) -> Self { Self { domain, upstream, ssl, + force, + json, + deployment, } } } impl CallableTrait for ProxyAddCommand { fn call(&self) -> Result<(), Box> { - let config = + let project_dir = std::env::current_dir()?; + let domain_config = build_domain_config(&self.domain, self.upstream.as_deref(), self.ssl.as_deref()); - let block = generate_nginx_server_block(&config)?; + let use_agent = self.deployment.is_some() || is_cloud_or_remote(&project_dir); + if use_agent { + 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; + let command = AgentConfigureProxyCommand::new( + target.app_code, + self.domain.clone(), + target.port, + ssl_enabled, + !ssl_enabled, + "create".to_string(), + self.force, + self.json, + self.deployment.clone(), + ); + command.call()?; + let persistence = persist_proxy_config_to_stacker_yml( + &project_dir, + ProxyType::NginxProxyManager, + domain_config, + )?; + if !self.json { + print_proxy_config_persistence(persistence.as_ref()); + } + return Ok(()); + } + + let block = generate_nginx_server_block(&domain_config)?; + let persistence = + persist_proxy_config_to_stacker_yml(&project_dir, ProxyType::Nginx, domain_config)?; println!("{}", block); - eprintln!("✓ Proxy entry generated for {}", self.domain); + 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 + ); Ok(()) } } @@ -194,8 +431,26 @@ impl CallableTrait for ProxyDetectCommand { let ctx = CliRuntime::new("proxy detect")?; let hash = resolve_deployment_hash_for_proxy(&self.deployment, &ctx)?; - let snapshot = ctx.block_on(ctx.client.agent_snapshot(&hash))?; - let detection = detect_proxy_from_snapshot(&snapshot); + // Use a live list_containers command — the snapshot's containers + // field is not populated for cloud deployments. + const DETECT_TIMEOUT: u64 = 30; + let params = crate::forms::status_panel::ListContainersCommandRequest { + include_health: true, + include_logs: false, + log_lines: 0, + }; + let request = AgentEnqueueRequest::new(&hash, "list_containers") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; + let info = run_agent_command(&ctx, &request, "Scanning containers", DETECT_TIMEOUT)?; + let containers = info + .result + .as_ref() + .and_then(|r| r.get("containers").and_then(|v| v.as_array())) + .cloned() + .unwrap_or_default(); + let fake_snapshot = serde_json::json!({ "containers": containers }); + let detection = detect_proxy_from_snapshot(&fake_snapshot); print_detection(&detection, self.json); } else { let runtime = DockerCliRuntime; @@ -212,7 +467,7 @@ impl CallableTrait for ProxyDetectCommand { #[cfg(test)] mod tests { use super::*; - use crate::cli::config_parser::ProxyType; + use crate::cli::config_parser::{ConfigBuilder, ProxyConfig}; use crate::cli::proxy_manager::ContainerInfo; struct MockRuntime { @@ -228,16 +483,43 @@ mod tests { } } + #[test] + fn proxy_provider_aliases_resolve_to_nginx_proxy_manager() { + for alias in [ + "npm", + "nginx-proxy-manager", + "nginx_proxy_manager", + "Nginx Proxy Manager", + ] { + assert_eq!( + ProxyProviderKind::from_alias(alias), + Some(ProxyProviderKind::NginxProxyManager) + ); + } + assert_eq!(ProxyProviderKind::from_alias("traefik"), None); + } + + #[test] + fn nginx_proxy_manager_metadata_uses_stack_service_defaults() { + let metadata = ProxyProviderKind::NginxProxyManager.metadata(); + + assert_eq!(metadata.service_catalog_name, "nginx_proxy_manager"); + assert_eq!(metadata.internal_api_url, "http://nginx-proxy-manager:81"); + assert_eq!(metadata.canonical_name, "nginx-proxy-manager"); + } + #[test] fn test_parse_ssl_mode_auto() { assert_eq!(parse_ssl_mode(Some("auto")), SslMode::Auto); assert_eq!(parse_ssl_mode(Some("AUTO")), SslMode::Auto); + assert_eq!(parse_ssl_mode(Some("true")), SslMode::Auto); } #[test] fn test_parse_ssl_mode_defaults_to_off() { assert_eq!(parse_ssl_mode(None), SslMode::Off); assert_eq!(parse_ssl_mode(Some("unknown")), SslMode::Off); + assert_eq!(parse_ssl_mode(Some("false")), SslMode::Off); } #[test] @@ -255,6 +537,149 @@ mod tests { assert_eq!(cfg.ssl, SslMode::Auto); } + #[test] + fn upsert_proxy_domain_config_sets_type_and_adds_domain() { + let mut config = ConfigBuilder::new().name("demo").build().unwrap(); + let changed = upsert_proxy_domain_config( + &mut config, + ProxyType::NginxProxyManager, + build_domain_config("example.com", Some("app:3000"), Some("auto")), + ); + + assert!(changed); + assert_eq!(config.proxy.proxy_type, ProxyType::NginxProxyManager); + assert_eq!(config.proxy.domains.len(), 1); + assert_eq!(config.proxy.domains[0].domain, "example.com"); + assert_eq!(config.proxy.domains[0].ssl, SslMode::Auto); + assert_eq!(config.proxy.domains[0].upstream, "app:3000"); + } + + #[test] + fn upsert_proxy_domain_config_updates_existing_domain_without_duplicate() { + let mut config = ConfigBuilder::new() + .name("demo") + .proxy(ProxyConfig { + proxy_type: ProxyType::None, + auto_detect: false, + domains: vec![build_domain_config( + "Example.com", + Some("app:3000"), + Some("off"), + )], + config: None, + }) + .build() + .unwrap(); + + let changed = upsert_proxy_domain_config( + &mut config, + ProxyType::NginxProxyManager, + build_domain_config("example.com", Some("web:8080"), Some("auto")), + ); + + assert!(changed); + assert_eq!(config.proxy.proxy_type, ProxyType::NginxProxyManager); + assert_eq!(config.proxy.domains.len(), 1); + assert_eq!(config.proxy.domains[0].domain, "Example.com"); + assert_eq!(config.proxy.domains[0].ssl, SslMode::Auto); + assert_eq!(config.proxy.domains[0].upstream, "web:8080"); + } + + #[test] + fn persist_proxy_config_to_stacker_yml_writes_backup_and_preserves_env_placeholders() { + let dir = tempfile::TempDir::new().unwrap(); + let config_path = dir.path().join("stacker.yml"); + std::fs::write( + &config_path, + "name: demo\napp:\n type: node\n image: ${APP_IMAGE}\nproxy:\n type: none\n domains: []\n", + ) + .unwrap(); + + let result = persist_proxy_config_to_stacker_yml( + dir.path(), + ProxyType::NginxProxyManager, + build_domain_config( + "status.example.com", + Some("status-panel-web:3000"), + Some("auto"), + ), + ) + .unwrap() + .expect("stacker.yml exists"); + + assert!(result.changed); + assert!(result.backup_path.exists()); + + let written = std::fs::read_to_string(&config_path).unwrap(); + assert!(written.contains("${APP_IMAGE}")); + + let config = StackerConfig::from_file_raw(&config_path).unwrap(); + assert_eq!(config.proxy.proxy_type, ProxyType::NginxProxyManager); + assert_eq!(config.proxy.domains.len(), 1); + assert_eq!(config.proxy.domains[0].domain, "status.example.com"); + assert_eq!(config.proxy.domains[0].upstream, "status-panel-web:3000"); + assert_eq!(config.proxy.domains[0].ssl, SslMode::Auto); + } + + #[test] + fn given_stacker_proxy_add_when_config_is_persisted_then_stacker_yml_reflects_proxy_state() { + let dir = tempfile::TempDir::new().unwrap(); + let config_path = dir.path().join("stacker.yml"); + std::fs::write( + &config_path, + r#" +name: web +services: + - name: status-panel-web + image: trydirect/status-panel-web:0.1.0 +proxy: + type: none + auto_detect: false + domains: [] +"#, + ) + .unwrap(); + + let result = persist_proxy_config_to_stacker_yml( + dir.path(), + ProxyType::NginxProxyManager, + build_domain_config( + "status.stacker.my", + Some("status-panel-web:3000"), + Some("auto"), + ), + ) + .unwrap() + .expect("stacker.yml exists"); + + assert!(result.changed); + assert!(result.backup_path.exists()); + + let config = StackerConfig::from_file_raw(&config_path).unwrap(); + assert_eq!(config.proxy.proxy_type, ProxyType::NginxProxyManager); + assert_eq!(config.proxy.domains.len(), 1); + assert_eq!(config.proxy.domains[0].domain, "status.stacker.my"); + assert_eq!(config.proxy.domains[0].ssl, SslMode::Auto); + assert_eq!(config.proxy.domains[0].upstream, "status-panel-web:3000"); + assert!(config + .services + .iter() + .all(|service| service.name != "nginx_proxy_manager")); + } + + #[test] + fn test_parse_proxy_upstream_strips_scheme() { + let target = parse_proxy_upstream("http://coolify:80").unwrap(); + assert_eq!(target.app_code, "coolify"); + assert_eq!(target.port, 80); + } + + #[test] + fn test_parse_proxy_upstream_rejects_paths() { + let err = parse_proxy_upstream("http://coolify:80/admin").unwrap_err(); + assert!(err.to_string().contains("paths are not supported")); + } + #[test] fn test_detect_returns_none_for_empty_containers() { let runtime = MockRuntime { containers: vec![] }; diff --git a/src/console/commands/cli/secrets.rs b/src/console/commands/cli/secrets.rs index 2037230b..a37f2760 100644 --- a/src/console/commands/cli/secrets.rs +++ b/src/console/commands/cli/secrets.rs @@ -16,6 +16,7 @@ use std::io::{self, IsTerminal, Read}; use std::path::{Path, PathBuf}; use crate::cli::config_parser::{ServiceDefinition, StackerConfig}; +use crate::cli::deployment_lock::DeploymentLock; use crate::cli::error::CliError; use crate::cli::runtime::CliRuntime; use crate::cli::stacker_client::{ @@ -72,11 +73,6 @@ impl RemoteSecretTarget { } } RemoteSecretScope::Server => { - if self.server_id.is_none() { - return Err(CliError::ConfigValidation( - "Server-scoped secrets require --server-id".to_string(), - )); - } if self.project.is_some() || self.service.is_some() { return Err(CliError::ConfigValidation( "Server-scoped secrets do not accept --project or --service".to_string(), @@ -340,6 +336,43 @@ fn remap_remote_secret_error(operation: &str, error: CliError) -> CliError { } } +/// Resolve a server ID from the deployment lock file when `--server-id` was not provided. +/// +/// Reads `.stacker/deployment-cloud.lock` (or the generic lock), looks up the +/// server by name via the API, and returns its platform ID. +fn resolve_server_id_from_lock(ctx: &CliRuntime) -> Result { + let project_dir = std::env::current_dir().map_err(|e| { + CliError::ConfigValidation(format!("Cannot determine project directory: {}", e)) + })?; + + let lock = DeploymentLock::load_active(&project_dir) + .map_err(|e| CliError::ConfigValidation(format!("Failed to read deployment lock: {}", e)))? + .ok_or_else(|| { + CliError::ConfigValidation( + "No deployment lock found. Run `stacker deploy` first or provide --server-id." + .to_string(), + ) + })?; + + let server_name = lock.server_name.ok_or_else(|| { + CliError::ConfigValidation( + "Deployment lock has no server name. Provide --server-id explicitly.".to_string(), + ) + })?; + + let server = ctx + .block_on(ctx.client.find_server_by_name(&server_name))? + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "Server '{}' from deployment lock not found on the Stacker platform. \ + Provide --server-id explicitly.", + server_name + )) + })?; + + Ok(server.id) +} + fn resolve_project( ctx: &CliRuntime, reference: &str, @@ -743,6 +776,7 @@ impl SecretsSetCommand { let value = resolve_remote_secret_value(options)?; let operation = "remote secrets set"; let ctx = CliRuntime::new("remote secrets set")?; + let server_id_from_lock = || resolve_server_id_from_lock(&ctx); match options.target.scope { RemoteSecretScope::Service => { @@ -769,11 +803,10 @@ impl SecretsSetCommand { ); } RemoteSecretScope::Server => { - let server_id = options.target.server_id().ok_or_else(|| { - CliError::ConfigValidation( - "Server-scoped secrets require --server-id".to_string(), - ) - })?; + let server_id = options + .target + .server_id() + .map_or_else(server_id_from_lock, Ok)?; let secret = ctx .block_on( ctx.client @@ -876,6 +909,7 @@ impl CallableTrait for SecretsGetCommand { options.validate()?; let operation = "remote secrets get"; let ctx = CliRuntime::new("remote secrets get")?; + let server_id_from_lock = || resolve_server_id_from_lock(&ctx); let secret = match options.target.scope { RemoteSecretScope::Service => { let project_ref = @@ -896,11 +930,10 @@ impl CallableTrait for SecretsGetCommand { .map_err(|error| remap_remote_secret_error(operation, error))? } RemoteSecretScope::Server => { - let server_id = options.target.server_id().ok_or_else(|| { - CliError::ConfigValidation( - "Server-scoped secrets require --server-id".to_string(), - ) - })?; + let server_id = options + .target + .server_id() + .map_or_else(server_id_from_lock, Ok)?; ctx.block_on( ctx.client .get_server_secret_metadata(server_id, &options.name), @@ -999,6 +1032,7 @@ impl CallableTrait for SecretsListCommand { options.validate()?; let operation = "remote secrets list"; let ctx = CliRuntime::new("remote secrets list")?; + let server_id_from_lock = || resolve_server_id_from_lock(&ctx); let secrets = match options.target.scope { RemoteSecretScope::Service => { let project_ref = @@ -1015,11 +1049,10 @@ impl CallableTrait for SecretsListCommand { .map_err(|error| remap_remote_secret_error(operation, error))? } RemoteSecretScope::Server => { - let server_id = options.target.server_id().ok_or_else(|| { - CliError::ConfigValidation( - "Server-scoped secrets require --server-id".to_string(), - ) - })?; + let server_id = options + .target + .server_id() + .map_or_else(server_id_from_lock, Ok)?; ctx.block_on(ctx.client.list_server_secrets(server_id)) .map_err(|error| remap_remote_secret_error(operation, error))? } @@ -1294,6 +1327,7 @@ impl CallableTrait for SecretsDeleteCommand { target.validate()?; let operation = "remote secrets delete"; let ctx = CliRuntime::new("remote secrets delete")?; + let server_id_from_lock = || resolve_server_id_from_lock(&ctx); match target.scope { RemoteSecretScope::Service => { @@ -1311,11 +1345,7 @@ impl CallableTrait for SecretsDeleteCommand { println!("✓ Deleted service secret {} from {}", key, app_code); } RemoteSecretScope::Server => { - let server_id = target.server_id().ok_or_else(|| { - CliError::ConfigValidation( - "Server-scoped secrets require --server-id".to_string(), - ) - })?; + let server_id = target.server_id().map_or_else(server_id_from_lock, Ok)?; ctx.block_on(ctx.client.delete_server_secret(server_id, key)) .map_err(|error| remap_remote_secret_error(operation, error))?; println!("✓ Deleted server secret {} from server {}", key, server_id); @@ -1495,6 +1525,7 @@ mod tests { hooks: Default::default(), env_file: None, env: Default::default(), + config_contract: Default::default(), }; assert_eq!( @@ -1782,11 +1813,11 @@ mod tests { } #[test] - fn test_remote_server_target_requires_server_id() { + fn test_remote_server_target_allows_missing_server_id() { + // server_id may be omitted at parse time; it is resolved from the + // lock file at execution time via server_id_from_lock(). let target = RemoteSecretTarget::new(RemoteSecretScope::Server, None, None, None); - - let error = target.validate().unwrap_err().to_string(); - assert!(error.contains("--server-id")); + assert!(target.validate().is_ok()); } #[test] diff --git a/src/console/commands/cli/service.rs b/src/console/commands/cli/service.rs index c9507820..e3985057 100644 --- a/src/console/commands/cli/service.rs +++ b/src/console/commands/cli/service.rs @@ -6,15 +6,23 @@ //! //! `stacker service list [--online]` shows available service templates. -use std::path::Path; +use std::path::{Path, PathBuf}; +use crate::cli::compose_service_sync::{ + sync_configured_compose_services, ComposeServiceSyncResult, +}; use crate::cli::config_parser::{ServiceDefinition, StackerConfig}; use crate::cli::credentials::CredentialsManager; use crate::cli::error::CliError; use crate::cli::service_catalog::ServiceCatalog; +use crate::cli::service_import::{ + import_plan_from_compose_file, parse_renames, ComposeImportRequest, ServiceImportPlan, + ServiceImportReview, +}; use crate::cli::stacker_client::{self, StackerClient}; use crate::console::commands::CallableTrait; use dialoguer::{Confirm, FuzzySelect}; +use serde::Serialize; const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; @@ -129,6 +137,11 @@ impl CallableTrait for ServiceAddCommand { let backup_path = format!("{}.bak", config_path); std::fs::copy(config_path, &backup_path)?; std::fs::write(config_path, &yaml)?; + let compose_sync = sync_configured_compose_services( + &project_dir_for_config(path), + &config, + std::slice::from_ref(&entry.service.name), + )?; println!("✓ Added '{}' to {}", entry.name, config_path); println!(" Image: {}", entry.service.image); @@ -167,6 +180,281 @@ impl CallableTrait for ServiceAddCommand { } eprintln!(" Backup saved to {}", backup_path); + print_compose_sync_result(&compose_sync); + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// service deploy +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker service deploy [--deployment ]` +/// +/// Validates that the named service exists in `stacker.yml`, then delegates to +/// the lower-level agent app deployment command using the service name as the +/// remote app code. +pub struct ServiceDeployCommand { + pub name: String, + pub force: bool, + pub runtime: String, + pub json: bool, + pub deployment: Option, + pub environment: Option, + pub plan: bool, + pub apply_plan: Option, +} + +impl ServiceDeployCommand { + #[allow(clippy::too_many_arguments)] + pub fn new( + name: String, + force: bool, + runtime: String, + json: bool, + deployment: Option, + environment: Option, + plan: bool, + apply_plan: Option, + ) -> Self { + Self { + name, + force, + runtime, + json, + deployment, + environment, + plan, + apply_plan, + } + } +} + +impl CallableTrait for ServiceDeployCommand { + fn call(&self) -> Result<(), Box> { + let config_path = DEFAULT_CONFIG_FILE; + let path = Path::new(config_path); + + if !path.exists() { + return Err(Box::new(CliError::ConfigNotFound { + path: path.to_path_buf(), + })); + } + + let config = StackerConfig::from_file_raw(path)?; + if !config + .services + .iter() + .any(|service| service.name == self.name) + { + return Err(Box::new(CliError::ConfigValidation(format!( + "Service '{}' was not found in {}. Add or import it first, then run `stacker service deploy {}`.", + self.name, config_path, self.name + )))); + } + + let compose_sync = sync_configured_compose_services( + &project_dir_for_config(path), + &config, + std::slice::from_ref(&self.name), + )?; + print_compose_sync_result(&compose_sync); + + let environment = self.environment.clone().or_else(|| { + if config.selected_environment(None).is_none() && config.deploy.compose_file.is_some() { + eprintln!( + " No deploy environment configured; using 'production' to build the service compose payload." + ); + Some("production".to_string()) + } else { + None + } + }); + + let command = crate::console::commands::cli::agent::AgentDeployAppCommand::new( + self.name.clone(), + None, + self.force, + self.runtime.clone(), + self.json, + self.deployment.clone(), + environment, + ) + .with_plan(self.plan) + .with_apply_plan(self.apply_plan.clone()); + + command.call() + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// service import +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker service import --from-compose [--service ]` +/// +/// Parses a local Docker Compose file, prints a safety review, and appends +/// selected image-backed services to `stacker.yml` only after confirmation. +pub struct ServiceImportCommand { + pub name: String, + pub from_compose: Option, + pub from_github: Option, + pub from_url: Option, + pub service: Option, + pub renames: Vec, + pub file: Option, + pub review: bool, + pub yes: bool, + pub json: bool, +} + +impl ServiceImportCommand { + #[allow(clippy::too_many_arguments)] + pub fn new( + name: String, + from_compose: Option, + from_github: Option, + from_url: Option, + service: Option, + renames: Vec, + file: Option, + review: bool, + yes: bool, + json: bool, + ) -> Self { + Self { + name, + from_compose, + from_github, + from_url, + service, + renames, + file, + review, + yes, + json, + } + } +} + +impl CallableTrait for ServiceImportCommand { + fn call(&self) -> Result<(), Box> { + let config_path = self.file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE); + let path = Path::new(config_path); + + if self.from_github.is_some() || self.from_url.is_some() { + return Err(Box::new(CliError::ConfigValidation( + "Remote custom service import is planned but not implemented yet. Download or inspect the Compose file yourself, then run `stacker service import --from-compose --review`." + .to_string(), + ))); + } + + let compose_path = self.from_compose.as_ref().ok_or_else(|| { + CliError::ConfigValidation( + "Specify a local Compose file with --from-compose . Remote GitHub/URL import is not fetched by default." + .to_string(), + ) + })?; + + if !path.exists() { + return Err(Box::new(CliError::ConfigNotFound { + path: path.to_path_buf(), + })); + } + + let renames = parse_renames(&self.renames)?; + let request = ComposeImportRequest { + import_name: self.name.clone(), + selected_service: self.service.clone(), + renames, + }; + let plan = import_plan_from_compose_file(compose_path, &request)?; + let config = StackerConfig::from_file_raw(path)?; + validate_no_duplicate_services(&config, &plan)?; + + if self.json && self.review { + let output = ServiceImportCommandOutput { + status: "review", + config_file: config_path.to_string(), + backup_file: None, + review: &plan.review, + imported_services: plan + .services + .iter() + .map(|service| service.name.clone()) + .collect(), + }; + println!("{}", serde_json::to_string_pretty(&output)?); + } else if !self.json { + print_import_plan(&plan); + } + + if self.review { + return Ok(()); + } + + if !self.yes { + let confirmed = Confirm::new() + .with_prompt(format!( + "Import {} service(s) into {}?", + plan.services.len(), + config_path + )) + .default(false) + .interact() + .map_err(|e| { + CliError::ConfigValidation(format!( + "Prompt failed: {e}. Re-run with --review to inspect only, or --yes to import non-interactively." + )) + })?; + + if !confirmed { + println!("Aborted."); + return Ok(()); + } + } + + let backup_path = import_services_into_config(path, config, &plan)?; + let updated_config = StackerConfig::from_file_raw(path)?; + let imported_service_names: Vec = plan + .services + .iter() + .map(|service| service.name.clone()) + .collect(); + let compose_sync = sync_configured_compose_services( + &project_dir_for_config(path), + &updated_config, + &imported_service_names, + )?; + + if self.json { + let output = ServiceImportCommandOutput { + status: "imported", + config_file: config_path.to_string(), + backup_file: Some(backup_path.clone()), + review: &plan.review, + imported_services: updated_config + .services + .iter() + .filter(|service| { + plan.services + .iter() + .any(|imported| imported.name == service.name) + }) + .map(|service| service.name.clone()) + .collect(), + }; + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!( + "✓ Imported {} service(s) into {}", + plan.services.len(), + config_path + ); + eprintln!(" Backup saved to {}", backup_path); + print_compose_sync_result(&compose_sync); + } Ok(()) } @@ -220,7 +508,7 @@ impl CallableTrait for ServiceListCommand { } println!("Usage: stacker service add "); - println!("Aliases: wp, pg, my, mongo, es, mq, pma, mh"); + println!("Aliases: wp, pg, my, mongo, es, mq, pma, smtp, mail, mh"); if self.online { eprintln!(); @@ -333,6 +621,125 @@ impl CallableTrait for ServiceRemoveCommand { // ── Helpers ────────────────────────────────────────── +#[derive(Serialize)] +struct ServiceImportCommandOutput<'a> { + status: &'static str, + config_file: String, + backup_file: Option, + review: &'a ServiceImportReview, + imported_services: Vec, +} + +fn validate_no_duplicate_services( + config: &StackerConfig, + plan: &ServiceImportPlan, +) -> Result<(), CliError> { + for imported in &plan.services { + if config.services.iter().any(|svc| svc.name == imported.name) { + return Err(CliError::ConfigValidation(format!( + "Service '{}' already exists in stacker.yml. Use --rename old=new or choose a different import name.", + imported.name + ))); + } + } + Ok(()) +} + +fn import_services_into_config( + path: &Path, + mut config: StackerConfig, + plan: &ServiceImportPlan, +) -> Result> { + for service in &plan.services { + config.services.push(service.clone()); + } + + let yaml = serde_yaml::to_string(&config) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + + let config_path = path.to_string_lossy().to_string(); + let backup_path = format!("{}.bak", config_path); + std::fs::copy(path, &backup_path)?; + std::fs::write(path, &yaml)?; + Ok(backup_path) +} + +fn print_import_plan(plan: &ServiceImportPlan) { + let review = &plan.review; + println!("Custom service import review: {}", review.import_name); + println!(); + + for service in &review.services { + println!(" Service: {} (from {})", service.name, service.source_name); + println!(" Image: {}", service.image); + if !service.ports.is_empty() { + println!(" Ports: {}", service.ports.join(", ")); + } + if !service.environment_keys.is_empty() { + println!(" Env keys: {}", service.environment_keys.join(", ")); + } + if !service.volumes.is_empty() { + println!(" Volumes: {}", service.volumes.join(", ")); + } + if !service.depends_on.is_empty() { + println!(" Depends on: {}", service.depends_on.join(", ")); + } + if !service.unsupported_fields.is_empty() { + println!( + " Unsupported Compose fields: {}", + service.unsupported_fields.join(", ") + ); + } + } + + if !review.risks.is_empty() { + println!(); + println!(" Risks to review:"); + for risk in &review.risks { + println!(" - [{}] {}: {}", risk.service, risk.kind, risk.detail); + } + } + + if !review.guidance.is_empty() { + println!(); + println!(" Guidance:"); + for item in &review.guidance { + println!(" - {}", item); + } + } + + if let Ok(yaml) = serde_yaml::to_string(&plan.services) { + println!(); + println!(" stacker.yml services to append:"); + for line in yaml.lines() { + println!(" {}", line); + } + } +} + +fn project_dir_for_config(path: &Path) -> PathBuf { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn print_compose_sync_result(result: &ComposeServiceSyncResult) { + if result.updated_services.is_empty() { + return; + } + if let Some(path) = result.compose_path.as_ref() { + eprintln!( + " Updated compose file {} with service(s): {}", + path.display(), + result.updated_services.join(", ") + ); + } + if let Some(path) = result.backup_path.as_ref() { + eprintln!(" Compose backup saved to {}", path.display()); + } +} + /// Try to build a `StackerClient` from stored credentials (best-effort). fn try_build_online_catalog() -> Option { let cred_manager = CredentialsManager::with_default_store(); @@ -359,6 +766,156 @@ fn category_icon(category: &str) -> &str { } } +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write_config(dir: &TempDir, body: &str) -> PathBuf { + let path = dir.path().join("stacker.yml"); + std::fs::write(&path, body).unwrap(); + path + } + + fn write_compose(dir: &TempDir, body: &str) -> PathBuf { + let path = dir.path().join("compose.yml"); + std::fs::write(&path, body).unwrap(); + path + } + + fn import_command( + config_path: &Path, + compose_path: &Path, + review: bool, + yes: bool, + ) -> ServiceImportCommand { + ServiceImportCommand::new( + "smtp".to_string(), + Some(compose_path.to_path_buf()), + None, + None, + Some("mailserver".to_string()), + Vec::new(), + Some(config_path.to_string_lossy().to_string()), + review, + yes, + false, + ) + } + + #[test] + fn service_import_review_only_does_not_write_config_or_backup() { + let dir = TempDir::new().unwrap(); + let config_path = write_config( + &dir, + r#" +name: test-app +app: + type: static +services: [] +"#, + ); + let compose_path = write_compose( + &dir, + r#" +services: + mailserver: + image: docker.io/mailserver/docker-mailserver:latest + environment: + ACCOUNT_PASSWORD: literal-secret +"#, + ); + let original = std::fs::read_to_string(&config_path).unwrap(); + + import_command(&config_path, &compose_path, true, false) + .call() + .unwrap(); + + assert_eq!(std::fs::read_to_string(&config_path).unwrap(), original); + assert!(!Path::new(&format!("{}.bak", config_path.to_string_lossy())).exists()); + } + + #[test] + fn service_import_prevents_duplicate_service_names() { + let dir = TempDir::new().unwrap(); + let config_path = write_config( + &dir, + r#" +name: test-app +app: + type: static +services: + - name: smtp + image: trydirect/smtp +"#, + ); + let compose_path = write_compose( + &dir, + r#" +services: + mailserver: + image: docker.io/mailserver/docker-mailserver:latest +"#, + ); + + let err = import_command(&config_path, &compose_path, false, true) + .call() + .unwrap_err(); + assert!(err.to_string().contains("already exists")); + } + + #[test] + fn service_import_writes_backup_and_preserves_secret_placeholders() { + let dir = TempDir::new().unwrap(); + let config_path = write_config( + &dir, + r#" +name: test-app +app: + type: static +services: [] +"#, + ); + let compose_path = write_compose( + &dir, + r#" +services: + mailserver: + image: docker.io/mailserver/docker-mailserver:latest + ports: + - "25:25" + environment: + ACCOUNT_PASSWORD: literal-secret + POSTMASTER_ADDRESS: postmaster@example.com + volumes: + - maildata:/var/mail +"#, + ); + + import_command(&config_path, &compose_path, false, true) + .call() + .unwrap(); + + let backup_path = format!("{}.bak", config_path.to_string_lossy()); + assert!(Path::new(&backup_path).exists()); + let config = StackerConfig::from_file_raw(&config_path).unwrap(); + let service = config + .services + .iter() + .find(|service| service.name == "smtp") + .unwrap(); + assert_eq!(service.ports, vec!["25:25"]); + assert_eq!( + service.environment.get("ACCOUNT_PASSWORD").unwrap(), + "${ACCOUNT_PASSWORD}" + ); + assert_eq!( + service.environment.get("POSTMASTER_ADDRESS").unwrap(), + "postmaster@example.com" + ); + } +} + fn capitalize(s: &str) -> String { let mut chars = s.chars(); match chars.next() { diff --git a/src/console/commands/cli/status.rs b/src/console/commands/cli/status.rs index 8546a26c..4fc714b7 100644 --- a/src/console/commands/cli/status.rs +++ b/src/console/commands/cli/status.rs @@ -224,7 +224,7 @@ fn print_deployment_status_rich(info: &DeploymentStatusInfo, json: bool, ctx: &S } /// Resolve the project name from stacker.yml (same logic as deploy). -fn resolve_project_name(config: &StackerConfig) -> String { +pub(crate) fn resolve_project_name(config: &StackerConfig) -> String { config .project .identity @@ -232,7 +232,7 @@ fn resolve_project_name(config: &StackerConfig) -> String { .unwrap_or_else(|| config.name.clone()) } -fn resolve_stacker_base_url(creds: &StoredCredentials) -> String { +pub(crate) fn resolve_stacker_base_url(creds: &StoredCredentials) -> String { creds .server_url .as_deref() @@ -240,7 +240,7 @@ fn resolve_stacker_base_url(creds: &StoredCredentials) -> String { .unwrap_or_else(|| stacker_client::DEFAULT_STACKER_URL.to_string()) } -fn missing_remote_project_reason( +pub(crate) fn missing_remote_project_reason( project_name: &str, base_url: &str, deploy_target: DeployTarget, @@ -491,7 +491,7 @@ fn run_remote_status(json: bool, watch: bool) -> Result<(), Box bool { +pub(crate) fn is_remote_deployment(project_dir: &Path) -> bool { if let Ok(Some(lock)) = crate::cli::deployment_lock::DeploymentLock::load(project_dir) { if lock.deployment_id.is_some() || lock.target != "local" { return true; diff --git a/src/console/main.rs b/src/console/main.rs index 565b5eee..85bf86c4 100644 --- a/src/console/main.rs +++ b/src/console/main.rs @@ -99,6 +99,15 @@ enum StackerCommands { /// Stacker API base URL (or set STACKER_URL) #[arg(long = "server-url", visible_alias = "api-url")] server_url: Option, + /// Authenticate via browser OAuth2 flow (opens a sign-in URL) + #[arg(long)] + browser: bool, + /// OAuth provider code for browser login: gc (Google), gh (GitHub), … (default: gc) + #[arg(long, value_name = "PROVIDER")] + provider: Option, + /// Log in with username/password instead of browser OAuth (skips browser flow) + #[arg(short = 'u', long, value_name = "EMAIL")] + user: Option, }, /// Show the saved login and current project's recorded deploy identity Whoami {}, @@ -135,6 +144,9 @@ enum StackerCommands { /// Project name on the Stacker server #[arg(long, value_name = "NAME")] project: Option, + /// Deployment environment/profile to use + #[arg(long = "env", visible_alias = "environment", value_name = "NAME")] + environment: Option, /// Name of saved cloud credential to reuse #[arg(long, value_name = "KEY_NAME")] key: Option, @@ -257,6 +269,26 @@ enum StackerConfigSetupCommands { #[arg(long, value_name = "FILE")] file: Option, }, + /// Configure AI defaults in stacker.yml + Ai { + #[arg(long, value_name = "FILE")] + file: Option, + /// AI provider: openai, anthropic, ollama, custom + #[arg(long, value_name = "PROVIDER")] + provider: Option, + /// AI endpoint, e.g. http://localhost:11434 for Ollama + #[arg(long, value_name = "URL")] + endpoint: Option, + /// AI model name, e.g. llama3.1 + #[arg(long, value_name = "MODEL")] + model: Option, + /// AI request timeout in seconds + #[arg(long, value_name = "SECONDS")] + timeout: Option, + /// AI task name. Repeat or use comma-separated values. + #[arg(long = "task", value_name = "TASK")] + tasks: Vec, + }, /// Advanced/debug: generate remote orchestrator payload and wire stacker.yml RemotePayload { #[arg(long, value_name = "FILE")] @@ -290,8 +322,14 @@ enum StackerProxyCommands { domain: String, #[arg(long)] upstream: Option, - #[arg(long)] + #[arg(long, num_args = 0..=1, default_missing_value = "auto")] ssl: Option, + #[arg(long)] + force: bool, + #[arg(long)] + json: bool, + #[arg(long)] + deployment: Option, }, /// Detect existing reverse-proxy containers Detect { @@ -365,12 +403,18 @@ fn get_command( domain, auth_url, server_url, + browser, + provider, + user, } => Ok(Box::new( stacker::console::commands::cli::login::LoginCommand::new( org, domain, auth_url, server_url, + browser, + provider, + user, ), )), StackerCommands::Whoami {} => Ok(Box::new( @@ -396,6 +440,7 @@ fn get_command( dry_run, force_rebuild, project, + environment, key, key_id, server, @@ -407,6 +452,7 @@ fn get_command( force_rebuild, ) .with_remote_overrides(project, key, server) + .with_environment(environment) .with_key_id(key_id), )), StackerCommands::Connect { handoff } => Ok(Box::new( @@ -454,6 +500,18 @@ fn get_command( StackerConfigSetupCommands::Cloud { file } => Ok(Box::new( stacker::console::commands::cli::config::ConfigSetupCloudCommand::new(file), )), + StackerConfigSetupCommands::Ai { + file, + provider, + endpoint, + model, + timeout, + tasks, + } => Ok(Box::new( + stacker::console::commands::cli::config::ConfigSetupAiCommand::new( + file, provider, endpoint, model, timeout, tasks, + ), + )), StackerConfigSetupCommands::RemotePayload { file, out } => Ok(Box::new( stacker::console::commands::cli::config::ConfigSetupRemotePayloadCommand::new(file, out), )), @@ -461,7 +519,7 @@ fn get_command( }, StackerCommands::Ai { command: ai_cmd, write } => match ai_cmd { None => Ok(Box::new( - stacker::console::commands::cli::ai::AiChatCommand::new(write), + stacker::console::commands::cli::ai::AiChatCommand::new(write, None, None), )), Some(StackerAiCommands::Ask { question, @@ -481,9 +539,12 @@ fn get_command( domain, upstream, ssl, + force, + json, + deployment, } => Ok(Box::new( stacker::console::commands::cli::proxy::ProxyAddCommand::new( - domain, upstream, ssl, + domain, upstream, ssl, force, json, deployment, ), )), StackerProxyCommands::Detect { json, deployment } => Ok(Box::new( diff --git a/src/db/pipe.rs b/src/db/pipe.rs index 9391e092..4246999c 100644 --- a/src/db/pipe.rs +++ b/src/db/pipe.rs @@ -245,22 +245,24 @@ pub async fn insert_instance( sqlx::query_as::<_, PipeInstance>( r#" INSERT INTO pipe_instances ( - id, template_id, deployment_hash, source_container, target_container, - target_url, field_mapping_override, config_override, status, - last_triggered_at, trigger_count, error_count, is_local, created_by, - created_at, updated_at + id, template_id, deployment_hash, source_adapter, source_container, target_adapter, + target_container, target_url, field_mapping_override, config_override, status, + last_triggered_at, trigger_count, error_count, is_local, created_by, created_at, + updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) - RETURNING id, template_id, deployment_hash, source_container, target_container, - target_url, field_mapping_override, config_override, status, - last_triggered_at, trigger_count, error_count, is_local, created_by, - created_at, updated_at + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + RETURNING id, template_id, deployment_hash, source_adapter, source_container, + target_adapter, target_container, target_url, field_mapping_override, + config_override, status, last_triggered_at, trigger_count, error_count, + is_local, created_by, created_at, updated_at "#, ) .bind(instance.id) .bind(instance.template_id) .bind(&instance.deployment_hash) + .bind(&instance.source_adapter) .bind(&instance.source_container) + .bind(&instance.target_adapter) .bind(&instance.target_container) .bind(&instance.target_url) .bind(&instance.field_mapping_override) @@ -288,10 +290,10 @@ pub async fn get_instance(pool: &PgPool, id: &Uuid) -> Result( r#" - SELECT id, template_id, deployment_hash, source_container, target_container, - target_url, field_mapping_override, config_override, status, - last_triggered_at, trigger_count, error_count, is_local, created_by, - created_at, updated_at + SELECT id, template_id, deployment_hash, source_adapter, source_container, + target_adapter, target_container, target_url, field_mapping_override, + config_override, status, last_triggered_at, trigger_count, error_count, + is_local, created_by, created_at, updated_at FROM pipe_instances WHERE id = $1 "#, @@ -315,10 +317,10 @@ pub async fn list_instances( let query_span = tracing::info_span!("Listing pipe instances for deployment"); sqlx::query_as::<_, PipeInstance>( r#" - SELECT id, template_id, deployment_hash, source_container, target_container, - target_url, field_mapping_override, config_override, status, - last_triggered_at, trigger_count, error_count, is_local, created_by, - created_at, updated_at + SELECT id, template_id, deployment_hash, source_adapter, source_container, + target_adapter, target_container, target_url, field_mapping_override, + config_override, status, last_triggered_at, trigger_count, error_count, + is_local, created_by, created_at, updated_at FROM pipe_instances WHERE deployment_hash = $1 ORDER BY created_at DESC @@ -343,10 +345,10 @@ pub async fn list_local_instances_by_user( let query_span = tracing::info_span!("Listing local pipe instances"); sqlx::query_as::<_, PipeInstance>( r#" - SELECT id, template_id, deployment_hash, source_container, target_container, - target_url, field_mapping_override, config_override, status, - last_triggered_at, trigger_count, error_count, is_local, created_by, - created_at, updated_at + SELECT id, template_id, deployment_hash, source_adapter, source_container, + target_adapter, target_container, target_url, field_mapping_override, + config_override, status, last_triggered_at, trigger_count, error_count, + is_local, created_by, created_at, updated_at FROM pipe_instances WHERE is_local = true AND created_by = $1 ORDER BY created_at DESC @@ -375,10 +377,10 @@ pub async fn update_instance_status( UPDATE pipe_instances SET status = $2, updated_at = NOW() WHERE id = $1 - RETURNING id, template_id, deployment_hash, source_container, target_container, - target_url, field_mapping_override, config_override, status, - last_triggered_at, trigger_count, error_count, is_local, created_by, - created_at, updated_at + RETURNING id, template_id, deployment_hash, source_adapter, source_container, + target_adapter, target_container, target_url, field_mapping_override, + config_override, status, last_triggered_at, trigger_count, error_count, + is_local, created_by, created_at, updated_at "#, ) .bind(id) diff --git a/src/forms/project/port.rs b/src/forms/project/port.rs index 27cbe30d..5078e2f9 100644 --- a/src/forms/project/port.rs +++ b/src/forms/project/port.rs @@ -50,13 +50,14 @@ fn validate_non_empty(v: &Option) -> Result<(), serde_valid::validation: impl TryInto for &Port { type Error = String; fn try_into(self) -> Result { - let cp = self + let normalized = normalize_port_mapping(self); + + let cp = normalized .container_port - .clone() .parse::() .map_err(|_err| "Could not parse container port".to_string())?; - let hp = match self.host_port.clone() { + let hp = match normalized.host_port { Some(hp) => { if hp.is_empty() { None @@ -77,14 +78,52 @@ impl TryInto for &Port { Ok(dctypes::Port { target: cp, - host_ip: None, + host_ip: normalized.host_ip, published: hp, - protocol: None, + protocol: self.protocol.clone(), mode: None, }) } } +struct NormalizedPortMapping { + host_ip: Option, + host_port: Option, + container_port: String, +} + +fn normalize_port_mapping(port: &Port) -> NormalizedPortMapping { + let container_no_proto = port + .container_port + .split('/') + .next() + .unwrap_or(port.container_port.as_str()); + + if let Some((host_part, container_port)) = container_no_proto.rsplit_once(':') { + let (host_ip, host_port) = match host_part.rsplit_once(':') { + Some((ip, published)) => (Some(ip.to_string()), Some(published.to_string())), + None => match port.host_port.as_deref() { + Some(host) if host.parse::().is_err() => { + (Some(host.to_string()), Some(host_part.to_string())) + } + _ => (None, Some(host_part.to_string())), + }, + }; + + return NormalizedPortMapping { + host_ip, + host_port, + container_port: container_port.to_string(), + }; + } + + NormalizedPortMapping { + host_ip: None, + host_port: port.host_port.clone(), + container_port: container_no_proto.to_string(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -126,6 +165,47 @@ mod tests { assert_eq!(dc_port.target, 80); } + #[test] + fn test_port_try_into_accepts_host_ip_mapping_in_container_port() { + let port = Port { + host_port: Some("127.0.0.1".to_string()), + container_port: "1025:25".to_string(), + protocol: Some("tcp".to_string()), + }; + + let result: Result = (&port).try_into(); + + assert!(result.is_ok()); + let dc_port = result.unwrap(); + assert_eq!(dc_port.target, 25); + assert_eq!(dc_port.host_ip.as_deref(), Some("127.0.0.1")); + assert_eq!( + dc_port.published, + Some(dctypes::PublishedPort::Single(1025)) + ); + assert_eq!(dc_port.protocol.as_deref(), Some("tcp")); + } + + #[test] + fn test_port_try_into_accepts_full_compose_mapping_in_container_port() { + let port = Port { + host_port: None, + container_port: "127.0.0.1:1025:25/tcp".to_string(), + protocol: None, + }; + + let result: Result = (&port).try_into(); + + assert!(result.is_ok()); + let dc_port = result.unwrap(); + assert_eq!(dc_port.target, 25); + assert_eq!(dc_port.host_ip.as_deref(), Some("127.0.0.1")); + assert_eq!( + dc_port.published, + Some(dctypes::PublishedPort::Single(1025)) + ); + } + #[test] fn test_port_try_into_no_host_port() { let port = Port { diff --git a/src/forms/project/var.rs b/src/forms/project/var.rs index f959b10a..e681b3ae 100644 --- a/src/forms/project/var.rs +++ b/src/forms/project/var.rs @@ -1,5 +1,33 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value; #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct Var {} +pub struct Var { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +#[cfg(test)] +mod tests { + use super::Var; + use serde_json::json; + + #[test] + fn preserves_key_value_entries() { + let parsed: Var = serde_json::from_value(json!({ + "key": "status_panel_only", + "value": "true" + })) + .expect("var should deserialize"); + + assert_eq!(parsed.key.as_deref(), Some("status_panel_only")); + assert_eq!(parsed.value, Some(json!("true"))); + + let serialized = serde_json::to_value(parsed).expect("var should serialize"); + assert_eq!(serialized["key"], json!("status_panel_only")); + assert_eq!(serialized["value"], json!("true")); + } +} diff --git a/src/forms/remote_secret.rs b/src/forms/remote_secret.rs index c73a53c3..cc6edd2a 100644 --- a/src/forms/remote_secret.rs +++ b/src/forms/remote_secret.rs @@ -21,6 +21,7 @@ pub struct RemoteSecretMetadataResponse { pub id: i32, pub scope: String, pub name: String, + pub secure: bool, pub project_id: Option, pub app_code: Option, pub server_id: Option, @@ -35,6 +36,7 @@ impl From for RemoteSecretMetadataResponse { id: value.id, scope: value.scope, name: value.name, + secure: true, project_id: value.project_id, app_code: value.app_code, server_id: value.server_id, @@ -44,3 +46,31 @@ impl From for RemoteSecretMetadataResponse { } } } + +#[cfg(test)] +mod tests { + use super::RemoteSecretMetadataResponse; + use crate::models::RemoteSecret; + use chrono::Utc; + + #[test] + fn remote_secret_metadata_is_marked_secure() { + let response = RemoteSecretMetadataResponse::from(RemoteSecret { + id: 7, + user_id: "user-1".to_string(), + project_id: Some(42), + app_code: Some("api".to_string()), + server_id: None, + scope: "service".to_string(), + name: "MYSECURE_PASSPHRASE".to_string(), + vault_path: "secret/path".to_string(), + updated_by: "user-1".to_string(), + last_sync_status: "synced".to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + }); + + assert!(response.secure); + assert_eq!(response.source, "vault"); + } +} diff --git a/src/forms/status_panel.rs b/src/forms/status_panel.rs index 1b161487..8e47f104 100644 --- a/src/forms/status_panel.rs +++ b/src/forms/status_panel.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Utc}; +use pipe_adapter_sdk::PipeAdapterReference; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -581,10 +582,14 @@ pub fn validate_command_parameters( if params.pipe_instance_id.trim().is_empty() { return Err("activate_pipe: pipe_instance_id is required".to_string()); } - // Validate target: at least one of target_container or target_url - if params.target_container.is_none() && params.target_url.is_none() { + // Validate target: at least one of target_container, target_url, or target_adapter + if params.target_container.is_none() + && params.target_url.is_none() + && params.target_adapter.is_none() + { return Err( - "activate_pipe: either target_container or target_url is required".to_string(), + "activate_pipe: either target_container, target_url, or target_adapter is required" + .to_string(), ); } // Validate trigger_type @@ -870,7 +875,11 @@ pub fn validate_command_result( // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ fn default_probe_protocols() -> Vec { - vec!["openapi".to_string(), "rest".to_string()] + vec![ + "openapi".to_string(), + "html_forms".to_string(), + "rest".to_string(), + ] } fn default_probe_timeout() -> u32 { @@ -921,6 +930,21 @@ pub struct ProbeOperation { pub sample_response: Option, } +/// Metadata about an attempted probe run or probe target. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ProbeAttempt { + #[serde(default)] + pub scope: String, + #[serde(default)] + pub selector: Option, + #[serde(default)] + pub container: Option, + #[serde(default)] + pub protocols: Vec, + #[serde(default)] + pub outcome: String, +} + /// A discovered HTML form #[derive(Debug, Deserialize, Serialize, Clone)] pub struct ProbeForm { @@ -990,11 +1014,17 @@ pub struct ProbeEndpointsCommandReport { pub app_code: String, pub protocols_detected: Vec, #[serde(default)] + pub protocols_requested: Vec, + #[serde(default)] pub containers: Vec, pub endpoints: Vec, #[serde(default)] pub resources: Vec, pub forms: Vec, + #[serde(default)] + pub probe_attempts: Vec, + #[serde(default)] + pub target_kind: Option, pub probed_at: String, } @@ -1007,6 +1037,9 @@ pub struct ProbeEndpointsCommandReport { pub struct ActivatePipeCommandRequest { /// UUID of the pipe instance to activate pub pipe_instance_id: String, + /// Optional typed source adapter reference for connector-style transports + #[serde(default)] + pub source_adapter: Option, /// Source container name #[serde(default)] pub source_container: Option, @@ -1034,6 +1067,9 @@ pub struct ActivatePipeCommandRequest { /// Target external URL (for external pipes) #[serde(default)] pub target_url: Option, + /// Optional typed target adapter reference for connector-style transports + #[serde(default)] + pub target_adapter: Option, /// Target endpoint path #[serde(default = "default_pipe_target_endpoint")] pub target_endpoint: String, @@ -1088,6 +1124,9 @@ pub struct TriggerPipeCommandRequest { /// Optional input data to feed into the pipe (overrides source fetch) #[serde(default)] pub input_data: Option, + /// Optional typed source adapter reference for connector-style transports + #[serde(default)] + pub source_adapter: Option, /// Optional source container override #[serde(default)] pub source_container: Option, @@ -1100,6 +1139,9 @@ pub struct TriggerPipeCommandRequest { /// Optional external target override #[serde(default)] pub target_url: Option, + /// Optional typed target adapter reference for connector-style transports + #[serde(default)] + pub target_adapter: Option, /// Optional internal target override #[serde(default)] pub target_container: Option, @@ -1202,12 +1244,18 @@ mod tests { "activate_pipe.rabbitmq.command.json" => include_str!( "../../tests/fixtures/pipe-contract/activate_pipe.rabbitmq.command.json" ), + "activate_pipe.adapter.command.json" => include_str!( + "../../tests/fixtures/pipe-contract/activate_pipe.adapter.command.json" + ), "deactivate_pipe.command.json" => { include_str!("../../tests/fixtures/pipe-contract/deactivate_pipe.command.json") } "trigger_pipe.manual.command.json" => { include_str!("../../tests/fixtures/pipe-contract/trigger_pipe.manual.command.json") } + "trigger_pipe.adapter.command.json" => { + include_str!("../../tests/fixtures/pipe-contract/trigger_pipe.adapter.command.json") + } "trigger_pipe.replay.command.json" => { include_str!("../../tests/fixtures/pipe-contract/trigger_pipe.replay.command.json") } @@ -1226,6 +1274,9 @@ mod tests { "trigger_pipe.replay.report.json" => { include_str!("../../tests/fixtures/pipe-contract/trigger_pipe.replay.report.json") } + "trigger_pipe.smtp_adapter.report.json" => include_str!( + "../../tests/fixtures/pipe-contract/trigger_pipe.smtp_adapter.report.json" + ), "npm_credentials.v1_email_password.json" => { include_str!("../../tests/fixtures/npm_credentials/v1_email_password.json") } @@ -1427,8 +1478,12 @@ mod tests { .expect("probe_endpoints params must be present"); assert_eq!(params["app_code"], "crm"); - assert_eq!(params["protocols"], json!(["openapi", "rest"])); + assert_eq!( + params["protocols"], + json!(["openapi", "html_forms", "rest"]) + ); assert_eq!(params["probe_timeout"], 5); + assert_eq!(params["capture_samples"], false); } #[test] @@ -1556,6 +1611,44 @@ mod tests { assert!(result.is_some()); } + #[test] + fn probe_endpoints_result_accepts_metadata_fields() { + let result = validate_command_result( + "probe_endpoints", + "hash_a", + &Some(json!({ + "type": "probe_endpoints", + "deployment_hash": "hash_a", + "app_code": "crm", + "protocols_detected": ["html_forms"], + "protocols_requested": ["html_forms"], + "endpoints": [], + "resources": [], + "forms": [{ + "id": "contact", + "action": "/contact", + "method": "POST", + "fields": ["name", "email"] + }], + "probe_attempts": [{ + "scope": "remote_app", + "selector": "crm", + "container": "crm-web", + "protocols": ["html_forms"], + "outcome": "detected" + }], + "target_kind": "html_form", + "probed_at": "2026-03-20T12:00:00Z" + })), + ) + .expect("valid metadata result should pass") + .expect("result payload should be present"); + + assert_eq!(result["protocols_requested"], json!(["html_forms"])); + assert_eq!(result["probe_attempts"][0]["scope"], "remote_app"); + assert_eq!(result["target_kind"], "html_form"); + } + // ── check_connections ──────────────────────────────────────────── #[test] @@ -1757,6 +1850,29 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn activate_pipe_accepts_adapter_references() { + let result = validate_command_parameters( + "activate_pipe", + &Some(json!({ + "pipe_instance_id": "abc-123", + "source_adapter": { + "code": "imap", + "role": "source", + "config": { "mailbox": "INBOX" } + }, + "target_adapter": { + "code": "smtp", + "role": "target", + "config": { "host": "smtp" } + }, + "target_url": "https://bridge.internal/pipes/contact", + "trigger_type": "webhook" + })), + ); + assert!(result.is_ok()); + } + #[test] fn activate_pipe_accepts_shared_webhook_fixture() { let result = validate_command_parameters( @@ -1775,6 +1891,15 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn activate_pipe_accepts_shared_adapter_fixture() { + let result = validate_command_parameters( + "activate_pipe", + &Some(fixture("activate_pipe.adapter.command.json")), + ); + assert!(result.is_ok()); + } + #[test] fn trigger_pipe_requires_instance_id() { let err = @@ -1782,6 +1907,26 @@ mod tests { assert!(err.is_err()); } + #[test] + fn trigger_pipe_accepts_adapter_references() { + let result = validate_command_parameters( + "trigger_pipe", + &Some(json!({ + "pipe_instance_id": "abc-123", + "source_adapter": { + "code": "pop3", + "role": "source" + }, + "target_adapter": { + "code": "smtp", + "role": "target" + }, + "trigger_type": "manual" + })), + ); + assert!(result.is_ok()); + } + #[test] fn trigger_pipe_accepts_valid_params() { let result = validate_command_parameters( @@ -1802,6 +1947,15 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn trigger_pipe_accepts_shared_adapter_fixture() { + let result = validate_command_parameters( + "trigger_pipe", + &Some(fixture("trigger_pipe.adapter.command.json")), + ); + assert!(result.is_ok()); + } + #[test] fn trigger_pipe_accepts_shared_replay_fixture() { let result = validate_command_parameters( @@ -2126,6 +2280,20 @@ mod tests { assert_eq!(payload["lifecycle"]["trigger_count"], 2); } + #[test] + fn trigger_pipe_smtp_adapter_result_accepts_shared_fixture() { + let result = validate_command_result( + "trigger_pipe", + "dep-123", + &Some(fixture("trigger_pipe.smtp_adapter.report.json")), + ); + assert!(result.is_ok()); + let payload = result.expect("fixture should validate").expect("payload"); + assert_eq!(payload["target_response"]["transport"], "smtp"); + assert_eq!(payload["target_response"]["adapter"], "smtp"); + assert_eq!(payload["target_response"]["delivered"], true); + } + #[test] fn trigger_pipe_result_trigger_type_defaults_manual() { let result = validate_command_result( diff --git a/src/helpers/env_path.rs b/src/helpers/env_path.rs index 414621a6..5012ce46 100644 --- a/src/helpers/env_path.rs +++ b/src/helpers/env_path.rs @@ -1,5 +1,6 @@ pub const REMOTE_RUNTIME_ENV_PATH: &str = "/home/trydirect/project/.env"; 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 @@ -9,6 +10,10 @@ pub fn compose_env_file_reference() -> &'static str { REMOTE_RUNTIME_ENV_FILE } +pub fn remote_runtime_compose_path() -> &'static str { + REMOTE_RUNTIME_COMPOSE_PATH +} + #[cfg(test)] mod tests { use super::*; @@ -22,4 +27,12 @@ mod tests { fn compose_env_file_reference_is_relative() { assert_eq!(compose_env_file_reference(), ".env"); } + + #[test] + fn remote_runtime_compose_path_is_canonical() { + assert_eq!( + remote_runtime_compose_path(), + "/home/trydirect/project/docker-compose.yml" + ); + } } diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index dcfd127b..9f4bb481 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -12,6 +12,7 @@ pub mod vault; pub use agent_capabilities::*; pub use agent_client::*; pub use db_pools::*; +pub use env_path::*; pub use json::*; pub use mq_manager::*; pub use ssh_client::*; @@ -22,6 +23,7 @@ pub mod dockerhub; pub mod env_path; pub mod fs; pub(crate) mod ip; +pub mod stacker_labels; pub use dockerhub::*; diff --git a/src/helpers/stacker_labels.rs b/src/helpers/stacker_labels.rs new file mode 100644 index 00000000..0289979f --- /dev/null +++ b/src/helpers/stacker_labels.rs @@ -0,0 +1,29 @@ +use std::collections::HashMap; + +pub const PROJECT_ID: &str = "my.stacker.project_id"; +pub const TARGET: &str = "my.stacker.target"; +pub const SCOPE: &str = "my.stacker.scope"; +pub const SERVICE: &str = "my.stacker.service"; +pub const DNS: &str = "my.stacker.dns"; + +pub const SCOPE_PROJECT: &str = "project"; +pub const SCOPE_PLATFORM: &str = "platform"; + +pub fn insert_runtime_labels( + labels: &mut HashMap, + project_id: Option, + target: Option<&str>, + scope: &str, + service: &str, + dns: &str, +) { + if let Some(project_id) = project_id { + labels.insert(PROJECT_ID.to_string(), project_id.to_string()); + } + if let Some(target) = target.filter(|value| !value.trim().is_empty()) { + labels.insert(TARGET.to_string(), target.to_string()); + } + labels.insert(SCOPE.to_string(), scope.to_string()); + labels.insert(SERVICE.to_string(), service.to_string()); + labels.insert(DNS.to_string(), dns.to_string()); +} diff --git a/src/lib.rs b/src/lib.rs index a8b00c02..618e34be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,66 @@ +#![allow( + dead_code, + unused_imports, + clippy::bool_comparison, + clippy::collapsible_if, + clippy::collapsible_match, + clippy::collapsible_str_replace, + clippy::comparison_to_empty, + clippy::complexity, + clippy::cmp_owned, + clippy::derivable_impls, + clippy::double_ended_iterator_last, + clippy::field_reassign_with_default, + clippy::filter_map_bool_then, + clippy::format_in_format_args, + clippy::from_over_into, + clippy::get_last_with_len, + clippy::if_same_then_else, + clippy::inherent_to_string, + clippy::inefficient_to_string, + clippy::into_iter_on_ref, + clippy::io_other_error, + clippy::iter_kv_map, + clippy::items_after_test_module, + clippy::len_zero, + clippy::let_underscore_future, + clippy::manual_clamp, + clippy::manual_contains, + clippy::manual_pattern_char_comparison, + clippy::manual_range_contains, + clippy::manual_split_once, + clippy::manual_strip, + clippy::map_identity, + clippy::match_like_matches_macro, + clippy::match_single_binding, + clippy::needless_borrow, + clippy::needless_return, + clippy::new_without_default, + clippy::nonminimal_bool, + clippy::option_map_unit_fn, + clippy::ptr_arg, + clippy::print_literal, + clippy::redundant_closure, + clippy::redundant_field_names, + clippy::single_char_add_str, + clippy::single_match, + clippy::should_implement_trait, + clippy::too_many_arguments, + clippy::type_complexity, + clippy::unnecessary_cast, + clippy::unnecessary_map_or, + clippy::unnecessary_unwrap, + clippy::unnecessary_lazy_evaluations, + clippy::unused_unit, + clippy::unwrap_or_default, + clippy::useless_conversion, + clippy::useless_format, + clippy::useless_vec, + clippy::write_literal, + clippy::wrong_self_convention, + clippy::for_kv_map +)] + pub mod banner; pub mod cli; pub mod configuration; diff --git a/src/main.rs b/src/main.rs index 7d11476a..876eb0eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,8 +75,8 @@ async fn main() -> std::io::Result<()> { let address = format!("{}:{}", settings.app_host, settings.app_port); banner::print_startup_info(&settings.app_host, settings.app_port); tracing::info!("Start server at {:?}", &address); - let listener = - TcpListener::bind(address).expect(&format!("failed to bind to {}", settings.app_port)); + let listener = TcpListener::bind(address) + .unwrap_or_else(|_| panic!("failed to bind to {}", settings.app_port)); run(listener, api_pool, agent_pool, settings).await?.await } diff --git a/src/mcp/protocol.rs b/src/mcp/protocol.rs index c7e982e0..1700d43c 100644 --- a/src/mcp/protocol.rs +++ b/src/mcp/protocol.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::services::TypedErrorEnvelope; + /// JSON-RPC 2.0 Request structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JsonRpcRequest { @@ -150,6 +152,15 @@ impl CallToolResponse { is_error: Some(true), } } + + pub fn typed_error(error: TypedErrorEnvelope) -> Self { + Self { + content: vec![ToolContent::Text { + text: error.to_pretty_json(), + }], + is_error: Some(true), + } + } } /// Tool execution result content diff --git a/src/mcp/protocol_tests.rs b/src/mcp/protocol_tests.rs index 82f4bdcb..a9255c5c 100644 --- a/src/mcp/protocol_tests.rs +++ b/src/mcp/protocol_tests.rs @@ -5,6 +5,7 @@ mod tests { JsonRpcRequest, JsonRpcResponse, ServerCapabilities, ServerInfo, Tool, ToolContent, ToolsCapability, }; + use crate::services::TypedErrorEnvelope; #[test] fn test_json_rpc_request_deserialize() { @@ -108,6 +109,24 @@ mod tests { assert_eq!(response.is_error, Some(true)); } + #[test] + fn test_call_tool_response_typed_error() { + let response = CallToolResponse::typed_error(TypedErrorEnvelope::deployment_not_found( + "Deployment not found", + )); + + assert_eq!(response.content.len(), 1); + assert_eq!(response.is_error, Some(true)); + + match &response.content[0] { + ToolContent::Text { text } => { + assert!(text.contains("deployment_not_found")); + assert!(text.contains("schemaVersion")); + } + _ => panic!("Expected text content"), + } + } + #[test] fn test_initialize_params_deserialize() { let json = r#"{ diff --git a/src/mcp/registry.rs b/src/mcp/registry.rs index 3e51ef51..6ba32fc4 100644 --- a/src/mcp/registry.rs +++ b/src/mcp/registry.rs @@ -13,6 +13,7 @@ use std::sync::Arc; use super::protocol::{Tool, ToolContent}; use crate::mcp::tools::{ + ActivatePipeTool, AddAppToDeploymentTool, AddCloudTool, AdminApproveTemplateTool, @@ -22,6 +23,7 @@ use crate::mcp::tools::{ AdminListTemplateVersionsTool, AdminRejectTemplateTool, AdminValidateTemplateSecurityTool, + ApplyDeploymentPlanTool, ApplyVaultConfigTool, CancelDeploymentTool, CloneProjectTool, @@ -31,8 +33,11 @@ use crate::mcp::tools::{ // Agent Control tools ConfigureProxyAgentTool, ConfigureProxyTool, + CreatePipeInstanceTool, + CreatePipeTemplateTool, CreateProjectAppTool, CreateProjectTool, + DeactivatePipeTool, DeleteAppEnvVarTool, DeleteCloudTool, DeleteProjectTool, @@ -45,6 +50,10 @@ use crate::mcp::tools::{ DiscoverStackServicesTool, EscalateToSupportTool, // Agent Control tools + ExecuteAgentCommandTool, + ExplainEnvTool, + ExplainTopologyTool, + GetAgentCommandHistoryTool, GetAgentStatusTool, GetAnsibleRoleDefaultsTool, GetAppConfigTool, @@ -54,13 +63,18 @@ use crate::mcp::tools::{ GetContainerExecTool, GetContainerHealthTool, GetContainerLogsTool, + GetDeploymentEventsTool, + GetDeploymentPlanTool, GetDeploymentResourcesTool, + GetDeploymentStateTool, GetDeploymentStatusTool, GetDockerComposeYamlTool, GetErrorSummaryTool, GetInstallationDetailsTool, GetLiveChatInfoTool, GetNotificationsTool, + GetPipeHistoryTool, + GetPipeTool, GetProjectTool, GetRemoteServiceSecretTool, GetRoleDetailsTool, @@ -79,6 +93,8 @@ use crate::mcp::tools::{ ListContainersTool, ListFirewallRulesTool, ListInstallationsTool, + ListPipeTemplatesTool, + ListPipesTool, ListProjectAppsTool, ListProjectsTool, ListProxiesTool, @@ -93,6 +109,8 @@ use crate::mcp::tools::{ RecommendStackServicesTool, RemoveAppTool, RenderAnsibleTemplateTool, + ReplayPipeExecutionTool, + RequestServerSnapshotTool, RestartContainerTool, SearchApplicationsTool, SearchMarketplaceTemplatesTool, @@ -104,6 +122,7 @@ use crate::mcp::tools::{ // Phase 5: Container Operations tools StopContainerTool, SuggestResourcesTool, + TriggerPipeTool, TriggerRedeployTool, UpdateAppDomainTool, UpdateAppPortsTool, @@ -134,8 +153,10 @@ const MFA_REQUIRED_TOOLS: &[&str] = &[ "create_project_app", "start_deployment", "cancel_deployment", + "apply_deployment_plan", "add_cloud", "delete_cloud", + "request_server_snapshot", "delete_project", "clone_project", "mark_notification_read", @@ -167,6 +188,13 @@ const MFA_REQUIRED_TOOLS: &[&str] = &[ "configure_proxy_agent", "configure_firewall", "configure_firewall_from_role", + "execute_agent_command", + "create_pipe_template", + "create_pipe_instance", + "replay_pipe_execution", + "activate_pipe", + "deactivate_pipe", + "trigger_pipe", ]; /// Trait for tool handlers @@ -204,6 +232,12 @@ impl ToolRegistry { // Phase 3: Deployment tools registry.register("get_deployment_status", Box::new(GetDeploymentStatusTool)); + registry.register("get_deployment_state", Box::new(GetDeploymentStateTool)); + registry.register("get_deployment_plan", Box::new(GetDeploymentPlanTool)); + registry.register("get_deployment_events", Box::new(GetDeploymentEventsTool)); + registry.register("apply_deployment_plan", Box::new(ApplyDeploymentPlanTool)); + registry.register("explain_env", Box::new(ExplainEnvTool)); + registry.register("explain_topology", Box::new(ExplainTopologyTool)); registry.register("start_deployment", Box::new(StartDeploymentTool)); registry.register("cancel_deployment", Box::new(CancelDeploymentTool)); @@ -218,6 +252,10 @@ impl ToolRegistry { Box::new(ListCloudServerSizesTool), ); registry.register("list_cloud_images", Box::new(ListCloudImagesTool)); + registry.register( + "request_server_snapshot", + Box::new(RequestServerSnapshotTool), + ); // Phase 3: Project management registry.register("delete_project", Box::new(DeleteProjectTool)); @@ -328,6 +366,18 @@ impl ToolRegistry { Box::new(DeleteRemoteServiceSecretTool), ); + // Pipe tools + registry.register("list_pipes", Box::new(ListPipesTool)); + registry.register("get_pipe", Box::new(GetPipeTool)); + registry.register("list_pipe_templates", Box::new(ListPipeTemplatesTool)); + registry.register("create_pipe_template", Box::new(CreatePipeTemplateTool)); + registry.register("create_pipe_instance", Box::new(CreatePipeInstanceTool)); + registry.register("get_pipe_history", Box::new(GetPipeHistoryTool)); + registry.register("replay_pipe_execution", Box::new(ReplayPipeExecutionTool)); + registry.register("activate_pipe", Box::new(ActivatePipeTool)); + registry.register("deactivate_pipe", Box::new(DeactivatePipeTool)); + registry.register("trigger_pipe", Box::new(TriggerPipeTool)); + // Phase 7: Advanced Monitoring & Troubleshooting tools registry.register( "get_docker_compose_yaml", @@ -378,6 +428,11 @@ impl ToolRegistry { registry.register("remove_app", Box::new(RemoveAppTool)); registry.register("configure_proxy_agent", Box::new(ConfigureProxyAgentTool)); registry.register("get_agent_status", Box::new(GetAgentStatusTool)); + registry.register( + "get_agent_command_history", + Box::new(GetAgentCommandHistoryTool), + ); + registry.register("execute_agent_command", Box::new(ExecuteAgentCommandTool)); // Firewall (iptables) management tools registry.register("configure_firewall", Box::new(ConfigureFirewallTool)); @@ -518,10 +573,30 @@ mod tests { .expect("deploy app should require policy"); assert!(deploy_policy.requires_mfa); + let apply_plan_policy = registry + .access_policy("apply_deployment_plan") + .expect("deployment plan apply should require policy"); + assert!(apply_plan_policy.requires_mfa); + let admin_validate_policy = registry .access_policy("admin_validate_template_security") .expect("admin security validation should require policy"); assert!(admin_validate_policy.requires_mfa); + + let exec_policy = registry + .access_policy("execute_agent_command") + .expect("raw agent exec should require policy"); + assert!(exec_policy.requires_mfa); + + let activate_policy = registry + .access_policy("activate_pipe") + .expect("pipe activation should require policy"); + assert!(activate_policy.requires_mfa); + + let replay_policy = registry + .access_policy("replay_pipe_execution") + .expect("pipe replay should require policy"); + assert!(replay_policy.requires_mfa); } #[test] @@ -539,6 +614,21 @@ mod tests { .expect("get tool should require policy"); assert_eq!(get_policy.object, "/mcp/tools/get_remote_service_secret"); assert!(!get_policy.requires_mfa); + + let history_policy = registry + .access_policy("get_agent_command_history") + .expect("history tool should require policy"); + assert_eq!( + history_policy.object, + "/mcp/tools/get_agent_command_history" + ); + assert!(!history_policy.requires_mfa); + + let list_pipes_policy = registry + .access_policy("list_pipes") + .expect("pipe list should require policy"); + assert_eq!(list_pipes_policy.object, "/mcp/tools/list_pipes"); + assert!(!list_pipes_policy.requires_mfa); } #[test] diff --git a/src/mcp/tools/agent_control.rs b/src/mcp/tools/agent_control.rs index c78976d0..46d08183 100644 --- a/src/mcp/tools/agent_control.rs +++ b/src/mcp/tools/agent_control.rs @@ -112,6 +112,61 @@ async fn enqueue_and_wait( } } +async fn enqueue_request_and_wait( + context: &ToolContext, + request: &crate::cli::stacker_client::AgentEnqueueRequest, + timeout_secs: u64, +) -> Result { + let command_id = uuid::Uuid::new_v4().to_string(); + let mut command = Command::new( + command_id.clone(), + request.deployment_hash.clone(), + request.command_type.clone(), + context.user.id.clone(), + ); + if let Some(parameters) = request.parameters.clone() { + command = command.with_parameters(parameters); + } + if let Some(timeout_seconds) = request.timeout_seconds { + command = command.with_timeout(timeout_seconds); + } + + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &request.deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + if let Some(cmd) = + wait_for_command_result(&context.pg_pool, &command.command_id, timeout_secs).await? + { + let status = cmd.status.to_lowercase(); + Ok(json!({ + "status": status, + "command_id": cmd.command_id, + "deployment_hash": request.deployment_hash, + "command_type": request.command_type, + "result": cmd.result, + "error": cmd.error, + })) + } else { + Ok(json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": request.deployment_hash, + "command_type": request.command_type, + "message": "Command queued. Agent will process shortly.", + })) + } +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Deploy App Tool // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -498,3 +553,188 @@ impl ToolHandler for GetAgentStatusTool { } } } + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Get Agent Command History Tool +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct GetAgentCommandHistoryTool; + +#[async_trait] +impl ToolHandler for GetAgentCommandHistoryTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default)] + limit: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + let commands = db::command::fetch_by_deployment(&context.pg_pool, &deployment_hash).await?; + let limit = params.limit.unwrap_or(20); + let commands: Vec = commands + .into_iter() + .take(limit) + .map(|command| { + json!({ + "command_id": command.command_id, + "type": command.r#type, + "status": command.status, + "priority": command.priority, + "created_at": command.created_at.to_rfc3339(), + "updated_at": command.updated_at.to_rfc3339(), + "parameters": command.parameters, + "result": command.result, + "error": command.error, + "timeout_seconds": command.timeout_seconds, + }) + }) + .collect(); + + Ok(ToolContent::Text { + text: json!({ + "status": "ok", + "deployment_hash": deployment_hash, + "commands": commands, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_agent_command_history".to_string(), + description: "List recent commands queued for a deployment's Status Panel agent, including status, timestamps, and any reported result or error.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash" + }, + "limit": { + "type": "integer", + "description": "Maximum number of commands to return (default: 20)" + } + } + }), + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Execute Agent Command Tool +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct ExecuteAgentCommandTool; + +#[async_trait] +impl ToolHandler for ExecuteAgentCommandTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + command_type: String, + #[serde(default)] + parameters: Option, + #[serde(default)] + timeout_seconds: Option, + #[serde(default)] + wait_timeout_seconds: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + let mut request = crate::cli::stacker_client::AgentEnqueueRequest::new( + &deployment_hash, + ¶ms.command_type, + ); + if let Some(parameters) = params.parameters { + request = request.with_raw_parameters(parameters); + } + if let Some(timeout_seconds) = params.timeout_seconds { + request = request.with_timeout(timeout_seconds); + } + + let result = enqueue_request_and_wait( + context, + &request, + params + .wait_timeout_seconds + .unwrap_or(COMMAND_RESULT_TIMEOUT_SECS), + ) + .await?; + + Ok(ToolContent::Text { + text: result.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "execute_agent_command".to_string(), + description: "Queue a raw command for the Status Panel agent and optionally wait for the result. Use for advanced operations not covered by a dedicated MCP tool.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash" + }, + "command_type": { + "type": "string", + "description": "Raw agent command type to enqueue" + }, + "parameters": { + "description": "Optional raw JSON parameters for the command", + "oneOf": [ + { "type": "object" }, + { "type": "array" }, + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { "type": "null" } + ], + }, + "timeout_seconds": { + "type": "number", + "description": "Optional agent-side timeout to store with the command request" + }, + "wait_timeout_seconds": { + "type": "number", + "description": "How long MCP should wait for a terminal command result before returning queued status (default: 15)" + } + }, + "required": ["command_type"] + }), + } + } +} diff --git a/src/mcp/tools/cloud.rs b/src/mcp/tools/cloud.rs index ccb3a52a..15185dc8 100644 --- a/src/mcp/tools/cloud.rs +++ b/src/mcp/tools/cloud.rs @@ -1,8 +1,11 @@ use async_trait::async_trait; use serde_json::{json, Value}; -use crate::connectors::fetch_app_service_catalog; +use crate::connectors::{ + fetch_app_service_catalog, HetznerCloudClient, HetznerCloudConnector, HetznerSnapshotTarget, +}; use crate::db; +use crate::forms::CloudForm; use crate::mcp::protocol::{Tool, ToolContent}; use crate::mcp::registry::{ToolContext, ToolHandler}; use crate::models; @@ -412,3 +415,259 @@ impl ToolHandler for ListCloudImagesTool { } } } + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Server Snapshot Tool +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Deserialize)] +struct RequestServerSnapshotArgs { + #[serde(default)] + server_id: Option, + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default)] + provider_server_id: Option, + #[serde(default)] + description: Option, + #[serde(default)] + reason: Option, + #[serde(default)] + confirm_snapshot: Option, +} + +pub struct RequestServerSnapshotTool; + +#[async_trait] +impl ToolHandler for RequestServerSnapshotTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let params: RequestServerSnapshotArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + if params.confirm_snapshot != Some(true) { + let response = json!({ + "status": "confirmation_required", + "snapshot_required": true, + "message": "Creating a cloud snapshot is a provider write operation. Re-run with confirm_snapshot=true after user approval before risky remote troubleshooting.", + "risky_operations": [ + "remote_exec", + "direct_ssh_remediation", + "restart_container", + "stop_container", + "remove_app", + "deploy_app_with_force_overwrite", + "proxy_or_firewall_changes" + ], + "required_argument": "confirm_snapshot" + }); + return Ok(ToolContent::Text { + text: response.to_string(), + }); + } + + let server = resolve_snapshot_server(context, ¶ms).await?; + let cloud_id = server.cloud_id.ok_or_else(|| { + "Server has no linked cloud credential for snapshot creation".to_string() + })?; + let cloud = db::cloud::fetch(&context.pg_pool, cloud_id) + .await + .map_err(|e| format!("Cloud error: {}", e))? + .ok_or_else(|| "Linked cloud credential not found".to_string())?; + if cloud.user_id != context.user.id { + return Err("Unauthorized: cloud credential does not belong to this user".to_string()); + } + + let provider = normalize_snapshot_provider(&cloud.provider); + if provider != "hetzner" { + return Err(format!( + "Server snapshots are currently supported for Hetzner only; provider was {}", + cloud.provider + )); + } + + let cloud = if cloud.save_token == Some(true) { + CloudForm::decode_model(cloud, true) + } else { + cloud + }; + let token = cloud + .cloud_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Hetzner snapshot requires a valid saved cloud token".to_string())?; + + let description = params.description.clone().unwrap_or_else(|| { + let reason = params + .reason + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("AI-assisted troubleshooting"); + format!( + "Stacker pre-troubleshooting snapshot for server {}: {}", + server.id, reason + ) + }); + + let target = HetznerSnapshotTarget { + provider_server_id: params.provider_server_id, + server_name: server + .name + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + public_ip: server + .srv_ip + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + }; + + if target.provider_server_id.is_none() + && target.server_name.is_none() + && target.public_ip.is_none() + { + return Err( + "Cannot match Hetzner server: provide provider_server_id or save server name/public IP" + .to_string(), + ); + } + + let connector = HetznerCloudClient::from_env() + .map_err(|e| format!("Failed to initialize Hetzner connector: {}", e))?; + let snapshot = connector + .create_server_snapshot(token, target, &description) + .await + .map_err(|e| format!("Hetzner snapshot request failed: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + server_id = server.id, + cloud_id = cloud_id, + action_id = snapshot.action_id, + image_id = ?snapshot.image_id, + "Requested Hetzner server snapshot via MCP" + ); + + let response = json!({ + "status": "snapshot_requested", + "provider": "hetzner", + "server_id": server.id, + "snapshot": snapshot, + "message": "Hetzner snapshot request accepted. Wait for the action/image to complete before high-risk remediation." + }); + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "request_server_snapshot".to_string(), + description: "Request a cloud snapshot for a remote server before risky AI-assisted troubleshooting. Hetzner is supported first. This is a provider write operation and requires confirm_snapshot=true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "server_id": { + "type": "number", + "description": "Stacker server ID. Preferred when available." + }, + "deployment_id": { + "type": "number", + "description": "Stacker deployment/installation ID to locate the linked server." + }, + "deployment_hash": { + "type": "string", + "description": "Deployment hash to locate the linked server." + }, + "provider_server_id": { + "type": "number", + "description": "Optional Hetzner server ID. If omitted Stacker matches by saved public IP or server name." + }, + "description": { + "type": "string", + "description": "Snapshot description stored at the provider. Do not include secrets." + }, + "reason": { + "type": "string", + "description": "Human-readable reason for the snapshot request." + }, + "confirm_snapshot": { + "type": "boolean", + "description": "Must be true after explicit user approval because this creates a provider snapshot." + } + }, + "required": ["confirm_snapshot"] + }), + } + } +} + +async fn resolve_snapshot_server( + context: &ToolContext, + params: &RequestServerSnapshotArgs, +) -> Result { + if let Some(server_id) = params.server_id { + let server = db::server::fetch(&context.pg_pool, server_id) + .await? + .ok_or_else(|| "Server not found".to_string())?; + if server.user_id != context.user.id { + return Err("Unauthorized: server does not belong to this user".to_string()); + } + return Ok(server); + } + + let deployment = if let Some(hash) = params.deployment_hash.as_deref() { + db::deployment::fetch_by_deployment_hash(&context.pg_pool, hash) + .await? + .ok_or_else(|| "Deployment not found for deployment_hash".to_string())? + } else if let Some(deployment_id) = params.deployment_id { + db::deployment::fetch(&context.pg_pool, deployment_id as i32) + .await? + .ok_or_else(|| "Deployment not found for deployment_id".to_string())? + } else { + return Err("Provide server_id, deployment_id, or deployment_hash".to_string()); + }; + + if deployment.user_id.as_deref() != Some(context.user.id.as_str()) { + let project = db::project::fetch(&context.pg_pool, deployment.project_id) + .await + .map_err(|e| format!("Project lookup failed: {}", e))? + .ok_or_else(|| "Project not found for deployment".to_string())?; + if project.user_id != context.user.id { + return Err("Unauthorized: deployment does not belong to this user".to_string()); + } + } + + let mut servers = db::server::fetch_by_project(&context.pg_pool, deployment.project_id).await?; + servers.retain(|server| server.user_id == context.user.id); + servers + .into_iter() + .find(|server| server.cloud_id.is_some()) + .ok_or_else(|| { + "No cloud-backed server found for deployment project; pass server_id".to_string() + }) +} + +fn normalize_snapshot_provider(provider: &str) -> String { + match provider.trim().to_lowercase().as_str() { + "htz" | "hcloud" | "hetzner_cloud" => "hetzner".to_string(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod snapshot_tool_tests { + use super::normalize_snapshot_provider; + + #[test] + fn snapshot_provider_normalizes_hetzner_aliases() { + assert_eq!(normalize_snapshot_provider("hetzner"), "hetzner"); + assert_eq!(normalize_snapshot_provider("htz"), "hetzner"); + assert_eq!(normalize_snapshot_provider("hcloud"), "hetzner"); + assert_eq!(normalize_snapshot_provider("hetzner_cloud"), "hetzner"); + } +} diff --git a/src/mcp/tools/config.rs b/src/mcp/tools/config.rs index 8a0957cd..e3e6849b 100644 --- a/src/mcp/tools/config.rs +++ b/src/mcp/tools/config.rs @@ -9,12 +9,14 @@ //! Configuration changes are staged and applied on next deployment/restart. use async_trait::async_trait; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeSet, HashSet}; use crate::db; use crate::mcp::protocol::{Tool, ToolContent}; use crate::mcp::registry::{ToolContext, ToolHandler}; -use serde::Deserialize; +use crate::services::env_model::normalize_json_env; +use serde::{Deserialize, Serialize}; /// Get environment variables for an app in a project pub struct GetAppEnvVarsTool; @@ -52,16 +54,26 @@ impl ToolHandler for GetAppEnvVarsTool { .ok_or_else(|| format!("App '{}' not found in project", params.app_code))?; // Parse environment variables from app config - // Redact sensitive values for AI safety let env_vars = app.environment.clone().unwrap_or_default(); - let redacted_env = redact_sensitive_env_vars(&env_vars); + let secure_keys = load_remote_secret_names( + &context.pg_pool, + &context.user.id, + params.project_id, + ¶ms.app_code, + ) + .await?; + let redacted_env = redact_sensitive_env_vars_with_secure_keys(&env_vars, &secure_keys); + let env_entries = build_env_var_entries(&env_vars, &secure_keys); + let secure_count = env_entries.iter().filter(|entry| entry.secure).count(); let result = json!({ "project_id": params.project_id, "app_code": params.app_code, "environment_variables": redacted_env, + "environment_entries": env_entries, "count": redacted_env.as_object().map(|o| o.len()).unwrap_or(0), - "note": "Sensitive values (passwords, tokens, keys) are redacted for security." + "secure_count": secure_count, + "note": "Sensitive values are redacted for security. Vault-backed variables are marked with secure=true." }); tracing::info!( @@ -660,11 +672,91 @@ impl ToolHandler for UpdateAppDomainTool { // Helper functions +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +struct AppEnvVarEntry { + name: String, + value: String, + secure: bool, + redacted: bool, + source: String, +} + +async fn load_remote_secret_names( + pool: &sqlx::PgPool, + user_id: &str, + project_id: i32, + app_code: &str, +) -> Result, String> { + db::remote_secret::list_service_secrets(pool, user_id, project_id, app_code) + .await + .map(|secrets| secrets.into_iter().map(|secret| secret.name).collect()) + .map_err(|error| format!("Failed to load remote service secrets: {}", error)) +} + /// Redact sensitive environment variable values fn redact_sensitive_env_vars(env: &Value) -> Value { + redact_sensitive_env_vars_with_secure_keys(env, &HashSet::new()) +} + +fn redact_sensitive_env_vars_with_secure_keys(env: &Value, secure_keys: &HashSet) -> Value { + let mut normalized = normalize_environment_object(env); + for key in secure_keys { + normalized.insert(key.clone(), json!("[REDACTED]")); + } + + let redacted = normalized + .into_iter() + .map(|(key, value)| { + if should_redact_env_var(&key, secure_keys) { + (key, json!("[REDACTED]")) + } else { + (key, value) + } + }) + .collect(); + + Value::Object(redacted) +} + +fn build_env_var_entries(env: &Value, secure_keys: &HashSet) -> Vec { + let normalized = normalize_environment_object(env); + let mut keys: BTreeSet = normalized.keys().cloned().collect(); + keys.extend(secure_keys.iter().cloned()); + + keys.into_iter() + .map(|name| { + let secure = secure_keys.contains(&name); + let redacted = should_redact_env_var(&name, secure_keys); + let value = if redacted { + "[REDACTED]".to_string() + } else { + stringify_env_value(normalized.get(&name)) + }; + + AppEnvVarEntry { + name, + value, + secure, + redacted, + source: if secure { + "vault".to_string() + } else { + "project".to_string() + }, + } + }) + .collect() +} + +fn should_redact_env_var(name: &str, secure_keys: &HashSet) -> bool { + secure_keys.contains(name) || is_sensitive_env_var_name(name) +} + +fn is_sensitive_env_var_name(name: &str) -> bool { const SENSITIVE_PATTERNS: &[&str] = &[ "password", "passwd", + "username", "secret", "token", "key", @@ -680,25 +772,24 @@ fn redact_sensitive_env_vars(env: &Value) -> Value { "refresh_token", ]; - if let Some(obj) = env.as_object() { - let redacted: serde_json::Map = obj - .iter() - .map(|(k, v)| { - let key_lower = k.to_lowercase(); - let is_sensitive = SENSITIVE_PATTERNS - .iter() - .any(|pattern| key_lower.contains(pattern)); - - if is_sensitive { - (k.clone(), json!("[REDACTED]")) - } else { - (k.clone(), v.clone()) - } - }) - .collect(); - Value::Object(redacted) - } else { - env.clone() + let key_lower = name.to_lowercase(); + SENSITIVE_PATTERNS + .iter() + .any(|pattern| key_lower.contains(pattern)) +} + +fn normalize_environment_object(env: &Value) -> Map { + normalize_json_env(env) + .into_iter() + .map(|(key, value)| (key, Value::String(value))) + .collect() +} + +fn stringify_env_value(value: Option<&Value>) -> String { + match value { + Some(Value::String(text)) => text.clone(), + Some(other) => other.to_string(), + None => String::new(), } } @@ -1185,6 +1276,9 @@ mod tests { "DATABASE_URL": "postgres://localhost", "DB_PASSWORD": "secret123", "API_KEY": "key-abc-123", + "REGISTRY_USERNAME": "registry-user", + "VAULT_TOKEN": "vault-token-value", + "INTERNAL_SERVICES_ACCESS_KEY": "internal-access-key", "LOG_LEVEL": "debug", "PORT": "8080" }); @@ -1195,7 +1289,60 @@ mod tests { assert_eq!(obj.get("DATABASE_URL").unwrap(), "postgres://localhost"); assert_eq!(obj.get("DB_PASSWORD").unwrap(), "[REDACTED]"); assert_eq!(obj.get("API_KEY").unwrap(), "[REDACTED]"); + assert_eq!(obj.get("REGISTRY_USERNAME").unwrap(), "[REDACTED]"); + assert_eq!(obj.get("VAULT_TOKEN").unwrap(), "[REDACTED]"); + assert_eq!( + obj.get("INTERNAL_SERVICES_ACCESS_KEY").unwrap(), + "[REDACTED]" + ); assert_eq!(obj.get("LOG_LEVEL").unwrap(), "debug"); assert_eq!(obj.get("PORT").unwrap(), "8080"); } + + #[test] + fn test_redact_secure_vault_vars_even_without_sensitive_name() { + let env = json!({ + "LOG_LEVEL": "debug" + }); + let secure_keys = HashSet::from([String::from("MYSECURE_PASSPHRASE")]); + + let redacted = redact_sensitive_env_vars_with_secure_keys(&env, &secure_keys); + let obj = redacted.as_object().unwrap(); + + assert_eq!(obj.get("LOG_LEVEL").unwrap(), "debug"); + assert_eq!(obj.get("MYSECURE_PASSPHRASE").unwrap(), "[REDACTED]"); + } + + #[test] + fn test_build_env_var_entries_marks_vault_vars_secure() { + let env = json!({ + "LOG_LEVEL": "debug", + "MYSECURE_TOKEN": "ignored-local" + }); + let secure_keys = HashSet::from([String::from("MYSECURE_PASSPHRASE")]); + + let entries = build_env_var_entries(&env, &secure_keys); + + assert!(entries.contains(&AppEnvVarEntry { + name: "LOG_LEVEL".to_string(), + value: "debug".to_string(), + secure: false, + redacted: false, + source: "project".to_string(), + })); + assert!(entries.contains(&AppEnvVarEntry { + name: "MYSECURE_PASSPHRASE".to_string(), + value: "[REDACTED]".to_string(), + secure: true, + redacted: true, + source: "vault".to_string(), + })); + assert!(entries.contains(&AppEnvVarEntry { + name: "MYSECURE_TOKEN".to_string(), + value: "[REDACTED]".to_string(), + secure: false, + redacted: true, + source: "project".to_string(), + })); + } } diff --git a/src/mcp/tools/deployment.rs b/src/mcp/tools/deployment.rs index bc0bd364..4a98c9fb 100644 --- a/src/mcp/tools/deployment.rs +++ b/src/mcp/tools/deployment.rs @@ -1,59 +1,420 @@ use async_trait::async_trait; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use crate::cli::stacker_client::StackerClient; use crate::connectors::user_service::UserServiceDeploymentResolver; use crate::db; use crate::mcp::protocol::{Tool, ToolContent}; use crate::mcp::registry::{ToolContext, ToolHandler}; -use crate::services::{DeploymentIdentifier, DeploymentResolver}; -use serde::Deserialize; +use crate::models::{Command, CommandPriority, Deployment}; +use crate::services::{ + build_deploy_plan, build_rollback_plan, resolve_rollback_plan_context, DeployPlan, + DeployPlanAction, DeployPlanOperation, DeployPlanRollback, DeployPlanScope, + DeploymentAgentState, DeploymentDriftState, DeploymentEvent, DeploymentEventFeed, + DeploymentIdentifier, DeploymentLastCommandState, DeploymentProjectState, DeploymentResolver, + DeploymentRuntimeState, DeploymentState, DeploymentStateDeployment, TypedErrorCode, + TypedErrorEnvelope, TypedRemediationClass, DEPLOY_PLAN_SCHEMA_VERSION, +}; /// Get deployment status pub struct GetDeploymentStatusTool; +pub struct GetDeploymentStateTool; +pub struct GetDeploymentPlanTool; +pub struct GetDeploymentEventsTool; +pub struct ApplyDeploymentPlanTool; -#[async_trait] -impl ToolHandler for GetDeploymentStatusTool { - async fn execute(&self, args: Value, context: &ToolContext) -> Result { - #[derive(Deserialize)] - struct Args { - #[serde(default)] - deployment_id: Option, - #[serde(default)] - deployment_hash: Option, +const COMMAND_RESULT_TIMEOUT_SECS: u64 = 15; +const COMMAND_POLL_INTERVAL_MS: u64 = 500; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpDeploymentStatusResponse { + id: i32, + project_id: i32, + deployment_hash: String, + status: String, + runtime: String, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpStartDeploymentResponse { + id: i32, + project_id: i32, + status: String, + deployment_hash: String, + created_at: chrono::DateTime, + message: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpCancelDeploymentResponse { + deployment_id: i32, + status: String, + message: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpDeploymentStateResponse { + schema_version: String, + project: DeploymentProjectState, + deployment: DeploymentStateDeployment, + agent: DeploymentAgentState, + runtime: DeploymentRuntimeState, + apps: Vec, + drift: DeploymentDriftState, + #[serde(skip_serializing_if = "Option::is_none")] + last_command: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpDeploymentPlanResponse { + schema_version: String, + deployment_hash: String, + operation: DeployPlanOperation, + target: String, + fingerprint: String, + scope: DeployPlanScope, + has_changes: bool, + actions: Vec, + reasoning: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + rollback: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpDeploymentEventsResponse { + schema_version: String, + deployment_hash: String, + events: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpApplyDeploymentPlanResponse { + schema_version: String, + deployment_hash: String, + operation: DeployPlanOperation, + fingerprint: String, + applied: bool, + has_changes: bool, + status: String, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + command_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rollback: Option, +} + +#[derive(Deserialize)] +struct DeploymentLookupArgs { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, +} + +#[derive(Deserialize)] +struct DeploymentPlanArgs { + #[serde(flatten)] + lookup: DeploymentLookupArgs, + #[serde(default)] + operation: Option, + #[serde(default)] + app_code: Option, + #[serde(default)] + target: Option, + #[serde(default)] + expected_fingerprint: Option, + #[serde(default)] + rollback_target: Option, +} + +#[derive(Deserialize)] +struct ApplyDeploymentPlanArgs { + #[serde(flatten)] + plan: DeploymentPlanArgs, + #[serde(default)] + confirm: bool, +} + +impl From for McpDeploymentStatusResponse { + fn from(value: Deployment) -> Self { + Self { + id: value.id, + project_id: value.project_id, + deployment_hash: value.deployment_hash, + status: value.status, + runtime: value.runtime, + created_at: value.created_at, + updated_at: value.updated_at, } + } +} - let args: Args = - serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; +impl From for McpDeploymentStateResponse { + fn from(value: DeploymentState) -> Self { + Self { + schema_version: value.schema_version, + project: value.project, + deployment: value.deployment, + agent: value.agent, + runtime: value.runtime, + apps: value.apps, + drift: value.drift, + last_command: value.last_command, + } + } +} - // Create identifier from args (prefers hash if both provided) - let identifier = DeploymentIdentifier::try_from_options( - args.deployment_hash.clone(), - args.deployment_id, - )?; +impl From for McpDeploymentPlanResponse { + fn from(value: DeployPlan) -> Self { + Self { + schema_version: value.schema_version, + deployment_hash: value.deployment_hash, + operation: value.operation, + target: value.target, + fingerprint: value.fingerprint, + scope: value.scope, + has_changes: value.has_changes, + actions: value.actions, + reasoning: value.reasoning, + rollback: value.rollback, + } + } +} - // Resolve to deployment_hash - let resolver = UserServiceDeploymentResolver::from_context( - &context.settings.user_service_url, - context.user.access_token.as_deref(), - ); - let deployment_hash = resolver.resolve(&identifier).await?; +impl From for McpDeploymentEventsResponse { + fn from(value: DeploymentEventFeed) -> Self { + Self { + schema_version: value.schema_version, + deployment_hash: value.deployment_hash, + events: value.events, + } + } +} + +fn json_tool_content(value: &T) -> Result { + Ok(ToolContent::Text { + text: serde_json::to_string(value).map_err(|e| format!("Serialization error: {}", e))?, + }) +} + +async fn wait_for_command_result( + pg_pool: &sqlx::PgPool, + command_id: &str, + timeout_secs: u64, +) -> Result, String> { + use tokio::time::{sleep, Duration, Instant}; + + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + + while Instant::now() < deadline { + let fetched = db::command::fetch_by_command_id(pg_pool, command_id) + .await + .map_err(|e| format!("Failed to fetch command: {}", e))?; + + if let Some(cmd) = fetched { + let status = cmd.status.to_lowercase(); + if status == "completed" + || status == "failed" + || cmd.result.is_some() + || cmd.error.is_some() + { + return Ok(Some(cmd)); + } + } + + sleep(Duration::from_millis(COMMAND_POLL_INTERVAL_MS)).await; + } + + Ok(None) +} + +async fn enqueue_and_wait( + context: &ToolContext, + deployment_hash: &str, + command_type: &str, + parameters: Value, + timeout_secs: u64, +) -> Result { + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.to_string(), + command_type.to_string(), + context.user.id.clone(), + ) + .with_parameters(parameters.clone()); + + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + if let Some(cmd) = + wait_for_command_result(&context.pg_pool, &command.command_id, timeout_secs).await? + { + let status = cmd.status.to_lowercase(); + Ok(json!({ + "status": status, + "command_id": cmd.command_id, + "deployment_hash": deployment_hash, + "command_type": command_type, + "result": cmd.result, + "error": cmd.error, + })) + } else { + Ok(json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "command_type": command_type, + "message": "Command queued. Agent will process shortly.", + })) + } +} + +fn create_resolver(context: &ToolContext) -> UserServiceDeploymentResolver { + UserServiceDeploymentResolver::from_context( + &context.settings.user_service_url, + context.user.access_token.as_deref(), + ) +} + +fn stacker_base_url(context: &ToolContext) -> String { + let host = match context.settings.app_host.trim() { + "" | "0.0.0.0" => "127.0.0.1", + host => host, + }; + + format!("http://{}:{}", host, context.settings.app_port) +} + +fn stacker_client(context: &ToolContext) -> Result { + let token = context.user.access_token.as_deref().ok_or_else(|| { + TypedErrorEnvelope::permission_denied( + "Authenticated MCP mutation requires a user access token", + ) + .to_pretty_json() + })?; + + Ok(StackerClient::new(&stacker_base_url(context), token)) +} + +fn apply_confirmation_required_error() -> String { + TypedErrorEnvelope::invalid_request("apply_deployment_plan requires confirm=true") + .with_context("tool", "apply_deployment_plan") + .to_pretty_json() +} - // Fetch deployment by hash - let deployment = - db::deployment::fetch_by_deployment_hash(&context.pg_pool, &deployment_hash) - .await - .map_err(|e| { - tracing::error!("Failed to fetch deployment: {}", e); - format!("Database error: {}", e) - })? - .ok_or_else(|| format!("Deployment not found with hash: {}", deployment_hash))?; +fn unsupported_apply_operation_error(operation: &DeployPlanOperation) -> String { + let operation_name = serde_json::to_string(operation) + .unwrap_or_else(|_| "\"unknown\"".to_string()) + .trim_matches('"') + .to_string(); + + TypedErrorEnvelope::new( + TypedErrorCode::InvalidRequest, + "apply_deployment_plan currently supports deploy_app and rollback_deploy; full deploy apply still requires local CLI context", + false, + TypedRemediationClass::Configuration, + ) + .with_context("operation", operation_name) + .to_pretty_json() +} - let result = serde_json::to_string(&deployment) - .map_err(|e| format!("Serialization error: {}", e))?; +async fn resolve_owned_deployment( + context: &ToolContext, + args: DeploymentLookupArgs, +) -> Result<(String, Deployment), String> { + let identifier = + DeploymentIdentifier::try_from_options(args.deployment_hash, args.deployment_id)?; + let deployment_hash = create_resolver(context).resolve(&identifier).await?; + let deployment = db::deployment::fetch_by_deployment_hash(&context.pg_pool, &deployment_hash) + .await + .map_err(|e| { + tracing::error!("Failed to fetch deployment: {}", e); + format!("Database error: {}", e) + })? + .ok_or_else(|| "Deployment not found".to_string())?; + + if deployment.user_id.as_deref() != Some(&context.user.id) { + return Err("Deployment not found".to_string()); + } + + Ok((deployment_hash, deployment)) +} + +async fn build_validated_plan( + context: &ToolContext, + args: DeploymentPlanArgs, +) -> Result<(Deployment, DeployPlan), String> { + let operation = args.operation.unwrap_or(DeployPlanOperation::Deploy); + let target = args.target.as_deref().unwrap_or("cloud"); + let (deployment_hash, deployment) = resolve_owned_deployment(context, args.lookup).await?; + let state = DeploymentState::for_deployment_hash(&context.pg_pool, &deployment_hash) + .await + .map_err(|e| format!("Database error: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + + let plan = match operation { + DeployPlanOperation::RollbackDeploy => { + let requested_target = args + .rollback_target + .as_deref() + .ok_or_else(|| "rollback_target is required for rollback plans".to_string())?; + let rollback = + resolve_rollback_plan_context(&context.pg_pool, &deployment, requested_target) + .await + .map_err(|error| error.to_pretty_json())?; + build_rollback_plan( + &state, + target, + rollback, + args.expected_fingerprint.as_deref(), + ) + } + _ => build_deploy_plan( + &state, + operation, + target, + args.app_code.as_deref(), + args.expected_fingerprint.as_deref(), + ), + } + .map_err(|error| error.to_pretty_json())?; + + Ok((deployment, plan)) +} + +#[async_trait] +impl ToolHandler for GetDeploymentStatusTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: DeploymentLookupArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let (deployment_hash, deployment) = resolve_owned_deployment(context, args).await?; + + let response = McpDeploymentStatusResponse::from(deployment); tracing::info!("Got deployment status for hash: {}", deployment_hash); - Ok(ToolContent::Text { text: result }) + json_tool_content(&response) } fn schema(&self) -> Tool { @@ -80,6 +441,295 @@ impl ToolHandler for GetDeploymentStatusTool { } } +#[async_trait] +impl ToolHandler for GetDeploymentStateTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: DeploymentLookupArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let (deployment_hash, _) = resolve_owned_deployment(context, args).await?; + let state = DeploymentState::for_deployment_hash(&context.pg_pool, &deployment_hash) + .await + .map_err(|e| format!("Database error: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + + json_tool_content(&McpDeploymentStateResponse::from(state)) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_deployment_state".to_string(), + description: "Get the canonical machine-readable deployment state. Provide either deployment_hash or deployment_id.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "Deployment hash (preferred, e.g., 'deployment_abc123')" + }, + "deployment_id": { + "type": "number", + "description": "Deployment ID (legacy numeric ID from User Service)" + } + }, + "required": [] + }), + } + } +} + +#[async_trait] +impl ToolHandler for GetDeploymentPlanTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: DeploymentPlanArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let (_, plan) = build_validated_plan(context, args).await?; + + json_tool_content(&McpDeploymentPlanResponse::from(plan)) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_deployment_plan".to_string(), + description: "Preview a deployment or rollback plan with stable fingerprinting before any mutation.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "Deployment hash (preferred, e.g., 'deployment_abc123')" + }, + "deployment_id": { + "type": "number", + "description": "Deployment ID (legacy numeric ID from User Service)" + }, + "operation": { + "type": "string", + "enum": ["deploy", "deploy_app", "rollback_deploy"], + "description": "Plan mode. Defaults to 'deploy'." + }, + "app_code": { + "type": "string", + "description": "Required for deploy_app plans; ignored for deployment-wide plans." + }, + "target": { + "type": "string", + "description": "Deployment target. Defaults to 'cloud'." + }, + "expected_fingerprint": { + "type": "string", + "description": "Optional stale-plan guard. The plan fails if this fingerprint no longer matches." + }, + "rollback_target": { + "type": "string", + "description": "Required for rollback_deploy plans. Use 'previous' or a specific marketplace version." + } + }, + "required": [] + }), + } + } +} + +#[async_trait] +impl ToolHandler for ApplyDeploymentPlanTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: ApplyDeploymentPlanArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + if !args.confirm { + return Err(apply_confirmation_required_error()); + } + + let (deployment, plan) = build_validated_plan(context, args.plan).await?; + + if !plan.has_changes { + return json_tool_content(&McpApplyDeploymentPlanResponse { + schema_version: DEPLOY_PLAN_SCHEMA_VERSION.to_string(), + deployment_hash: plan.deployment_hash, + operation: plan.operation, + fingerprint: plan.fingerprint, + applied: false, + has_changes: false, + status: "noop".to_string(), + message: "Plan already satisfied. Nothing to apply.".to_string(), + command_id: None, + rollback: plan.rollback, + }); + } + + match plan.operation { + DeployPlanOperation::DeployApp => { + let app_code = plan.scope.app_code.clone().ok_or_else(|| { + TypedErrorEnvelope::invalid_request( + "apply_deployment_plan requires an appCode/app_code for deploy_app operations", + ) + .to_pretty_json() + })?; + let result = enqueue_and_wait( + context, + &plan.deployment_hash, + "deploy_app", + json!({ + "app_code": app_code, + "image": serde_json::Value::Null, + "pull": true, + "force_recreate": false, + "force_config_overwrite": false, + }), + COMMAND_RESULT_TIMEOUT_SECS, + ) + .await?; + + let status = result + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("queued") + .to_string(); + let message = result + .get("message") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| { + format!("deploy_app apply accepted for {}", plan.deployment_hash) + }); + let command_id = result + .get("command_id") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + + json_tool_content(&McpApplyDeploymentPlanResponse { + schema_version: DEPLOY_PLAN_SCHEMA_VERSION.to_string(), + deployment_hash: plan.deployment_hash, + operation: plan.operation, + fingerprint: plan.fingerprint, + applied: true, + has_changes: true, + status, + message, + command_id, + rollback: None, + }) + } + DeployPlanOperation::RollbackDeploy => { + let rollback = plan.rollback.clone().ok_or_else(|| { + TypedErrorEnvelope::internal_error( + "Rollback plan did not include a resolved target version", + ) + .to_pretty_json() + })?; + let client = stacker_client(context)?; + let response = client + .rollback_project(deployment.project_id, &rollback.resolved_version) + .await + .map_err(|error| match error { + crate::cli::error::CliError::Typed(envelope) => envelope.to_pretty_json(), + other => { + TypedErrorEnvelope::internal_error(other.to_string()).to_pretty_json() + } + })?; + + json_tool_content(&McpApplyDeploymentPlanResponse { + schema_version: DEPLOY_PLAN_SCHEMA_VERSION.to_string(), + deployment_hash: plan.deployment_hash, + operation: plan.operation, + fingerprint: plan.fingerprint, + applied: true, + has_changes: true, + status: response.status.unwrap_or_else(|| "accepted".to_string()), + message: response.msg.unwrap_or_else(|| { + format!("Rollback accepted for {}", rollback.resolved_version) + }), + command_id: None, + rollback: Some(rollback), + }) + } + DeployPlanOperation::Deploy => Err(unsupported_apply_operation_error(&plan.operation)), + } + } + + fn schema(&self) -> Tool { + Tool { + name: "apply_deployment_plan".to_string(), + description: "Apply a previously previewed deployment plan after revalidating its fingerprint. Supports deploy_app and rollback_deploy operations.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "Deployment hash (preferred, e.g., 'deployment_abc123')" + }, + "deployment_id": { + "type": "number", + "description": "Deployment ID (legacy numeric ID from User Service)" + }, + "operation": { + "type": "string", + "enum": ["deploy", "deploy_app", "rollback_deploy"], + "description": "Mutation mode. deploy currently returns an unsupported typed error because it still requires local CLI context." + }, + "app_code": { + "type": "string", + "description": "Required for deploy_app applies." + }, + "target": { + "type": "string", + "description": "Deployment target. Defaults to 'cloud'." + }, + "expected_fingerprint": { + "type": "string", + "description": "Required fingerprint from get_deployment_plan to prevent stale applies." + }, + "rollback_target": { + "type": "string", + "description": "Required for rollback_deploy applies. Use 'previous' or a specific marketplace version." + }, + "confirm": { + "type": "boolean", + "description": "Must be true to acknowledge the mutation." + } + }, + "required": ["expected_fingerprint", "confirm"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for GetDeploymentEventsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: DeploymentLookupArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let (deployment_hash, _) = resolve_owned_deployment(context, args).await?; + let feed = DeploymentEventFeed::for_deployment_hash(&context.pg_pool, &deployment_hash) + .await + .map_err(|e| format!("Database error: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + + json_tool_content(&McpDeploymentEventsResponse::from(feed)) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_deployment_events".to_string(), + description: "Get the structured deployment event feed for progress, failure, and remediation signals.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "Deployment hash (preferred, e.g., 'deployment_abc123')" + }, + "deployment_id": { + "type": "number", + "description": "Deployment ID (legacy numeric ID from User Service)" + } + }, + "required": [] + }), + } + } +} + /// Start a new deployment pub struct StartDeploymentTool; @@ -121,14 +771,14 @@ impl ToolHandler for StartDeploymentTool { .await .map_err(|e| format!("Failed to create deployment: {}", e))?; - let response = serde_json::json!({ - "id": deployment.id, - "project_id": deployment.project_id, - "status": deployment.status, - "deployment_hash": deployment.deployment_hash, - "created_at": deployment.created_at, - "message": "Deployment initiated - agent will connect shortly" - }); + let response = McpStartDeploymentResponse { + id: deployment.id, + project_id: deployment.project_id, + status: deployment.status, + deployment_hash: deployment.deployment_hash, + created_at: deployment.created_at, + message: "Deployment initiated - agent will connect shortly".to_string(), + }; tracing::info!( "Started deployment {} for project {}", @@ -136,9 +786,7 @@ impl ToolHandler for StartDeploymentTool { args.project_id ); - Ok(ToolContent::Text { - text: response.to_string(), - }) + json_tool_content(&response) } fn schema(&self) -> Tool { @@ -198,17 +846,15 @@ impl ToolHandler for CancelDeploymentTool { } // Mark deployment as cancelled (would update status in real implementation) - let response = serde_json::json!({ - "deployment_id": args.deployment_id, - "status": "cancelled", - "message": "Deployment cancellation initiated" - }); + let response = McpCancelDeploymentResponse { + deployment_id: args.deployment_id, + status: "cancelled".to_string(), + message: "Deployment cancellation initiated".to_string(), + }; tracing::info!("Cancelled deployment {}", args.deployment_id); - Ok(ToolContent::Text { - text: response.to_string(), - }) + json_tool_content(&response) } fn schema(&self) -> Tool { @@ -228,3 +874,213 @@ impl ToolHandler for CancelDeploymentTool { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::registry::ToolRegistry; + use crate::{configuration::Settings, mcp::registry::ToolContext, models::User}; + use actix_web::web; + use chrono::Utc; + use serde_json::json; + use std::sync::Arc; + + #[test] + fn deployment_status_response_omits_internal_fields() { + let deployment = Deployment { + id: 31, + project_id: 17, + deployment_hash: "deployment_state_online".to_string(), + user_id: Some("user-1".to_string()), + deleted: Some(false), + status: "healthy".to_string(), + runtime: "runc".to_string(), + metadata: json!({"status_message": "hidden"}), + last_seen_at: Some(Utc::now()), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + let response = McpDeploymentStatusResponse::from(deployment); + let serialized = serde_json::to_value(&response).expect("serialize MCP status response"); + + assert_eq!(serialized["deploymentHash"], "deployment_state_online"); + assert_eq!(serialized["status"], "healthy"); + assert!(serialized.get("metadata").is_none()); + assert!(serialized.get("userId").is_none()); + assert!(serialized.get("deleted").is_none()); + assert!(serialized.get("lastSeenAt").is_none()); + } + + #[test] + fn start_deployment_response_uses_allow_list_shape() { + let response = McpStartDeploymentResponse { + id: 31, + project_id: 17, + status: "pending".to_string(), + deployment_hash: "deployment_state_online".to_string(), + created_at: Utc::now(), + message: "Deployment initiated - agent will connect shortly".to_string(), + }; + + let serialized = serde_json::to_value(&response).expect("serialize start response"); + assert!(serialized.get("projectId").is_some()); + assert!(serialized.get("message").is_some()); + assert!(serialized.get("metadata").is_none()); + } + + #[test] + fn deployment_state_response_uses_stable_contract_shape() { + let state = DeploymentState { + schema_version: "v1alpha1".to_string(), + project: crate::services::DeploymentProjectState { + id: 17, + identity: "demo".to_string(), + name: "Demo".to_string(), + }, + deployment: crate::services::DeploymentStateDeployment { + id: 31, + deployment_hash: "deployment_state_online".to_string(), + status: "healthy".to_string(), + runtime: "runc".to_string(), + }, + agent: DeploymentAgentState { + id: Some("agent-1".to_string()), + status: "online".to_string(), + version: Some("1.0.0".to_string()), + last_heartbeat: None, + capabilities: vec!["compose".to_string()], + features: crate::services::DeploymentAgentFeatures { + compose: true, + kata_runtime: false, + backup: false, + pipes: false, + proxy_credentials_vault: false, + }, + }, + runtime: DeploymentRuntimeState { + compose_path: "/opt/stacker/docker-compose.remote.yml".to_string(), + env_path: "/home/trydirect/project/.env".to_string(), + }, + apps: vec![], + drift: DeploymentDriftState { + has_drift: false, + summary: "no drift detected".to_string(), + }, + last_command: None, + }; + + let serialized = serde_json::to_value(McpDeploymentStateResponse::from(state)) + .expect("serialize deployment state"); + assert_eq!(serialized["schemaVersion"], "v1alpha1"); + assert!(serialized.get("project").is_some()); + assert!(serialized.get("deployment").is_some()); + assert!(serialized.get("metadata").is_none()); + } + + #[test] + fn deployment_ai_tools_are_registered() { + let registry = ToolRegistry::new(); + assert!(registry.has_tool("get_deployment_state")); + assert!(registry.has_tool("get_deployment_plan")); + assert!(registry.has_tool("get_deployment_events")); + assert!(registry.has_tool("apply_deployment_plan")); + } + + #[test] + fn apply_deployment_plan_response_has_allow_list_shape() { + let response = McpApplyDeploymentPlanResponse { + schema_version: DEPLOY_PLAN_SCHEMA_VERSION.to_string(), + deployment_hash: "deployment_state_online".to_string(), + operation: DeployPlanOperation::DeployApp, + fingerprint: "fingerprint-123".to_string(), + applied: true, + has_changes: true, + status: "queued".to_string(), + message: "Command queued. Agent will process shortly.".to_string(), + command_id: Some("cmd-1".to_string()), + rollback: None, + }; + + let serialized = serde_json::to_value(&response).expect("serialize apply response"); + assert_eq!(serialized["schemaVersion"], DEPLOY_PLAN_SCHEMA_VERSION); + assert_eq!(serialized["operation"], "deploy_app"); + assert!(serialized.get("commandId").is_some()); + assert!(serialized.get("result").is_none()); + assert!(serialized.get("meta").is_none()); + } + + #[test] + fn apply_deployment_plan_requires_confirmation_with_typed_error() { + let envelope = + serde_json::from_str::(&apply_confirmation_required_error()) + .expect("deserialize typed confirmation error"); + + assert_eq!(envelope.code, TypedErrorCode::InvalidRequest); + assert_eq!( + envelope.message, + "apply_deployment_plan requires confirm=true" + ); + assert_eq!( + envelope.context.get("tool").map(|value| value.as_str()), + Some("apply_deployment_plan") + ); + } + + #[test] + fn apply_deployment_plan_rejects_full_deploy_with_typed_error() { + let envelope = serde_json::from_str::( + &unsupported_apply_operation_error(&DeployPlanOperation::Deploy), + ) + .expect("deserialize typed unsupported operation error"); + + assert_eq!(envelope.code, TypedErrorCode::InvalidRequest); + assert!(envelope + .message + .contains("currently supports deploy_app and rollback_deploy")); + assert_eq!( + envelope + .context + .get("operation") + .map(|value| value.as_str()), + Some("deploy") + ); + } + + #[tokio::test] + async fn apply_deployment_plan_confirmation_error_does_not_reflect_secret_inputs() { + let tool = ApplyDeploymentPlanTool; + let pg_pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://postgres:postgres@localhost/stacker_test") + .expect("lazy pool"); + let context = ToolContext { + user: Arc::new(User { + id: "user-1".to_string(), + first_name: "Test".to_string(), + last_name: "User".to_string(), + email: "test@example.com".to_string(), + role: "group_user".to_string(), + email_confirmed: true, + mfa_verified: true, + access_token: None, + }), + pg_pool, + settings: web::Data::new(Settings::default()), + }; + let args = json!({ + "deployment_hash": "deployment_state_online", + "operation": "deploy_app", + "app_code": "SUPER_SECRET_SHOULD_NOT_LEAK", + "expected_fingerprint": "fingerprint-SUPER_SECRET_SHOULD_NOT_LEAK", + "confirm": false + }); + + let error = tool + .execute(args, &context) + .await + .expect_err("confirm=false should reject apply"); + + assert!(error.contains("confirm=true")); + assert!(!error.contains("SUPER_SECRET_SHOULD_NOT_LEAK")); + } +} diff --git a/src/mcp/tools/explain.rs b/src/mcp/tools/explain.rs new file mode 100644 index 00000000..2880d6e9 --- /dev/null +++ b/src/mcp/tools/explain.rs @@ -0,0 +1,469 @@ +use async_trait::async_trait; +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::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::models::{Project, ProjectApp}; +use crate::services::config_renderer::EnvRenderInput; +use crate::services::{ + build_explain_env, build_explain_topology, ExplainEnv, ExplainEnvLayer, ExplainRenderedEnv, + ExplainTopology, ExplainTopologyService, +}; + +pub struct ExplainEnvTool; +pub struct ExplainTopologyTool; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpExplainEnvResponse { + schema_version: String, + deployment_hash: String, + app_code: String, + local_authoring_env_path: String, + runtime_env_path: String, + runtime_compose_path: String, + layers: Vec, + destination: McpExplainDestinationResponse, + rendered_env: McpExplainRenderedEnvResponse, + reasoning: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpExplainEnvLayerResponse { + name: String, + key_names: Vec, + key_count: usize, + hash: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpExplainDestinationResponse { + path: String, + write_policy: String, + drift_protection: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpExplainRenderedEnvResponse { + hash: String, + inputs: Vec, + server_secrets_inherited: bool, + service_secrets_override_server_secrets: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpExplainTopologyResponse { + schema_version: String, + deployment_hash: String, + target: String, + local_compose_path: String, + runtime_compose_path: String, + local_authoring_env_path: String, + runtime_env_path: String, + services: Vec, + reasoning: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpExplainTopologyServiceResponse { + code: String, + name: String, + enabled: bool, +} + +#[derive(Deserialize)] +struct ExplainArgs { + deployment_hash: String, + #[serde(default)] + app_code: Option, +} + +fn local_authoring_env_path(project: &Project) -> String { + project + .request_json + .get("env_file") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| ".env".to_string()) +} + +fn runtime_compose_path(project: &Project) -> String { + 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()) +} + +fn project_target(project: &Project) -> String { + project + .request_json + .pointer("/deploy/target") + .and_then(|value| value.as_str()) + .unwrap_or("cloud") + .to_string() +} + +fn app_env_input(app: &ProjectApp) -> EnvRenderInput { + let mut input = EnvRenderInput::default(); + if let Some(env) = app.environment.as_ref().and_then(|value| value.as_object()) { + input.service = env + .iter() + .filter_map(|(key, value)| value.as_str().map(|value| (key.clone(), value.to_string()))) + .collect(); + } + input +} + +fn topology_services(apps: &[ProjectApp]) -> Vec { + apps.iter() + .map(|app| ExplainTopologyService { + code: app.code.clone(), + name: app.name.clone(), + enabled: app.enabled.unwrap_or(true), + }) + .collect() +} + +fn json_tool_content(value: &T) -> Result { + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(value) + .map_err(|err| format!("Serialization error: {err}"))?, + }) +} + +impl From for McpExplainEnvLayerResponse { + fn from(value: ExplainEnvLayer) -> Self { + Self { + name: value.name, + key_names: value.key_names, + key_count: value.key_count, + hash: value.hash, + } + } +} + +impl From for McpExplainDestinationResponse { + fn from(value: crate::services::ExplainDestination) -> Self { + Self { + path: value.path, + write_policy: value.write_policy, + drift_protection: value.drift_protection, + } + } +} + +impl From for McpExplainRenderedEnvResponse { + fn from(value: ExplainRenderedEnv) -> Self { + Self { + hash: value.hash, + inputs: value.inputs, + server_secrets_inherited: value.server_secrets_inherited, + service_secrets_override_server_secrets: value.service_secrets_override_server_secrets, + } + } +} + +impl From for McpExplainEnvResponse { + fn from(value: ExplainEnv) -> Self { + Self { + schema_version: value.schema_version, + deployment_hash: value.deployment_hash, + app_code: value.app_code, + local_authoring_env_path: value.local_authoring_env_path, + runtime_env_path: value.runtime_env_path, + runtime_compose_path: value.runtime_compose_path, + layers: value.layers.into_iter().map(Into::into).collect(), + destination: value.destination.into(), + rendered_env: value.rendered_env.into(), + reasoning: value.reasoning, + } + } +} + +impl From for McpExplainTopologyServiceResponse { + fn from(value: ExplainTopologyService) -> Self { + Self { + code: value.code, + name: value.name, + enabled: value.enabled, + } + } +} + +impl From for McpExplainTopologyResponse { + fn from(value: ExplainTopology) -> Self { + Self { + schema_version: value.schema_version, + deployment_hash: value.deployment_hash, + target: value.target, + local_compose_path: value.local_compose_path, + runtime_compose_path: value.runtime_compose_path, + local_authoring_env_path: value.local_authoring_env_path, + runtime_env_path: value.runtime_env_path, + services: value.services.into_iter().map(Into::into).collect(), + reasoning: value.reasoning, + } + } +} + +async fn load_owned_deployment( + context: &ToolContext, + deployment_hash: &str, +) -> Result<(crate::models::Deployment, Project), String> { + let deployment = db::deployment::fetch_by_deployment_hash(&context.pg_pool, deployment_hash) + .await + .map_err(|err| format!("Failed to fetch deployment: {err}"))? + .ok_or_else(|| "Deployment not found".to_string())?; + let project = db::project::fetch(&context.pg_pool, deployment.project_id) + .await + .map_err(|err| format!("Failed to fetch project: {err}"))? + .ok_or_else(|| "Project not found".to_string())?; + if project.user_id != context.user.id { + return Err("Deployment not found".to_string()); + } + Ok((deployment, project)) +} + +#[async_trait] +impl ToolHandler for ExplainEnvTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: ExplainArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {e}"))?; + let (deployment, project) = load_owned_deployment(context, &args.deployment_hash).await?; + let app_code = args.app_code.unwrap_or_else(|| "app".to_string()); + + let apps = + db::project_app::fetch_by_deployment(&context.pg_pool, project.id, deployment.id) + .await + .map_err(|err| format!("Failed to fetch apps: {err}"))?; + let app = apps + .iter() + .find(|app| app.code == app_code) + .or_else(|| apps.first()) + .ok_or_else(|| "No deployment apps found".to_string())?; + + let explain = build_explain_env( + &deployment.deployment_hash, + &app.code, + &local_authoring_env_path(&project), + remote_runtime_env_path(), + &runtime_compose_path(&project), + app_env_input(app), + ) + .map_err(|err| err.to_string())?; + + json_tool_content(&McpExplainEnvResponse::from(explain)) + } + + fn schema(&self) -> Tool { + Tool { + name: "explain_env".to_string(), + description: "Explain runtime env provenance for a deployment app without exposing secret values.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { "type": "string", "description": "Deployment hash to inspect" }, + "app_code": { "type": "string", "description": "Optional app code; defaults to first deployment app" } + }, + "required": ["deployment_hash"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for ExplainTopologyTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: ExplainArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {e}"))?; + let (deployment, project) = load_owned_deployment(context, &args.deployment_hash).await?; + + let apps = + db::project_app::fetch_by_deployment(&context.pg_pool, project.id, deployment.id) + .await + .map_err(|err| format!("Failed to fetch apps: {err}"))?; + + 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(), + topology_services(&apps), + ); + + json_tool_content(&McpExplainTopologyResponse::from(topology)) + } + + fn schema(&self) -> Tool { + Tool { + name: "explain_topology".to_string(), + description: "Explain deployment topology paths and service targets without exposing secret values.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { "type": "string", "description": "Deployment hash to inspect" } + }, + "required": ["deployment_hash"] + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::registry::ToolRegistry; + use crate::models::{Deployment, Project, ProjectApp}; + use serde_json::json; + + #[test] + fn explain_tools_have_expected_schema_names() { + assert_eq!(ExplainEnvTool.schema().name, "explain_env"); + assert_eq!(ExplainTopologyTool.schema().name, "explain_topology"); + } + + #[test] + fn explain_tools_are_registered() { + let registry = ToolRegistry::new(); + assert!(registry.has_tool("explain_env")); + assert!(registry.has_tool("explain_topology")); + } + + #[test] + fn explain_env_text_never_contains_secret_values() { + let project = Project::new( + "user-1".to_string(), + "demo".to_string(), + json!({}), + json!({ + "env_file": "docker/prod/.env", + "custom": { + "deployment_artifacts": { + "config_bundle": { + "remote_compose_path": "/opt/stacker/deployments/prod/docker-compose.remote.yml" + } + } + } + }), + ); + let deployment = Deployment::new( + 1, + Some("user-1".to_string()), + "deployment_demo".to_string(), + "running".to_string(), + "runc".to_string(), + json!({}), + ); + let mut app = ProjectApp::new( + 1, + "api".to_string(), + "API".to_string(), + "demo/api:latest".to_string(), + ); + app.environment = Some(json!({ + "DATABASE_URL": "SUPER_SECRET_SHOULD_NOT_LEAK", + "API_ACCESS_TOKEN": "TOKEN_SECRET_SHOULD_NOT_LEAK", + "REGISTRY_USERNAME": "REGISTRY_USER_SHOULD_NOT_LEAK", + "REGISTRY_PASSWORD": "REGISTRY_PASSWORD_SHOULD_NOT_LEAK", + "RUST_LOG": "debug" + })); + + let explain = build_explain_env( + &deployment.deployment_hash, + &app.code, + &local_authoring_env_path(&project), + remote_runtime_env_path(), + &runtime_compose_path(&project), + app_env_input(&app), + ) + .expect("explain env should build"); + let text = serde_json::to_string_pretty(&explain).expect("serialize explain env"); + + assert!(text.contains("DATABASE_URL")); + assert!(text.contains("API_ACCESS_TOKEN")); + assert!(text.contains("REGISTRY_USERNAME")); + assert!(text.contains("REGISTRY_PASSWORD")); + assert!(!text.contains("SUPER_SECRET_SHOULD_NOT_LEAK")); + assert!(!text.contains("TOKEN_SECRET_SHOULD_NOT_LEAK")); + assert!(!text.contains("REGISTRY_USER_SHOULD_NOT_LEAK")); + assert!(!text.contains("REGISTRY_PASSWORD_SHOULD_NOT_LEAK")); + } + + #[test] + fn explain_env_mcp_response_has_allow_list_shape() { + let explain = build_explain_env( + "deployment_demo", + "api", + "docker/prod/.env", + remote_runtime_env_path(), + remote_runtime_compose_path(), + app_env_input(&{ + let mut app = ProjectApp::new( + 1, + "api".to_string(), + "API".to_string(), + "demo/api:latest".to_string(), + ); + app.environment = Some(json!({ + "DATABASE_URL": "SUPER_SECRET_SHOULD_NOT_LEAK", + "API_ACCESS_TOKEN": "TOKEN_SECRET_SHOULD_NOT_LEAK", + "REGISTRY_USERNAME": "REGISTRY_USER_SHOULD_NOT_LEAK", + "REGISTRY_PASSWORD": "REGISTRY_PASSWORD_SHOULD_NOT_LEAK", + "RUST_LOG": "debug" + })); + app + }), + ) + .expect("explain env should build"); + + let response = McpExplainEnvResponse::from(explain); + let serialized = serde_json::to_value(&response).expect("serialize MCP explain env"); + + assert!(serialized.get("schemaVersion").is_some()); + assert!(serialized.get("layers").is_some()); + assert!(serialized.get("requestJson").is_none()); + assert!(serialized.get("environment").is_none()); + let text = serde_json::to_string(&serialized).expect("serialize MCP explain env response"); + assert!(!text.contains("SUPER_SECRET_SHOULD_NOT_LEAK")); + assert!(!text.contains("TOKEN_SECRET_SHOULD_NOT_LEAK")); + assert!(!text.contains("REGISTRY_USER_SHOULD_NOT_LEAK")); + assert!(!text.contains("REGISTRY_PASSWORD_SHOULD_NOT_LEAK")); + } + + #[test] + fn explain_topology_mcp_response_has_allow_list_shape() { + let topology = build_explain_topology( + "deployment_state_online", + "cloud", + "docker/prod/compose.yml", + remote_runtime_compose_path(), + "docker/prod/.env", + remote_runtime_env_path(), + vec![ExplainTopologyService { + code: "upload".to_string(), + name: "Upload".to_string(), + enabled: true, + }], + ); + + let response = McpExplainTopologyResponse::from(topology); + let serialized = serde_json::to_value(&response).expect("serialize MCP topology"); + + assert!(serialized.get("services").is_some()); + assert!(serialized.get("target").is_some()); + assert!(serialized.get("requestJson").is_none()); + assert!(serialized.get("metadata").is_none()); + } +} diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index 25673062..9bae3309 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -4,10 +4,12 @@ pub mod cloud; pub mod compose; pub mod config; pub mod deployment; +pub mod explain; pub mod firewall; pub mod install_preview; pub mod marketplace_admin; pub mod monitoring; +pub mod pipes; pub mod project; pub mod proxy; pub mod recommendations; @@ -22,10 +24,12 @@ pub use cloud::*; pub use compose::*; pub use config::*; pub use deployment::*; +pub use explain::*; pub use firewall::*; pub use install_preview::*; pub use marketplace_admin::*; pub use monitoring::*; +pub use pipes::*; pub use project::*; pub use proxy::*; pub use recommendations::*; diff --git a/src/mcp/tools/monitoring.rs b/src/mcp/tools/monitoring.rs index b1167f6f..5b96f069 100644 --- a/src/mcp/tools/monitoring.rs +++ b/src/mcp/tools/monitoring.rs @@ -28,6 +28,38 @@ const MAX_LOG_LIMIT: usize = 500; const COMMAND_RESULT_TIMEOUT_SECS: u64 = 8; const COMMAND_POLL_INTERVAL_MS: u64 = 400; +fn paused_deployment_cli_commands(server_ip: Option<&str>) -> Vec { + let mut commands = vec![ + "stacker status".to_string(), + "stacker status --watch".to_string(), + "stacker agent status".to_string(), + "stacker logs --tail 100".to_string(), + ]; + + if let Some(ip) = server_ip.filter(|ip| !ip.trim().is_empty()) { + commands.push(format!( + "ssh -i ~/.config/stacker/ssh/ -p 22 root@{}", + ip + )); + } + + commands +} + +fn paused_deployment_mcp_sequence() -> Vec<&'static str> { + vec![ + "get_deployment_status", + "get_deployment_events", + "get_deployment_state", + "get_docker_compose_yaml", + "list_containers", + "get_container_logs", + "get_error_summary", + "get_container_health", + "escalate_to_support", + ] +} + /// Helper to create a resolver from context. /// Uses UserServiceDeploymentResolver from connectors to support legacy installations. fn create_resolver(context: &ToolContext) -> UserServiceDeploymentResolver { @@ -527,6 +559,18 @@ impl ToolHandler for DiagnoseDeploymentTool { recommendations.push("Check deployment logs for error details".to_string()); recommendations.push("Verify cloud credentials are valid".to_string()); } + "paused" => { + issues.push("Deployment is PAUSED and needs troubleshooting".to_string()); + recommendations.push( + "Continue with stacker status --watch to collect the final installer message" + .to_string(), + ); + recommendations.push( + "Use the backup SSH command printed by deploy if the server IP is reachable" + .to_string(), + ); + recommendations.push("Inspect Docker Compose config, container logs, and config-bundle file mappings before redeploying".to_string()); + } "pending" => { issues.push("Deployment is still PENDING".to_string()); recommendations.push( @@ -574,6 +618,30 @@ impl ToolHandler for DiagnoseDeploymentTool { "issues_found": issues.len(), "issues": issues, "recommendations": recommendations, + "mcp_tool_sequence": paused_deployment_mcp_sequence(), + "stacker_cli_commands": if status == "paused" || status == "failed" { + paused_deployment_cli_commands(server_ip.as_deref()) + } else { + vec![ + "stacker status".to_string(), + "stacker agent status".to_string(), + ] + }, + "safe_ai_context": { + "include": [ + "deployment id/hash and status", + "last installer message", + "sanitized docker compose error", + "redacted compose env_file/image/ports snippets", + "config bundle source -> destination mappings" + ], + "exclude": [ + "cloud tokens", + "registry tokens", + "private SSH keys", + "full .env contents" + ] + }, "next_steps": if issues.is_empty() { vec!["Deployment appears healthy. Use get_container_health for detailed metrics.".to_string()] } else { @@ -616,6 +684,31 @@ impl ToolHandler for DiagnoseDeploymentTool { } } +#[cfg(test)] +mod tests { + use super::{paused_deployment_cli_commands, paused_deployment_mcp_sequence}; + + #[test] + fn paused_deployment_cli_commands_include_status_and_ssh_when_ip_exists() { + let commands = paused_deployment_cli_commands(Some("178.105.162.176")); + + assert!(commands.contains(&"stacker status".to_string())); + assert!(commands.contains(&"stacker status --watch".to_string())); + assert!(commands + .iter() + .any(|command| command.contains("root@178.105.162.176"))); + } + + #[test] + fn paused_deployment_mcp_sequence_prioritizes_diagnosis_before_escalation() { + let sequence = paused_deployment_mcp_sequence(); + + assert_eq!(sequence.first(), Some(&"get_deployment_status")); + assert!(sequence.contains(&"get_container_logs")); + assert_eq!(sequence.last(), Some(&"escalate_to_support")); + } +} + /// Stop a container in a deployment pub struct StopContainerTool; diff --git a/src/mcp/tools/pipes.rs b/src/mcp/tools/pipes.rs new file mode 100644 index 00000000..160dff8a --- /dev/null +++ b/src/mcp/tools/pipes.rs @@ -0,0 +1,781 @@ +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::cli::stacker_client::{ + AgentEnqueueRequest, CreatePipeInstanceApiRequest, CreatePipeTemplateApiRequest, + PipeInstanceInfo, StackerClient, +}; +use crate::connectors::user_service::UserServiceDeploymentResolver; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::services::{DeploymentIdentifier, DeploymentResolver, TypedErrorEnvelope}; + +pub struct ListPipesTool; +pub struct GetPipeTool; +pub struct ListPipeTemplatesTool; +pub struct CreatePipeTemplateTool; +pub struct CreatePipeInstanceTool; +pub struct GetPipeHistoryTool; +pub struct ReplayPipeExecutionTool; +pub struct ActivatePipeTool; +pub struct DeactivatePipeTool; +pub struct TriggerPipeTool; + +const PIPE_COMMAND_TIMEOUT_SECS: u64 = 90; +const PIPE_COMMAND_POLL_INTERVAL_SECS: u64 = 1; + +fn create_resolver(context: &ToolContext) -> UserServiceDeploymentResolver { + UserServiceDeploymentResolver::from_context( + &context.settings.user_service_url, + context.user.access_token.as_deref(), + ) +} + +fn stacker_base_url(context: &ToolContext) -> String { + let host = match context.settings.app_host.trim() { + "" | "0.0.0.0" => "127.0.0.1", + host => host, + }; + + format!("http://{}:{}", host, context.settings.app_port) +} + +fn stacker_client(context: &ToolContext) -> Result { + let token = context.user.access_token.as_deref().ok_or_else(|| { + TypedErrorEnvelope::permission_denied( + "Authenticated MCP mutation requires a user access token", + ) + .to_pretty_json() + })?; + + Ok(StackerClient::new(&stacker_base_url(context), token)) +} + +async fn resolve_deployment_hash( + context: &ToolContext, + deployment_hash: Option, + deployment_id: Option, +) -> Result { + let identifier = DeploymentIdentifier::try_from_options(deployment_hash, deployment_id)?; + create_resolver(context) + .resolve(&identifier) + .await + .map_err(|e| e.to_string()) +} + +async fn require_pipe(client: &StackerClient, pipe_id: &str) -> Result { + client + .get_pipe_instance(pipe_id) + .await + .map_err(|e| format!("Failed to fetch pipe '{}': {}", pipe_id, e))? + .ok_or_else(|| format!("Pipe instance '{}' not found", pipe_id)) +} + +async fn ensure_pipe_capability( + client: &StackerClient, + deployment_hash: &str, +) -> Result<(), String> { + let capabilities = client + .deployment_capabilities(deployment_hash) + .await + .map_err(|e| format!("Failed to fetch deployment capabilities: {}", e))?; + + if capabilities.features.pipes { + return Ok(()); + } + + let capabilities_list = if capabilities.capabilities.is_empty() { + "(none)".to_string() + } else { + capabilities.capabilities.join(", ") + }; + + Err(format!( + "The active agent for deployment '{}' does not support pipe commands. Agent status: {}. Capabilities: {}. Update or relink the Status Panel agent so it advertises 'pipes', then retry.", + capabilities.deployment_hash, + if capabilities.status.is_empty() { + "unknown" + } else { + &capabilities.status + }, + capabilities_list + )) +} + +fn pipe_command_response(result: crate::cli::stacker_client::AgentCommandInfo) -> Value { + json!({ + "command_id": result.command_id, + "deployment_hash": result.deployment_hash, + "command_type": result.command_type, + "status": result.status, + "priority": result.priority, + "parameters": result.parameters, + "result": result.result, + "error": result.error, + "created_at": result.created_at, + "updated_at": result.updated_at, + }) +} + +async fn run_pipe_command( + client: &StackerClient, + request: &AgentEnqueueRequest, + wait_timeout_seconds: u64, +) -> Result { + let result = client + .agent_poll_result( + request, + wait_timeout_seconds, + PIPE_COMMAND_POLL_INTERVAL_SECS, + ) + .await + .map_err(|e| format!("Agent command failed: {}", e))?; + + Ok(pipe_command_response(result)) +} + +async fn resolve_pipe_deployment( + context: &ToolContext, + client: &StackerClient, + pipe_id: &str, + deployment_hash: Option, + deployment_id: Option, +) -> Result<(PipeInstanceInfo, String), String> { + let pipe = require_pipe(client, pipe_id).await?; + let resolved = if deployment_hash.is_some() || deployment_id.is_some() { + let explicit = resolve_deployment_hash(context, deployment_hash, deployment_id).await?; + if explicit != pipe.deployment_hash { + return Err(format!( + "Pipe '{}' belongs to deployment '{}', not '{}'", + pipe_id, pipe.deployment_hash, explicit + )); + } + explicit + } else { + pipe.deployment_hash.clone() + }; + + Ok((pipe, resolved)) +} + +async fn activate_pipe_request( + client: &StackerClient, + pipe: &PipeInstanceInfo, + trigger: &str, + poll_interval: u32, +) -> Result { + let (source_endpoint, source_method, target_endpoint, target_method, field_mapping) = + if let Some(template_id) = pipe.template_id.as_ref() { + let templates = client + .list_pipe_templates(None, None) + .await + .map_err(|e| format!("Failed to load pipe templates: {}", e))?; + + if let Some(template) = templates + .iter() + .find(|template| &template.id == template_id) + { + ( + template.source_endpoint["path"] + .as_str() + .unwrap_or("/") + .to_string(), + template.source_endpoint["method"] + .as_str() + .unwrap_or("GET") + .to_string(), + template.target_endpoint["path"] + .as_str() + .unwrap_or("/") + .to_string(), + template.target_endpoint["method"] + .as_str() + .unwrap_or("POST") + .to_string(), + pipe.field_mapping_override + .clone() + .unwrap_or(template.field_mapping.clone()), + ) + } else { + ( + "/".to_string(), + "GET".to_string(), + "/".to_string(), + "POST".to_string(), + serde_json::json!({}), + ) + } + } else { + ( + "/".to_string(), + "GET".to_string(), + "/".to_string(), + "POST".to_string(), + pipe.field_mapping_override + .clone() + .unwrap_or(serde_json::json!({})), + ) + }; + + let params = json!({ + "pipe_instance_id": pipe.id, + "source_adapter": pipe.source_adapter.clone(), + "source_container": pipe.source_container.clone(), + "source_endpoint": source_endpoint, + "source_method": source_method, + "target_adapter": pipe.target_adapter.clone(), + "target_container": pipe.target_container.clone(), + "target_url": pipe.target_url.clone(), + "target_endpoint": target_endpoint, + "target_method": target_method, + "field_mapping": field_mapping, + "trigger_type": trigger, + "poll_interval_secs": poll_interval, + }); + + Ok( + AgentEnqueueRequest::new(&pipe.deployment_hash, "activate_pipe") + .with_raw_parameters(params), + ) +} + +#[async_trait] +impl ToolHandler for ListPipesTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let deployment_hash = + resolve_deployment_hash(context, params.deployment_hash, params.deployment_id).await?; + let client = stacker_client(context)?; + let pipes = client + .list_pipe_instances(&deployment_hash) + .await + .map_err(|e| format!("Failed to list pipes: {}", e))?; + + Ok(ToolContent::Text { + text: json!({ + "status": "ok", + "deployment_hash": deployment_hash, + "pipes": pipes, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_pipes".to_string(), + description: "List remote pipe instances for a deployment.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { "type": "number", "description": "The deployment/installation ID" }, + "deployment_hash": { "type": "string", "description": "The deployment hash" } + } + }), + } + } +} + +#[async_trait] +impl ToolHandler for GetPipeTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + pipe_id: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let pipe = require_pipe(&client, ¶ms.pipe_id).await?; + + Ok(ToolContent::Text { + text: json!({ + "status": "ok", + "pipe": pipe, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_pipe".to_string(), + description: "Get details for a single remote pipe instance.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "pipe_id": { "type": "string", "description": "Pipe instance ID" } + }, + "required": ["pipe_id"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for ListPipeTemplatesTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + source_app_type: Option, + #[serde(default)] + target_app_type: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let templates = client + .list_pipe_templates( + params.source_app_type.as_deref(), + params.target_app_type.as_deref(), + ) + .await + .map_err(|e| format!("Failed to list pipe templates: {}", e))?; + + Ok(ToolContent::Text { + text: json!({ + "status": "ok", + "templates": templates, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_pipe_templates".to_string(), + description: + "List remote pipe templates, optionally filtered by source or target app type." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "source_app_type": { "type": "string", "description": "Optional source app type filter" }, + "target_app_type": { "type": "string", "description": "Optional target app type filter" } + } + }), + } + } +} + +#[async_trait] +impl ToolHandler for CreatePipeTemplateTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + request: CreatePipeTemplateApiRequest, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let template = client + .create_pipe_template(¶ms.request) + .await + .map_err(|e| format!("Failed to create pipe template: {}", e))?; + + Ok(ToolContent::Text { + text: json!({ + "status": "created", + "template": template, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "create_pipe_template".to_string(), + description: "Create a reusable remote pipe template.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "request": { + "type": "object", + "description": "Pipe template request matching CreatePipeTemplateApiRequest" + } + }, + "required": ["request"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for CreatePipeInstanceTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + request: CreatePipeInstanceApiRequest, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let deployment_hash = + resolve_deployment_hash(context, params.deployment_hash, params.deployment_id).await?; + let client = stacker_client(context)?; + + let mut request = params.request; + request.deployment_hash = Some(deployment_hash.clone()); + + let pipe = client + .create_pipe_instance(&request) + .await + .map_err(|e| format!("Failed to create pipe instance: {}", e))?; + + Ok(ToolContent::Text { + text: json!({ + "status": "created", + "deployment_hash": deployment_hash, + "pipe": pipe, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "create_pipe_instance".to_string(), + description: "Create a remote pipe instance for a deployment.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { "type": "number", "description": "The deployment/installation ID" }, + "deployment_hash": { "type": "string", "description": "The deployment hash" }, + "request": { + "type": "object", + "description": "Pipe instance request matching CreatePipeInstanceApiRequest" + } + }, + "required": ["request"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for GetPipeHistoryTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + instance_id: String, + #[serde(default)] + limit: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let executions = client + .list_pipe_executions(¶ms.instance_id, params.limit.unwrap_or(20), 0) + .await + .map_err(|e| format!("Failed to fetch pipe execution history: {}", e))?; + + Ok(ToolContent::Text { + text: json!({ + "status": "ok", + "instance_id": params.instance_id, + "executions": executions, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_pipe_history".to_string(), + description: "Get recent execution history for a pipe instance.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "instance_id": { "type": "string", "description": "Pipe instance ID" }, + "limit": { "type": "integer", "description": "Maximum number of executions to return (default: 20)" } + }, + "required": ["instance_id"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for ReplayPipeExecutionTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + execution_id: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let replay = client + .replay_pipe_execution(¶ms.execution_id) + .await + .map_err(|e| format!("Failed to replay pipe execution: {}", e))?; + + Ok(ToolContent::Text { + text: json!({ + "status": "replayed", + "replay": replay, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "replay_pipe_execution".to_string(), + description: "Replay a previous pipe execution.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "execution_id": { "type": "string", "description": "Pipe execution ID" } + }, + "required": ["execution_id"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for ActivatePipeTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + pipe_id: String, + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default = "default_trigger")] + trigger: String, + #[serde(default = "default_poll_interval")] + poll_interval: u32, + #[serde(default = "default_wait_timeout")] + wait_timeout_seconds: u64, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let (pipe, deployment_hash) = resolve_pipe_deployment( + context, + &client, + ¶ms.pipe_id, + params.deployment_hash, + params.deployment_id, + ) + .await?; + ensure_pipe_capability(&client, &deployment_hash).await?; + + client + .update_pipe_status(¶ms.pipe_id, "active") + .await + .map_err(|e| format!("Failed to set pipe status to active: {}", e))?; + + let request = + activate_pipe_request(&client, &pipe, ¶ms.trigger, params.poll_interval).await?; + let command = run_pipe_command(&client, &request, params.wait_timeout_seconds).await?; + + Ok(ToolContent::Text { + text: json!({ + "status": "active", + "pipe_id": params.pipe_id, + "deployment_hash": deployment_hash, + "trigger": params.trigger, + "poll_interval": params.poll_interval, + "command": command, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "activate_pipe".to_string(), + description: "Activate a remote pipe and start its agent listener.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "pipe_id": { "type": "string", "description": "Pipe instance ID" }, + "deployment_id": { "type": "number", "description": "Optional deployment/installation ID for validation" }, + "deployment_hash": { "type": "string", "description": "Optional deployment hash for validation" }, + "trigger": { "type": "string", "description": "Trigger type: webhook, poll, or manual", "default": "webhook" }, + "poll_interval": { "type": "integer", "description": "Poll interval in seconds when trigger=poll", "default": 300 }, + "wait_timeout_seconds": { "type": "integer", "description": "How long MCP should wait for the agent command result", "default": 90 } + }, + "required": ["pipe_id"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for DeactivatePipeTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + pipe_id: String, + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default = "default_wait_timeout")] + wait_timeout_seconds: u64, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let (_pipe, deployment_hash) = resolve_pipe_deployment( + context, + &client, + ¶ms.pipe_id, + params.deployment_hash, + params.deployment_id, + ) + .await?; + ensure_pipe_capability(&client, &deployment_hash).await?; + + client + .update_pipe_status(¶ms.pipe_id, "paused") + .await + .map_err(|e| format!("Failed to set pipe status to paused: {}", e))?; + + let request = AgentEnqueueRequest::new(&deployment_hash, "deactivate_pipe") + .with_raw_parameters(json!({ "pipe_instance_id": params.pipe_id })); + let command = run_pipe_command(&client, &request, params.wait_timeout_seconds).await?; + + Ok(ToolContent::Text { + text: json!({ + "status": "paused", + "pipe_id": params.pipe_id, + "deployment_hash": deployment_hash, + "command": command, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "deactivate_pipe".to_string(), + description: "Pause a remote pipe and stop its agent listener.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "pipe_id": { "type": "string", "description": "Pipe instance ID" }, + "deployment_id": { "type": "number", "description": "Optional deployment/installation ID for validation" }, + "deployment_hash": { "type": "string", "description": "Optional deployment hash for validation" }, + "wait_timeout_seconds": { "type": "integer", "description": "How long MCP should wait for the agent command result", "default": 90 } + }, + "required": ["pipe_id"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for TriggerPipeTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + pipe_id: String, + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default)] + input_data: Value, + #[serde(default = "default_wait_timeout")] + wait_timeout_seconds: u64, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let (_pipe, deployment_hash) = resolve_pipe_deployment( + context, + &client, + ¶ms.pipe_id, + params.deployment_hash, + params.deployment_id, + ) + .await?; + ensure_pipe_capability(&client, &deployment_hash).await?; + + let request = AgentEnqueueRequest::new(&deployment_hash, "trigger_pipe") + .with_raw_parameters(json!({ + "pipe_instance_id": params.pipe_id, + "input_data": params.input_data, + })); + let command = run_pipe_command(&client, &request, params.wait_timeout_seconds).await?; + + Ok(ToolContent::Text { + text: json!({ + "status": "triggered", + "pipe_id": params.pipe_id, + "deployment_hash": deployment_hash, + "command": command, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "trigger_pipe".to_string(), + description: "Execute a one-shot remote pipe trigger with input data.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "pipe_id": { "type": "string", "description": "Pipe instance ID" }, + "deployment_id": { "type": "number", "description": "Optional deployment/installation ID for validation" }, + "deployment_hash": { "type": "string", "description": "Optional deployment hash for validation" }, + "input_data": { + "description": "Optional JSON payload to inject into the pipe trigger", + "oneOf": [ + { "type": "object" }, + { "type": "array" }, + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { "type": "null" } + ] + }, + "wait_timeout_seconds": { "type": "integer", "description": "How long MCP should wait for the agent command result", "default": 90 } + }, + "required": ["pipe_id"] + }), + } + } +} + +fn default_trigger() -> String { + "webhook".to_string() +} + +fn default_poll_interval() -> u32 { + 300 +} + +fn default_wait_timeout() -> u64 { + PIPE_COMMAND_TIMEOUT_SECS +} diff --git a/src/mcp/websocket.rs b/src/mcp/websocket.rs index 44c3daa8..f17cc0f6 100644 --- a/src/mcp/websocket.rs +++ b/src/mcp/websocket.rs @@ -15,6 +15,7 @@ use super::protocol::{ }; use super::registry::{ToolContext, ToolRegistry}; use super::session::McpSession; +use crate::services::TypedErrorEnvelope; /// WebSocket heartbeat interval const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); @@ -179,7 +180,9 @@ impl McpWebSocket { .await { tracing::warn!(tool = %call_req.name, error = %err, "MCP tool authorization failed"); - let response = CallToolResponse::error(format!("Error: {}", err)); + let response = CallToolResponse::typed_error( + TypedErrorEnvelope::from_mcp_error_message(&err), + ); return JsonRpcResponse::success( req.id, serde_json::to_value(response).unwrap(), @@ -209,7 +212,9 @@ impl McpWebSocket { } Err(e) => { tracing::error!("Tool execution failed: {}", e); - let response = CallToolResponse::error(format!("Error: {}", e)); + let response = CallToolResponse::typed_error( + TypedErrorEnvelope::from_mcp_error_message(&e), + ); JsonRpcResponse::success(req.id, serde_json::to_value(response).unwrap()) } } diff --git a/src/models/pipe.rs b/src/models/pipe.rs index c54b6cbc..6f749db4 100644 --- a/src/models/pipe.rs +++ b/src/models/pipe.rs @@ -113,7 +113,9 @@ pub struct PipeInstance { pub id: Uuid, pub template_id: Option, pub deployment_hash: Option, + pub source_adapter: Option, pub source_container: String, + pub target_adapter: Option, pub target_container: Option, pub target_url: Option, pub field_mapping_override: Option, @@ -134,7 +136,9 @@ impl PipeInstance { id: Uuid::new_v4(), template_id: None, deployment_hash: Some(deployment_hash), + source_adapter: None, source_container, + target_adapter: None, target_container: None, target_url: None, field_mapping_override: None, @@ -156,7 +160,9 @@ impl PipeInstance { id: Uuid::new_v4(), template_id: None, deployment_hash: None, + source_adapter: None, source_container, + target_adapter: None, target_container: None, target_url: None, field_mapping_override: None, @@ -182,6 +188,16 @@ impl PipeInstance { self } + pub fn with_source_adapter(mut self, adapter: JsonValue) -> Self { + self.source_adapter = Some(adapter); + self + } + + pub fn with_target_adapter(mut self, adapter: JsonValue) -> Self { + self.target_adapter = Some(adapter); + self + } + pub fn with_target_url(mut self, url: String) -> Self { self.target_url = Some(url); self @@ -384,6 +400,8 @@ mod tests { assert_eq!(instance.status, "draft"); assert!(!instance.is_local); assert!(instance.template_id.is_none()); + assert!(instance.source_adapter.is_none()); + assert!(instance.target_adapter.is_none()); assert!(instance.target_container.is_none()); assert!(instance.target_url.is_none()); assert_eq!(instance.trigger_count, 0); @@ -411,12 +429,16 @@ mod tests { "user789".to_string(), ) .with_template(template_id) + .with_source_adapter(json!({"code": "imap"})) + .with_target_adapter(json!({"code": "smtp"})) .with_target_container("mailchimp_1".to_string()) .with_target_url("https://external.api/hook".to_string()) .with_field_mapping_override(json!({"email": "$.custom_email"})) .with_config_override(json!({"timeout": 30})); assert_eq!(instance.template_id, Some(template_id)); + assert_eq!(instance.source_adapter, Some(json!({"code": "imap"}))); + assert_eq!(instance.target_adapter, Some(json!({"code": "smtp"}))); assert_eq!(instance.target_container, Some("mailchimp_1".to_string())); assert_eq!( instance.target_url, @@ -443,6 +465,8 @@ mod tests { deserialized.deployment_hash, Some("deploy_test".to_string()) ); + assert!(deserialized.source_adapter.is_none()); + assert!(deserialized.target_adapter.is_none()); assert_eq!(deserialized.source_container, "container_a"); assert_eq!(deserialized.status, "draft"); } diff --git a/src/project_app/mod.rs b/src/project_app/mod.rs index 9758948b..1f5db4c1 100644 --- a/src/project_app/mod.rs +++ b/src/project_app/mod.rs @@ -19,6 +19,18 @@ pub(crate) fn is_platform_managed_app_code(value: &str) -> bool { PLATFORM_MANAGED_APP_CODES.contains(&normalized.as_str()) } +pub(crate) fn is_platform_managed_app_identity(service_name: &str, image: Option<&str>) -> bool { + app_identity_candidates(service_name, image) + .iter() + .any(|candidate| is_platform_managed_app_code(candidate)) +} + +pub(crate) fn is_nginx_proxy_manager_identity(service_name: &str, image: Option<&str>) -> bool { + app_identity_candidates(service_name, image) + .iter() + .any(|candidate| candidate == "nginx_proxy_manager") +} + pub(crate) fn normalize_app_code(value: &str) -> String { value .trim() @@ -30,6 +42,28 @@ pub(crate) fn normalize_app_code(value: &str) -> String { .join("_") } +fn app_identity_candidates(service_name: &str, image: Option<&str>) -> Vec { + let normalized_service_name = normalize_app_code(service_name); + let mut candidates = vec![normalized_service_name.clone()]; + if normalized_service_name == "npm" { + candidates.push("nginx_proxy_manager".to_string()); + } + + if let Some(image) = image { + if let Some(image_name) = image.split('/').last() { + if let Some(name_without_tag) = image_name.split(':').next() { + let normalized_image_name = normalize_app_code(name_without_tag); + if normalized_image_name == "npm" { + candidates.push("nginx_proxy_manager".to_string()); + } + candidates.push(normalized_image_name); + } + } + } + + candidates +} + pub(crate) fn is_compose_filename(file_name: &str) -> bool { matches!( file_name, diff --git a/src/project_app/tests.rs b/src/project_app/tests.rs index 8cb7d517..5bb9d4b1 100644 --- a/src/project_app/tests.rs +++ b/src/project_app/tests.rs @@ -1,7 +1,10 @@ use crate::helpers::project::builder::generate_single_app_compose; use super::mapping::{ProjectAppContext, ProjectAppPostArgs}; -use super::{is_platform_managed_app_code, project_app_from_post}; +use super::{ + is_nginx_proxy_manager_identity, is_platform_managed_app_code, + is_platform_managed_app_identity, project_app_from_post, +}; use serde_json::json; /// Example payload from the user's request @@ -45,6 +48,27 @@ fn platform_managed_app_code_normalizes_common_variants() { assert!(!is_platform_managed_app_code("coolify")); } +#[test] +fn platform_managed_app_identity_matches_name_or_image() { + assert!(is_platform_managed_app_identity( + "nginx_proxy_manager", + None + )); + assert!(is_platform_managed_app_identity( + "proxy", + Some("jc21/nginx-proxy-manager:latest") + )); + assert!(is_nginx_proxy_manager_identity( + "proxy", + Some("jc21/nginx-proxy-manager:latest") + )); + assert!(is_nginx_proxy_manager_identity("npm", None)); + assert!(!is_platform_managed_app_identity( + "postgres", + Some("postgres:16-alpine") + )); +} + #[test] fn test_project_app_post_args_from_params() { let payload = example_deploy_app_payload(); diff --git a/src/routes/agent/enqueue.rs b/src/routes/agent/enqueue.rs index 3069a5cc..68d4d061 100644 --- a/src/routes/agent/enqueue.rs +++ b/src/routes/agent/enqueue.rs @@ -6,12 +6,14 @@ use crate::helpers::{ NPM_CREDENTIAL_SOURCE_KEY, }; use crate::models::{Command, CommandPriority, User}; -use crate::routes::legacy_installations::resolve_owned_deployment_by_hash; +use crate::routes::command::enrich_deploy_app_with_compose; +use crate::routes::legacy_installations::{resolve_owned_deployment_by_hash, OwnedDeployment}; use actix_web::{post, web, Responder, Result}; use serde::Deserialize; use std::sync::Arc; const CONFIGURE_PROXY_CAPABILITY_MODE_ENV: &str = "STACKER_CONFIGURE_PROXY_CAPABILITY_MODE"; +const PIPE_COMMAND_TYPES: &[&str] = &["activate_pipe", "deactivate_pipe", "trigger_pipe"]; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ConfigureProxyCapabilityMode { @@ -40,6 +42,10 @@ fn configure_proxy_requires_vault_capability(capabilities: &[String]) -> bool { has_capability_value(capabilities, NPM_CREDENTIAL_SOURCE_KEY, "vault") } +fn command_requires_pipes_capability(command_type: &str) -> bool { + PIPE_COMMAND_TYPES.contains(&command_type) +} + #[derive(Debug, Deserialize)] pub struct EnqueueRequest { pub deployment_hash: String, @@ -68,20 +74,24 @@ pub async fn enqueue_handler( return Err(JsonResponse::<()>::build().bad_request("command_type is required")); } - resolve_owned_deployment_by_hash( + let owned_deployment = resolve_owned_deployment_by_hash( agent_pool.as_ref(), settings.get_ref(), user.as_ref(), &payload.deployment_hash, ) .await?; + let project_id = project_id_from_owned_deployment(&owned_deployment); // Validate parameters let validated_parameters = status_panel::validate_command_parameters(&payload.command_type, &payload.parameters) .map_err(|err| JsonResponse::<()>::build().bad_request(err))?; + let requires_pipes_capability = command_requires_pipes_capability(&payload.command_type); + let agent = if payload.command_type == "configure_proxy" + || requires_pipes_capability || validated_parameters .as_ref() .and_then(|params| params.get("runtime")) @@ -115,6 +125,19 @@ pub async fn enqueue_handler( } } + if requires_pipes_capability { + let capabilities = agent + .as_ref() + .map(|agent| extract_capabilities(agent.capabilities.clone())) + .unwrap_or_default(); + + if !has_capability(&capabilities, "pipes") { + return Err(JsonResponse::<()>::build().bad_request( + "Agent does not support pipe commands. Check agent capabilities at GET /deployments/{hash}/capabilities" + )); + } + } + if payload.command_type == "configure_proxy" { let capabilities = agent .as_ref() @@ -139,6 +162,27 @@ pub async fn enqueue_handler( } } + let final_parameters = if payload.command_type == "deploy_app" { + enrich_deploy_app_with_compose( + &payload.deployment_hash, + validated_parameters, + &settings.vault, + agent_pool.as_ref(), + project_id, + ) + .await + .map_err(|error| { + tracing::error!( + deployment_hash = %payload.deployment_hash, + error = %error, + "Failed to enrich deploy_app command before enqueue" + ); + JsonResponse::<()>::build().internal_server_error(error) + })? + } else { + validated_parameters + }; + // Generate command ID let command_id = format!("cmd_{}", uuid::Uuid::new_v4()); @@ -164,7 +208,7 @@ pub async fn enqueue_handler( ) .with_priority(priority.clone()); - if let Some(params) = &validated_parameters { + if let Some(params) = &final_parameters { command = command.with_parameters(params.clone()); } @@ -194,7 +238,7 @@ pub async fn enqueue_handler( })?; // Extract runtime for tracing - let runtime = validated_parameters + let runtime = final_parameters .as_ref() .and_then(|p| p.get("runtime")) .and_then(|v| v.as_str()) @@ -213,6 +257,13 @@ pub async fn enqueue_handler( .created("Command enqueued")) } +fn project_id_from_owned_deployment(deployment: &OwnedDeployment) -> Option { + match deployment { + OwnedDeployment::Native(deployment) => Some(deployment.project_id), + OwnedDeployment::Legacy(_) => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -242,4 +293,29 @@ mod tests { "status_panel".to_string() ])); } + + #[test] + fn pipe_commands_require_pipe_capability() { + assert!(command_requires_pipes_capability("activate_pipe")); + assert!(command_requires_pipes_capability("deactivate_pipe")); + assert!(command_requires_pipes_capability("trigger_pipe")); + assert!(!command_requires_pipes_capability("restart")); + } + + #[test] + fn native_owned_deployment_exposes_project_id_for_deploy_app_enrichment() { + let deployment = crate::models::Deployment::new( + 65, + Some("user-1".to_string()), + "deployment_test".to_string(), + "active".to_string(), + "runc".to_string(), + serde_json::json!({}), + ); + + assert_eq!( + project_id_from_owned_deployment(&OwnedDeployment::Native(deployment)), + Some(65) + ); + } } diff --git a/src/routes/command/create.rs b/src/routes/command/create.rs index 51187de0..339ed975 100644 --- a/src/routes/command/create.rs +++ b/src/routes/command/create.rs @@ -9,6 +9,7 @@ use crate::project_app::{ store_configs_to_vault_from_params, store_registry_auth_command_to_vault, upsert_app_config_for_deploy, REGISTRY_AUTH_VAULT_KEY, }; +use crate::services::env_model::reconcile_env_file_content; use crate::services::{AppConfig, ConfigRenderer, ProjectAppService, VaultService}; use actix_web::{post, web, Responder, Result}; use serde::{Deserialize, Serialize}; @@ -382,7 +383,7 @@ fn extract_registry_auth_from_params( /// Enrich deploy_app command parameters with compose_content and config_files from Vault /// Falls back to fetching templates from Install Service if not in Vault /// If compose_content is already provided in the request, keep it as-is -async fn enrich_deploy_app_with_compose( +pub(crate) async fn enrich_deploy_app_with_compose( deployment_hash: &str, params: Option, vault_settings: &crate::configuration::VaultSettings, @@ -726,7 +727,7 @@ fn merge_rendered_env_into_app_env_files( .get("content") .and_then(|value| value.as_str()) .unwrap_or_default(); - let merged_content = append_rendered_env(existing_content, rendered_env_content); + let merged_content = reconcile_env_file_content(existing_content, rendered_env_content); if let Some(obj) = config_file.as_object_mut() { obj.insert("content".to_string(), json!(merged_content)); @@ -757,15 +758,6 @@ fn is_app_env_config_file( destination_path.contains(&format!("/{app_code}/docker/")) } -fn append_rendered_env(existing_content: &str, rendered_env_content: &str) -> String { - let existing_content = existing_content.trim_end(); - if existing_content.is_empty() { - return rendered_env_content.to_string(); - } - - format!("{existing_content}\n\n{rendered_env_content}") -} - fn compose_env_file_destinations_for_app(compose_content: &str, app_code: &str) -> HashSet { let Ok(doc) = serde_yaml::from_str::(compose_content) else { return HashSet::new(); @@ -1330,6 +1322,64 @@ services: assert_eq!(config_files[0]["file_mode"], "0644"); } + #[test] + fn merge_rendered_env_replaces_previous_rendered_block() { + let mut config_files = vec![json!({ + "content": "RUST_LOG=debug\n\n# stacker-render version=1 hash=old generated_at=now inputs=service\nOLD_SECRET=outdated\n", + "destination_path": "/opt/stacker/deployments/prod/files/device-api/docker/prod/.env" + })]; + + let merged = merge_rendered_env_into_app_env_files( + &mut config_files, + Some( + r#" +services: + device-api: + env_file: /opt/stacker/deployments/prod/files/device-api/docker/prod/.env +"#, + ), + "device-api", + "# stacker-render version=2 hash=new generated_at=now inputs=service\nNEW_SECRET=fresh\n", + ); + + assert_eq!(merged, 1); + let env_content = config_files[0]["content"].as_str().expect("content"); + assert_eq!( + env_content, + "RUST_LOG=debug\n\n# stacker-render version=2 hash=new generated_at=now inputs=service\nNEW_SECRET=fresh\n" + ); + assert!(!env_content.contains("OLD_SECRET=outdated")); + } + + #[test] + fn merge_rendered_env_removes_authored_key_overridden_by_rendered_block() { + let mut config_files = vec![json!({ + "content": "RUST_LOG=debug\nS3_BUCKET=local\n", + "destination_path": "/opt/stacker/deployments/prod/files/device-api/docker/prod/.env" + })]; + + let merged = merge_rendered_env_into_app_env_files( + &mut config_files, + Some( + r#" +services: + device-api: + env_file: /opt/stacker/deployments/prod/files/device-api/docker/prod/.env +"#, + ), + "device-api", + "# stacker-render version=2 hash=new generated_at=now inputs=service\nS3_BUCKET=remote\n", + ); + + assert_eq!(merged, 1); + let env_content = config_files[0]["content"].as_str().expect("content"); + assert_eq!( + env_content, + "RUST_LOG=debug\n\n# stacker-render version=2 hash=new generated_at=now inputs=service\nS3_BUCKET=remote\n" + ); + assert!(!env_content.contains("S3_BUCKET=local")); + } + #[test] fn merge_rendered_env_matches_app_local_env_by_destination_when_compose_uses_relative_env_file() { diff --git a/src/routes/deployment/capabilities.rs b/src/routes/deployment/capabilities.rs index 6872ed0e..db6ff362 100644 --- a/src/routes/deployment/capabilities.rs +++ b/src/routes/deployment/capabilities.rs @@ -4,6 +4,7 @@ use actix_web::{get, web, Responder, Result}; use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::PgPool; +use std::sync::Arc; use crate::{ db, @@ -45,6 +46,37 @@ pub struct CapabilitiesResponse { pub features: CapabilityFeatures, } +async fn can_view_capabilities( + pool: &PgPool, + user: &crate::models::User, + deployment_hash: &str, +) -> Result { + if user.role == "agent" { + return Ok(user.id == deployment_hash); + } + + let deployment = db::deployment::fetch_by_deployment_hash(pool, deployment_hash) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let Some(deployment) = deployment else { + return Ok(false); + }; + + if deployment.user_id.as_deref() == Some(&user.id) { + return Ok(true); + } + + Ok( + db::project_member::fetch(pool, deployment.project_id, &user.id) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })? + .is_some(), + ) +} + struct CommandMetadata { command_type: &'static str, requires: &'static str, @@ -130,10 +162,15 @@ const COMMAND_CATALOG: &[CommandMetadata] = &[ #[get("/{deployment_hash}/capabilities")] pub async fn capabilities_handler( path: web::Path, + user: web::ReqData>, pg_pool: web::Data, ) -> Result { let deployment_hash = path.into_inner(); + if !can_view_capabilities(pg_pool.get_ref(), user.as_ref(), &deployment_hash).await? { + return Err(JsonResponse::::build().not_found("Deployment not found")); + } + let agent = db::agent::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment_hash) .await .map_err(|err| JsonResponse::::build().internal_server_error(err))?; diff --git a/src/routes/deployment/events.rs b/src/routes/deployment/events.rs new file mode 100644 index 00000000..ce93d288 --- /dev/null +++ b/src/routes/deployment/events.rs @@ -0,0 +1,55 @@ +use actix_web::{get, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::{ + helpers::JsonResponse, + models, + services::{ApiTypedError, DeploymentEventFeed, TypedErrorEnvelope}, +}; + +#[tracing::instrument(name = "Get deployment events by hash", skip_all)] +#[get("/{deployment_hash}/events")] +pub async fn events_handler( + path: web::Path, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let deployment_hash = path.into_inner(); + let deployment = + crate::db::deployment::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|_| { + ApiTypedError::internal(TypedErrorEnvelope::internal_error( + "Failed to load deployment events", + )) + })? + .ok_or_else(|| { + ApiTypedError::not_found(TypedErrorEnvelope::deployment_not_found( + "Deployment not found", + )) + })?; + + if deployment.user_id.as_deref() != Some(&user.id) { + return Err(ApiTypedError::not_found( + TypedErrorEnvelope::deployment_not_found("Deployment not found"), + )); + } + + let feed = DeploymentEventFeed::for_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|_| { + ApiTypedError::internal(TypedErrorEnvelope::internal_error( + "Failed to build deployment events", + )) + })? + .ok_or_else(|| { + ApiTypedError::not_found(TypedErrorEnvelope::deployment_not_found( + "Deployment not found", + )) + })?; + + Ok(JsonResponse::build() + .set_item(feed) + .ok("Deployment events fetched")) +} diff --git a/src/routes/deployment/mod.rs b/src/routes/deployment/mod.rs index b53d18d4..95a6b675 100644 --- a/src/routes/deployment/mod.rs +++ b/src/routes/deployment/mod.rs @@ -1,7 +1,13 @@ pub mod capabilities; +pub mod events; pub mod force_complete; +pub mod plan; +pub mod state; pub mod status; pub use capabilities::*; +pub use events::*; pub use force_complete::*; +pub use plan::*; +pub use state::*; pub use status::*; diff --git a/src/routes/deployment/plan.rs b/src/routes/deployment/plan.rs new file mode 100644 index 00000000..7a118690 --- /dev/null +++ b/src/routes/deployment/plan.rs @@ -0,0 +1,111 @@ +use actix_web::{get, web, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::{ + models, + services::{ + build_deploy_plan, build_rollback_plan, resolve_rollback_plan_context, ApiTypedError, + DeployPlanOperation, DeploymentState, TypedErrorCode, TypedErrorEnvelope, + }, +}; + +#[derive(Debug, Deserialize)] +pub struct DeploymentPlanQuery { + #[serde(default)] + pub operation: Option, + #[serde(default, rename = "appCode")] + pub app_code: Option, + #[serde(default)] + pub target: Option, + #[serde(default, rename = "expectedFingerprint")] + pub expected_fingerprint: Option, + #[serde(default, rename = "rollbackTarget")] + pub rollback_target: Option, +} + +#[tracing::instrument(name = "Get deployment plan by hash", skip_all)] +#[get("/{deployment_hash}/plan")] +pub async fn plan_handler( + path: web::Path, + query: web::Query, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let deployment_hash = path.into_inner(); + let deployment = + crate::db::deployment::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|_| { + ApiTypedError::internal(TypedErrorEnvelope::internal_error( + "Failed to load deployment for plan", + )) + })? + .ok_or_else(|| { + ApiTypedError::not_found(TypedErrorEnvelope::deployment_not_found( + "Deployment not found", + )) + })?; + + if deployment.user_id.as_deref() != Some(&user.id) { + return Err(ApiTypedError::not_found( + TypedErrorEnvelope::deployment_not_found("Deployment not found"), + )); + } + + let state = DeploymentState::for_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|_| { + ApiTypedError::internal(TypedErrorEnvelope::internal_error( + "Failed to build deployment plan state", + )) + })? + .ok_or_else(|| { + ApiTypedError::not_found(TypedErrorEnvelope::deployment_not_found( + "Deployment not found", + )) + })?; + + let operation = query + .operation + .clone() + .unwrap_or(DeployPlanOperation::Deploy); + let target = query.target.as_deref().unwrap_or("cloud"); + let plan = match operation { + DeployPlanOperation::RollbackDeploy => { + let requested_target = query.rollback_target.as_deref().ok_or_else(|| { + ApiTypedError::bad_request(TypedErrorEnvelope::invalid_request( + "rollbackTarget is required for rollback plans", + )) + })?; + let rollback = + resolve_rollback_plan_context(pg_pool.get_ref(), &deployment, requested_target) + .await + .map_err(ApiTypedError::bad_request)?; + build_rollback_plan( + &state, + target, + rollback, + query.expected_fingerprint.as_deref(), + ) + } + _ => build_deploy_plan( + &state, + operation, + target, + query.app_code.as_deref(), + query.expected_fingerprint.as_deref(), + ), + } + .map_err(|error| match error.code { + TypedErrorCode::PlanStale => ApiTypedError::conflict(error), + TypedErrorCode::InvalidRequest => ApiTypedError::bad_request(error), + TypedErrorCode::RollbackTargetUnavailable => ApiTypedError::bad_request(error), + _ => ApiTypedError::internal(error), + })?; + + Ok(crate::helpers::JsonResponse::build() + .set_item(plan) + .ok("Deployment plan fetched")) +} diff --git a/src/routes/deployment/state.rs b/src/routes/deployment/state.rs new file mode 100644 index 00000000..608e4168 --- /dev/null +++ b/src/routes/deployment/state.rs @@ -0,0 +1,55 @@ +use actix_web::{get, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::{ + helpers::JsonResponse, + models, + services::{ApiTypedError, DeploymentState, TypedErrorEnvelope}, +}; + +#[tracing::instrument(name = "Get canonical deployment state by hash", skip_all)] +#[get("/{deployment_hash}/state")] +pub async fn state_handler( + path: web::Path, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let deployment_hash = path.into_inner(); + let deployment = + crate::db::deployment::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|_| { + ApiTypedError::internal(TypedErrorEnvelope::internal_error( + "Failed to load deployment state", + )) + })? + .ok_or_else(|| { + ApiTypedError::not_found(TypedErrorEnvelope::deployment_not_found( + "Deployment not found", + )) + })?; + + if deployment.user_id.as_deref() != Some(&user.id) { + return Err(ApiTypedError::not_found( + TypedErrorEnvelope::deployment_not_found("Deployment not found"), + )); + } + + let state = DeploymentState::for_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|_| { + ApiTypedError::internal(TypedErrorEnvelope::internal_error( + "Failed to build deployment state", + )) + })?; + + match state { + Some(state) => Ok(JsonResponse::build() + .set_item(state) + .ok("Deployment state fetched")), + None => Err(ApiTypedError::not_found( + TypedErrorEnvelope::deployment_not_found("Deployment not found"), + )), + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 5688dba3..98a3e83d 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -24,8 +24,9 @@ pub(crate) mod pipe; pub use agreement::*; pub use deployment::{ - capabilities_handler, force_complete_handler, list_handler, status_by_project_handler, - status_handler, DeploymentListQuery, DeploymentStatusResponse, + capabilities_handler, events_handler, force_complete_handler, list_handler, plan_handler, + state_handler, status_by_project_handler, status_handler, DeploymentListQuery, + DeploymentStatusResponse, }; pub use marketplace::{ analytics_handler, approve_handler, create_handler, list_plans_handler, list_submitted_handler, diff --git a/src/routes/pipe/create.rs b/src/routes/pipe/create.rs index 4395f9ad..baa36720 100644 --- a/src/routes/pipe/create.rs +++ b/src/routes/pipe/create.rs @@ -2,6 +2,7 @@ use crate::db; use crate::helpers::JsonResponse; use crate::models::{PipeInstance, PipeTemplate, User}; use actix_web::{post, web, Responder, Result}; +use pipe_adapter_sdk::PipeAdapterReference; use serde::Deserialize; use serde_json::Value as JsonValue; use sqlx::PgPool; @@ -29,8 +30,12 @@ pub struct CreatePipeTemplateRequest { pub struct CreatePipeInstanceRequest { #[serde(default)] pub deployment_hash: Option, + #[serde(default)] + pub source_adapter: Option, pub source_container: String, #[serde(default)] + pub target_adapter: Option, + #[serde(default)] pub target_container: Option, #[serde(default)] pub target_url: Option, @@ -124,9 +129,9 @@ pub async fn create_instance_handler( if req.source_container.trim().is_empty() { return Err(JsonResponse::<()>::build().bad_request("source_container is required")); } - if req.target_container.is_none() && req.target_url.is_none() { + if req.target_container.is_none() && req.target_url.is_none() && req.target_adapter.is_none() { return Err(JsonResponse::<()>::build() - .bad_request("either target_container or target_url is required")); + .bad_request("either target_container, target_url, or target_adapter is required")); } // For remote pipes, verify deployment belongs to the requesting user @@ -168,6 +173,16 @@ pub async fn create_instance_handler( if let Some(template_id) = req.template_id { instance = instance.with_template(template_id); } + if let Some(adapter) = &req.source_adapter { + let adapter = serde_json::to_value(adapter) + .map_err(|err| JsonResponse::<()>::build().internal_server_error(err.to_string()))?; + instance = instance.with_source_adapter(adapter); + } + if let Some(adapter) = &req.target_adapter { + let adapter = serde_json::to_value(adapter) + .map_err(|err| JsonResponse::<()>::build().internal_server_error(err.to_string()))?; + instance = instance.with_target_adapter(adapter); + } if let Some(target) = &req.target_container { instance = instance.with_target_container(target.clone()); } diff --git a/src/routes/pipe/deploy.rs b/src/routes/pipe/deploy.rs index ad7ac8ac..47570162 100644 --- a/src/routes/pipe/deploy.rs +++ b/src/routes/pipe/deploy.rs @@ -74,6 +74,8 @@ pub async fn deploy_pipe_handler( source_instance.source_container.clone(), user.id.clone(), ); + remote.source_adapter = source_instance.source_adapter.clone(); + remote.target_adapter = source_instance.target_adapter.clone(); remote.target_container = source_instance.target_container.clone(); remote.target_url = source_instance.target_url.clone(); remote.template_id = source_instance.template_id; diff --git a/src/routes/project/app.rs b/src/routes/project/app.rs index 3d75b0d0..a9ecc915 100644 --- a/src/routes/project/app.rs +++ b/src/routes/project/app.rs @@ -16,7 +16,9 @@ use crate::db; use crate::helpers::JsonResponse; use crate::models::{self, Project}; -use crate::services::ProjectAppService; +use crate::services::{ + runtime_env_contract_response, ProjectAppService, RuntimeEnvContractResponse, +}; use actix_web::{delete, get, post, put, web, Responder, Result}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -94,6 +96,7 @@ pub struct AppConfigResponse { pub project_id: i32, pub app_code: String, pub environment: Value, + pub runtime_env_contract: RuntimeEnvContractResponse, pub ports: Value, pub volumes: Value, pub domain: Option, @@ -444,6 +447,7 @@ pub async fn get_app_config( project_id, app_code: code, environment: env, + runtime_env_contract: runtime_env_contract_response(), ports: app.ports.clone().unwrap_or(json!([])), volumes: app.volumes.clone().unwrap_or(json!([])), domain: app.domain.clone(), @@ -499,6 +503,7 @@ pub async fn get_env_vars( "project_id": project_id, "app_code": code, "variables": env, + "runtime_env_contract": runtime_env_contract_response(), "count": env.as_object().map(|o| o.len()).unwrap_or(0), "note": "Sensitive values (passwords, tokens, keys) are redacted" }); diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index eed1b9d4..adc09a51 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -19,6 +19,15 @@ use std::sync::Arc; use std::time::Duration; use uuid::Uuid; +const MANAGED_NGINX_PROXY_MANAGER_FEATURE: &str = "nginx_proxy_manager"; +const STATUS_PANEL_FEATURE: &str = "statuspanel"; +const STATUS_PANEL_CONNECTION_MODE: &str = "status_panel"; +const STATUS_PANEL_NPM_CREDENTIALS_SECRET: &str = "npm_credentials"; +const DEFAULT_STATUS_PANEL_NPM_HOST: &str = "http://nginx-proxy-manager:81"; +const DEFAULT_STATUS_PANEL_NPM_EMAIL: &str = "admin@example.com"; +const DEFAULT_STATUS_PANEL_NPM_PASSWORD: &str = "changeme"; +const DEFAULT_STATUS_PANEL_NPM_AUTH_MODE: &str = "email_password"; + fn parse_template_requirements( template: &models::StackTemplate, ) -> Result { @@ -476,6 +485,98 @@ fn build_rollback_deploy_form(template_stack_code: String) -> forms::project::De } } +fn deploy_features_contain(features: Option<&Vec>, expected: &str) -> bool { + features.is_some_and(|items| { + items.iter().any(|feature| { + feature + .as_str() + .is_some_and(|value| value.eq_ignore_ascii_case(expected)) + }) + }) +} + +fn deploy_uses_managed_nginx_proxy_manager(form: &forms::project::Deploy) -> bool { + deploy_features_contain( + form.stack.extended_features.as_ref(), + MANAGED_NGINX_PROXY_MANAGER_FEATURE, + ) +} + +fn deploy_uses_status_panel_agent(form: &forms::project::Deploy) -> bool { + form.server.connection_mode.as_deref() == Some(STATUS_PANEL_CONNECTION_MODE) + || deploy_features_contain( + form.stack.integrated_features.as_ref(), + STATUS_PANEL_FEATURE, + ) +} + +fn should_seed_default_status_panel_npm_credentials(form: &forms::project::Deploy) -> bool { + deploy_uses_managed_nginx_proxy_manager(form) && deploy_uses_status_panel_agent(form) +} + +fn default_status_panel_npm_credentials() -> serde_json::Value { + serde_json::json!({ + "schema_version": 1, + "host": DEFAULT_STATUS_PANEL_NPM_HOST, + "email": DEFAULT_STATUS_PANEL_NPM_EMAIL, + "password": DEFAULT_STATUS_PANEL_NPM_PASSWORD, + "auth_mode": DEFAULT_STATUS_PANEL_NPM_AUTH_MODE + }) +} + +async fn ensure_default_status_panel_npm_credentials( + user: &models::User, + form: &forms::project::Deploy, + pg_pool: &PgPool, + settings: &Settings, + server: &models::Server, +) -> Result { + if !should_seed_default_status_panel_npm_credentials(form) { + return Ok(false); + } + + if db::remote_secret::fetch_server_secret( + pg_pool, + &user.id, + server.id, + STATUS_PANEL_NPM_CREDENTIALS_SECRET, + ) + .await? + .is_some() + { + return Ok(false); + } + + let vault = services::VaultService::from_settings(&settings.vault) + .map_err(|error| error.to_string())?; + let vault_path = vault.status_panel_npm_credentials_path(server.id); + let default_credentials = default_status_panel_npm_credentials(); + + vault + .store_structured_secret_value(&vault_path, &default_credentials) + .await + .map_err(|error| error.to_string())?; + + db::remote_secret::upsert_server_secret( + pg_pool, + &user.id, + server.id, + STATUS_PANEL_NPM_CREDENTIALS_SECRET, + &vault_path, + &user.id, + "synced", + ) + .await?; + + tracing::info!( + "Seeded default Nginx Proxy Manager credentials for server {} at {}", + server.id, + vault_path + ); + + Ok(true) +} + fn is_non_empty_json(value: &serde_json::Value) -> bool { match value { serde_json::Value::Null => false, @@ -822,6 +923,85 @@ fn compose_content_from_config_files( Ok(None) } +fn runtime_config_files_from_deploy_config_files( + config_files: &serde_json::Value, +) -> Result { + let files = config_files + .as_array() + .ok_or_else(|| "config_files must be an array".to_string())?; + let mut runtime_files = Vec::new(); + + for file in files { + let file_name = file + .get("name") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .or_else(|| { + file.get("destination_path") + .and_then(|value| value.as_str()) + .and_then(basename_from_path) + }); + + if file_name.is_some_and(crate::project_app::is_compose_filename) { + continue; + } + + let path = file + .get("destination_path") + .or_else(|| file.get("path")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "config file is missing destination_path".to_string())?; + let content = file + .get("content") + .and_then(|value| value.as_str()) + .ok_or_else(|| format!("config file '{}' is missing string content", path))?; + + let mut runtime_file = serde_json::json!({ + "path": path, + "content": content, + }); + if let Some(mode) = file + .get("file_mode") + .or_else(|| file.get("mode")) + .and_then(|value| value.as_str()) + { + runtime_file["mode"] = serde_json::json!(mode); + } + runtime_files.push(runtime_file); + } + + Ok(serde_json::Value::Array(runtime_files)) +} + +fn merge_marketplace_config_files(target: &mut serde_json::Value, generated: &serde_json::Value) { + let Some(generated_files) = generated.as_array().filter(|files| !files.is_empty()) else { + return; + }; + + let custom = ensure_custom_object(target); + let existing = custom + .entry("marketplace_config_files".to_string()) + .or_insert_with(|| serde_json::json!([])); + if !existing.is_array() { + *existing = serde_json::json!([]); + } + + let existing_files = existing + .as_array_mut() + .expect("marketplace_config_files should be normalized to an array"); + for generated_file in generated_files { + let generated_path = generated_file.get("path").and_then(|value| value.as_str()); + if let Some(path) = generated_path { + existing_files + .retain(|file| file.get("path").and_then(|value| value.as_str()) != Some(path)); + } + existing_files.push(generated_file.clone()); + } +} + fn apply_deploy_bundle( project: &mut models::Project, form: &forms::project::Deploy, @@ -845,6 +1025,9 @@ fn apply_deploy_bundle( Some(config_files) => { upsert_root_field(&mut project.metadata, "config_files", config_files); upsert_root_field(&mut project.request_json, "config_files", config_files); + let runtime_config_files = runtime_config_files_from_deploy_config_files(config_files)?; + merge_marketplace_config_files(&mut project.metadata, &runtime_config_files); + merge_marketplace_config_files(&mut project.request_json, &runtime_config_files); compose_content_from_config_files(config_files)? } None => None, @@ -1272,6 +1455,16 @@ pub async fn item( .await .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + ensure_default_status_panel_npm_credentials( + user.as_ref(), + &form, + pg_pool.get_ref(), + sets.get_ref(), + &server, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + if let Some(template) = marketplace_template.as_ref() { let requirements = parse_template_requirements(template) .map_err(|msg| JsonResponse::::build().bad_request(msg))?; @@ -1509,6 +1702,16 @@ pub async fn saved_item( .await .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + ensure_default_status_panel_npm_credentials( + user.as_ref(), + &form, + pg_pool.get_ref(), + sets.get_ref(), + &server, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + if let Some(template) = marketplace_template.as_ref() { let requirements = parse_template_requirements(template) .map_err(|msg| JsonResponse::::build().bad_request(msg))?; @@ -1677,10 +1880,11 @@ pub async fn rollback( mod tests { use super::{ apply_deploy_bundle, build_runtime_artifact_bundle, compose_content_from_config_files, - find_matching_hetzner_server, hetzner_server_ip, preserve_marketplace_runtime_artifacts, - resolve_provided_ssh_keypair, sync_runtime_artifact_bundle, validate_min_cpu_requirement, - validate_min_disk_requirement, validate_min_ram_requirement, HetznerIpv4, HetznerPublicNet, - HetznerServer, + default_status_panel_npm_credentials, find_matching_hetzner_server, hetzner_server_ip, + preserve_marketplace_runtime_artifacts, resolve_provided_ssh_keypair, + should_seed_default_status_panel_npm_credentials, sync_runtime_artifact_bundle, + validate_min_cpu_requirement, validate_min_disk_requirement, validate_min_ram_requirement, + HetznerIpv4, HetznerPublicNet, HetznerServer, }; use crate::configuration::Settings; use crate::connectors::app_service_catalog::ServerCapacity; @@ -1737,6 +1941,47 @@ mod tests { } } + #[test] + fn status_panel_managed_proxy_deploy_seeds_default_npm_credentials() { + let form = forms::project::Deploy { + stack: forms::project::Stack { + extended_features: Some(vec![json!("nginx_proxy_manager")]), + ..Default::default() + }, + server: forms::ServerForm { + connection_mode: Some("status_panel".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + assert!(should_seed_default_status_panel_npm_credentials(&form)); + } + + #[test] + fn deploy_without_status_panel_does_not_seed_default_npm_credentials() { + let form = forms::project::Deploy { + stack: forms::project::Stack { + extended_features: Some(vec![json!("nginx_proxy_manager")]), + ..Default::default() + }, + ..Default::default() + }; + + assert!(!should_seed_default_status_panel_npm_credentials(&form)); + } + + #[test] + fn default_status_panel_npm_credentials_match_cli_status_defaults() { + let credentials = default_status_panel_npm_credentials(); + + assert_eq!(credentials["schema_version"], 1); + assert_eq!(credentials["host"], "http://nginx-proxy-manager:81"); + assert_eq!(credentials["email"], "admin@example.com"); + assert_eq!(credentials["password"], "changeme"); + assert_eq!(credentials["auth_mode"], "email_password"); + } + #[test] fn hetzner_server_matching_prefers_name_or_ip() { let provider_servers = vec![ @@ -1917,7 +2162,8 @@ mod tests { { "name": ".env", "content": "WEBSITE_IMAGE=syncopiaapp/website:latest\n", - "destination_path": "/opt/stacker/deployments/prod/files/.env" + "destination_path": ".env", + "file_mode": "0644" } ])), config_bundle: Some(json!({ @@ -1925,7 +2171,7 @@ mod tests { "environment": "prod", "config_files": [ { - "destination_path": "/opt/stacker/deployments/prod/files/.env" + "destination_path": ".env" } ] } @@ -1951,7 +2197,19 @@ mod tests { assert_eq!( project.request_json["custom"]["deployment_artifacts"]["config_bundle"]["config_files"] [0]["destination_path"], - json!("/opt/stacker/deployments/prod/files/.env") + json!(".env") + ); + assert_eq!( + project.metadata["custom"]["marketplace_config_files"][0]["path"], + json!(".env") + ); + assert_eq!( + project.metadata["custom"]["marketplace_config_files"][0]["content"], + json!("WEBSITE_IMAGE=syncopiaapp/website:latest\n") + ); + assert_eq!( + project.metadata["custom"]["marketplace_config_files"][0]["mode"], + json!("0644") ); } diff --git a/src/routes/server/secret.rs b/src/routes/server/secret.rs index e190751b..8276fbf5 100644 --- a/src/routes/server/secret.rs +++ b/src/routes/server/secret.rs @@ -9,6 +9,8 @@ use serde_valid::Validate; use sqlx::PgPool; use std::sync::Arc; +const STATUS_PANEL_NPM_CREDENTIALS_SECRET: &str = "npm_credentials"; + async fn fetch_owned_server( pool: &PgPool, user: &models::User, @@ -33,6 +35,23 @@ fn build_vault( .map_err(|error| JsonResponse::internal_server_error(error.to_string())) } +fn uses_status_panel_npm_credentials_contract(name: &str) -> bool { + name == STATUS_PANEL_NPM_CREDENTIALS_SECRET +} + +fn server_secret_vault_path( + vault: &VaultService, + user_id: &str, + server_id: i32, + name: &str, +) -> String { + if uses_status_panel_npm_credentials_contract(name) { + vault.status_panel_npm_credentials_path(server_id) + } else { + vault.server_secret_path(user_id, server_id, name) + } +} + #[tracing::instrument(name = "List server secrets", skip_all)] #[get("/{server_id}/secrets")] pub async fn list( @@ -95,11 +114,29 @@ pub async fn upsert( .map_err(|e| JsonResponse::bad_request(e.to_string()))?; let vault = build_vault(settings.get_ref())?; - let vault_path = vault.server_secret_path(&user.id, server_id, &name); - vault - .store_secret_value(&vault_path, &body.value) - .await - .map_err(|error| JsonResponse::internal_server_error(error.to_string()))?; + let vault_path = server_secret_vault_path(&vault, &user.id, server_id, &name); + if uses_status_panel_npm_credentials_contract(&name) { + let parsed = serde_json::from_str::(&body.value).map_err(|error| { + JsonResponse::bad_request(format!( + "npm_credentials body must be valid JSON: {}", + error + )) + })?; + if !parsed.is_object() { + return Err(JsonResponse::bad_request( + "npm_credentials body must be a JSON object".to_string(), + )); + } + vault + .store_structured_secret_value(&vault_path, &parsed) + .await + .map_err(|error| JsonResponse::internal_server_error(error.to_string()))?; + } else { + vault + .store_secret_value(&vault_path, &body.value) + .await + .map_err(|error| JsonResponse::internal_server_error(error.to_string()))?; + } let secret = db::remote_secret::upsert_server_secret( pg_pool.get_ref(), diff --git a/src/services/config_renderer.rs b/src/services/config_renderer.rs index 2efa8385..dd06c1df 100644 --- a/src/services/config_renderer.rs +++ b/src/services/config_renderer.rs @@ -12,6 +12,9 @@ use crate::configuration::DeploymentSettings; use crate::db; use crate::helpers::env_path::{compose_env_file_reference, remote_runtime_env_path}; use crate::models::{Project, ProjectApp}; +use crate::services::env_model::{ + normalize_optional_json_env, reconcile_env_layers, EnvLayer, ReconciledEnv, +}; use crate::services::vault_service::{AppConfig, VaultError, VaultService}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -50,6 +53,7 @@ pub struct EnvRenderInput { pub version: u64, pub generated_at: chrono::DateTime, pub base: HashMap, + pub generated: HashMap, pub server: HashMap, pub inherit_server_secrets: bool, pub service: HashMap, @@ -62,6 +66,7 @@ impl Default for EnvRenderInput { version: 1, generated_at: chrono::Utc::now(), base: HashMap::new(), + generated: HashMap::new(), server: HashMap::new(), inherit_server_secrets: false, service: HashMap::new(), @@ -107,7 +112,8 @@ pub struct EnvRenderAuditEvent { } pub fn render_env(input: EnvRenderInput) -> std::result::Result { - let (environment, inputs) = merge_env_layers(&input); + let ReconciledEnv { entries, inputs } = reconcile_render_input(&input); + let environment = entries; validate_env(&environment)?; let body = format_env_body(&environment); @@ -215,38 +221,65 @@ pub fn emit_env_render_audit(event: &EnvRenderAuditEvent) { ); } -fn merge_env_layers(input: &EnvRenderInput) -> (BTreeMap, Vec<&'static str>) { - let mut environment = BTreeMap::new(); - let mut inputs = Vec::new(); - - merge_layer(&mut environment, &input.base); - if !input.base.is_empty() { - inputs.push("base"); - } +fn reconcile_render_input(input: &EnvRenderInput) -> ReconciledEnv { + let mut layers = vec![ + EnvLayer { + name: "base", + entries: &input.base, + include_in_inputs: true, + }, + EnvLayer { + name: "generated", + entries: &input.generated, + include_in_inputs: false, + }, + ]; if input.inherit_server_secrets { - merge_layer(&mut environment, &input.server); - if !input.server.is_empty() { - inputs.push("server"); - } - } - - merge_layer(&mut environment, &input.service); - if !input.service.is_empty() { - inputs.push("service"); + layers.push(EnvLayer { + name: "server", + entries: &input.server, + include_in_inputs: true, + }); } - merge_layer(&mut environment, &input.compose_environment); - if !input.compose_environment.is_empty() { - inputs.push("compose"); - } + layers.push(EnvLayer { + name: "service", + entries: &input.service, + include_in_inputs: true, + }); + layers.push(EnvLayer { + name: "compose", + entries: &input.compose_environment, + include_in_inputs: true, + }); + + reconcile_env_layers(&layers) +} - (environment, inputs) +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct ResolvedAppEnvironment { + authored: HashMap, + service: HashMap, } -fn merge_layer(target: &mut BTreeMap, layer: &HashMap) { - for (key, value) in layer { - target.insert(key.clone(), value.clone()); +impl ResolvedAppEnvironment { + fn effective(&self) -> HashMap { + reconcile_env_layers(&[ + EnvLayer { + name: "base", + entries: &self.authored, + include_in_inputs: false, + }, + EnvLayer { + name: "service", + entries: &self.service, + include_in_inputs: false, + }, + ]) + .entries + .into_iter() + .collect() } } @@ -295,6 +328,20 @@ fn sha256_hex(bytes: &[u8]) -> String { format!("{:x}", digest) } +fn project_target(project: &Project) -> Option { + ["target", "deployment_target", "deploy_target"] + .iter() + .find_map(|key| { + project + .metadata + .get(*key) + .or_else(|| project.request_json.get(*key)) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + }) +} + pub fn env_body_hash(content: &str) -> String { let body = content .strip_prefix("# stacker-render ") @@ -445,7 +492,16 @@ impl ConfigRenderer { for app in apps.iter().filter(|a| a.is_enabled()) { let environment = self.resolve_app_environment(pool, project, app).await?; - app_contexts.push(self.project_app_to_context(app, environment.clone())?); + let mut context = self.project_app_to_context(app, environment.effective())?; + crate::helpers::stacker_labels::insert_runtime_labels( + &mut context.labels, + Some(project.id), + project_target(project).as_deref(), + crate::helpers::stacker_labels::SCOPE_PROJECT, + &app.code, + &app.code, + ); + app_contexts.push(context); let rendered_env = self.render_env_file(app, deployment_hash, &environment)?; let config = AppConfig { @@ -535,20 +591,19 @@ impl ConfigRenderer { pool: &PgPool, project: &Project, app: &ProjectApp, - ) -> Result> { - let mut environment = self.parse_environment(&app.environment)?; - self.merge_service_secrets(pool, project, app, &mut environment) - .await?; - Ok(environment) + ) -> Result { + Ok(ResolvedAppEnvironment { + authored: self.parse_environment(&app.environment)?, + service: self.load_service_secrets(pool, project, app).await?, + }) } - async fn merge_service_secrets( + async fn load_service_secrets( &self, pool: &PgPool, project: &Project, app: &ProjectApp, - environment: &mut HashMap, - ) -> Result<()> { + ) -> Result> { let secrets = db::remote_secret::list_service_secrets(pool, &project.user_id, project.id, &app.code) .await @@ -557,7 +612,7 @@ impl ConfigRenderer { })?; if secrets.is_empty() { - return Ok(()); + return Ok(HashMap::new()); } let vault = self.vault_service.as_ref().ok_or_else(|| { @@ -567,6 +622,7 @@ impl ConfigRenderer { ) })?; + let mut service_secrets = HashMap::new(); for secret in secrets { let value = vault .fetch_secret_value(&secret.vault_path) @@ -579,43 +635,17 @@ impl ConfigRenderer { error ) })?; - environment.insert(secret.name, value); + service_secrets.insert(secret.name, value); } - Ok(()) + Ok(service_secrets) } /// Parse environment JSON to HashMap fn parse_environment(&self, env: &Option) -> Result> { - match env { - Some(Value::Object(map)) => { - let mut result = HashMap::new(); - for (k, v) in map { - let value = match v { - Value::String(s) => s.clone(), - Value::Number(n) => n.to_string(), - Value::Bool(b) => b.to_string(), - _ => v.to_string(), - }; - result.insert(k.clone(), value); - } - Ok(result) - } - Some(Value::Array(arr)) => { - // Handle array format: ["VAR=value", "VAR2=value2"] - let mut result = HashMap::new(); - for item in arr { - if let Value::String(s) = item { - if let Some((k, v)) = s.split_once('=') { - result.insert(k.to_string(), v.to_string()); - } - } - } - Ok(result) - } - None => Ok(HashMap::new()), - _ => Ok(HashMap::new()), - } + Ok(normalize_optional_json_env(env.as_ref()) + .into_iter() + .collect()) } /// Parse ports JSON to Vec @@ -851,20 +881,22 @@ impl ConfigRenderer { &self, app: &ProjectApp, deployment_hash: &str, - environment: &HashMap, + environment: &ResolvedAppEnvironment, ) -> Result { - let mut base = environment.clone(); - base.insert("DEPLOYMENT_HASH".to_string(), deployment_hash.to_string()); + let mut generated = + HashMap::from([("DEPLOYMENT_HASH".to_string(), deployment_hash.to_string())]); if let Some(domain) = &app.domain { - base.insert("APP_DOMAIN".to_string(), domain.clone()); + generated.insert("APP_DOMAIN".to_string(), domain.clone()); } if app.ssl_enabled.unwrap_or(false) { - base.insert("SSL_ENABLED".to_string(), "true".to_string()); + generated.insert("SSL_ENABLED".to_string(), "true".to_string()); } render_env(EnvRenderInput { - base, + base: environment.authored.clone(), + generated, + service: environment.service.clone(), generated_at: chrono::Utc::now(), ..EnvRenderInput::default() }) @@ -1182,6 +1214,7 @@ mod tests { ("SHARED".to_string(), "base".to_string()), ("BASE_ONLY".to_string(), "yes".to_string()), ]), + generated: HashMap::new(), server: HashMap::from([("SHARED".to_string(), "server".to_string())]), inherit_server_secrets: true, service: HashMap::from([("SHARED".to_string(), "service".to_string())]), @@ -1224,6 +1257,19 @@ mod tests { assert!(!rendered.content.contains("S3_BUCKET=")); } + #[test] + fn render_env_generated_layer_overrides_authored_value() { + let rendered = render_env(EnvRenderInput { + base: HashMap::from([("DEPLOYMENT_HASH".to_string(), "stale".to_string())]), + generated: HashMap::from([("DEPLOYMENT_HASH".to_string(), "fresh".to_string())]), + ..EnvRenderInput::default() + }) + .unwrap(); + + assert!(rendered.content.contains("DEPLOYMENT_HASH=fresh\n")); + assert!(!rendered.inputs.contains(&"generated")); + } + #[test] fn render_env_rejects_reserved_prefix() { let result = render_env(EnvRenderInput { @@ -1292,6 +1338,17 @@ mod tests { assert!(first.content.ends_with("A=1\nB=2\n")); } + #[test] + fn project_target_reads_stable_target_metadata() { + let project = Project { + metadata: json!({"target": "cloud"}), + request_json: json!({"target": "server"}), + ..Project::default() + }; + + assert_eq!(project_target(&project).as_deref(), Some("cloud")); + } + #[test] fn format_header_stamp_is_deterministic() { let generated_at = chrono::DateTime::parse_from_rfc3339("2026-05-13T17:00:00Z") @@ -1675,4 +1732,47 @@ mod tests { assert!(compose.contains("env_file:\n - .env")); } + + #[test] + fn render_compose_includes_stacker_runtime_labels() { + let renderer = ConfigRenderer::new().unwrap(); + let project = Project { + name: "demo".to_string(), + ..Project::default() + }; + let mut labels = HashMap::new(); + crate::helpers::stacker_labels::insert_runtime_labels( + &mut labels, + Some(42), + Some("cloud"), + crate::helpers::stacker_labels::SCOPE_PROJECT, + "web", + "web", + ); + 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, + healthcheck: None, + runtime: None, + }; + + let compose = renderer.render_compose(&[ctx], &project).unwrap(); + + assert!(compose.contains("my.stacker.project_id: \"42\"")); + assert!(compose.contains("my.stacker.target: \"cloud\"")); + assert!(compose.contains("my.stacker.scope: \"project\"")); + assert!(compose.contains("my.stacker.service: \"web\"")); + assert!(compose.contains("my.stacker.dns: \"web\"")); + } } diff --git a/src/services/deploy_plan.rs b/src/services/deploy_plan.rs new file mode 100644 index 00000000..cb8db220 --- /dev/null +++ b/src/services/deploy_plan.rs @@ -0,0 +1,600 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; + +use crate::{ + db, + models::Deployment, + services::{ + DeploymentAppState, DeploymentState, TypedErrorCode, TypedErrorEnvelope, + TypedRemediationClass, + }, +}; + +pub const DEPLOY_PLAN_SCHEMA_VERSION: &str = "v1alpha1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DeployPlanOperation { + Deploy, + DeployApp, + RollbackDeploy, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DeployPlanActionKind { + ReconcileRuntimeEnv, + RedeployApp, + RollbackDeploy, + SyncAppConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeployPlanRollback { + pub requested_target: String, + pub current_version: String, + pub resolved_version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RollbackPlanContext { + pub requested_target: String, + pub current_version: String, + pub resolved_version: String, +} + +pub async fn resolve_rollback_plan_context( + pg_pool: &PgPool, + deployment: &Deployment, + requested_target: &str, +) -> Result { + let project = db::project::fetch(pg_pool, deployment.project_id) + .await + .map_err(|_| TypedErrorEnvelope::internal_error("Failed to load rollback project"))? + .ok_or_else(|| { + TypedErrorEnvelope::deployment_not_found("Project not found for deployment") + })?; + + let template_id = project.source_template_id.ok_or_else(|| { + TypedErrorEnvelope::new( + TypedErrorCode::RollbackTargetUnavailable, + "Rollback is only available for marketplace deployments with an older template version", + false, + TypedRemediationClass::State, + ) + .with_context("rollbackTarget", requested_target) + })?; + + let versions = db::marketplace::list_versions_by_template(pg_pool, template_id) + .await + .map_err(|_| TypedErrorEnvelope::internal_error("Failed to load rollback versions"))?; + + let current = if let Some(current_version) = project.template_version.as_deref() { + versions + .iter() + .find(|version| version.version == current_version) + } else { + versions + .iter() + .find(|version| version.is_latest.unwrap_or(false)) + } + .ok_or_else(|| { + TypedErrorEnvelope::new( + TypedErrorCode::RollbackTargetUnavailable, + "Rollback target could not be resolved from the current deployment state", + false, + TypedRemediationClass::State, + ) + .with_context("rollbackTarget", requested_target) + })?; + + let resolved_version = if requested_target == "previous" { + let current_index = versions + .iter() + .position(|version| version.version == current.version) + .ok_or_else(|| { + TypedErrorEnvelope::new( + TypedErrorCode::RollbackTargetUnavailable, + "Current template version is not present in the rollback history", + false, + TypedRemediationClass::State, + ) + .with_context("rollbackTarget", requested_target) + .with_context("currentVersion", current.version.clone()) + })?; + + versions + .get(current_index + 1) + .map(|version| version.version.clone()) + .ok_or_else(|| { + TypedErrorEnvelope::new( + TypedErrorCode::RollbackTargetUnavailable, + "No older marketplace template version is available for rollback", + false, + TypedRemediationClass::State, + ) + .with_context("rollbackTarget", requested_target) + .with_context("currentVersion", current.version.clone()) + })? + } else { + versions + .iter() + .find(|version| version.version == requested_target) + .map(|version| version.version.clone()) + .ok_or_else(|| { + TypedErrorEnvelope::new( + TypedErrorCode::RollbackTargetUnavailable, + format!( + "Marketplace template version '{}' is not available for rollback", + requested_target + ), + false, + TypedRemediationClass::State, + ) + .with_context("rollbackTarget", requested_target) + })? + }; + + Ok(RollbackPlanContext { + requested_target: requested_target.to_string(), + current_version: current.version.clone(), + resolved_version, + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeployPlanScope { + pub mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_code: Option, + pub selected_apps: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeployPlanAction { + pub kind: DeployPlanActionKind, + pub target: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_code: Option, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeployPlan { + pub schema_version: String, + pub deployment_hash: String, + pub operation: DeployPlanOperation, + pub target: String, + pub fingerprint: String, + pub scope: DeployPlanScope, + pub has_changes: bool, + pub actions: Vec, + pub reasoning: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub rollback: Option, +} + +pub fn build_deploy_plan( + state: &DeploymentState, + operation: DeployPlanOperation, + target: &str, + requested_app: Option<&str>, + expected_fingerprint: Option<&str>, +) -> Result { + let selected_apps = select_apps(state, requested_app)?; + let fingerprint = plan_fingerprint(state, target, &operation, &selected_apps); + + if let Some(expected) = expected_fingerprint.filter(|value| !value.is_empty()) { + if expected != fingerprint { + return Err(TypedErrorEnvelope::new( + TypedErrorCode::PlanStale, + "Plan input is stale; regenerate the plan before apply", + false, + TypedRemediationClass::State, + ) + .with_context("expectedFingerprint", expected) + .with_context("actualFingerprint", fingerprint.clone()) + .with_context("deploymentHash", state.deployment.deployment_hash.clone())); + } + } + + let mut actions = Vec::new(); + let mut reasoning = Vec::new(); + + if state.drift.has_drift { + actions.push(DeployPlanAction { + kind: DeployPlanActionKind::ReconcileRuntimeEnv, + target: "deployment".to_string(), + app_code: None, + reason: "runtime env drift detected".to_string(), + }); + reasoning + .push("deployment drift requires runtime env reconciliation before apply".to_string()); + } + + for app in &selected_apps { + if app.config_version > app.vault_sync_version { + actions.push(DeployPlanAction { + kind: DeployPlanActionKind::SyncAppConfig, + target: "app".to_string(), + app_code: Some(app.code.clone()), + reason: "app config version is ahead of the synced Vault/runtime version" + .to_string(), + }); + } + } + + if matches!(operation, DeployPlanOperation::DeployApp) { + let app = selected_apps.first().ok_or_else(|| { + TypedErrorEnvelope::invalid_request("deploy-app plan requires a selected app") + })?; + actions.insert( + 0, + DeployPlanAction { + kind: DeployPlanActionKind::RedeployApp, + target: "app".to_string(), + app_code: Some(app.code.clone()), + reason: "explicit deploy-app plan targets a single app".to_string(), + }, + ); + reasoning.push("deploy-app scope is restricted to the requested app".to_string()); + } else if actions.is_empty() { + reasoning.push("no drift detected for the selected scope".to_string()); + reasoning.push( + "all selected apps are already synced with their current config versions".to_string(), + ); + } else if selected_apps + .iter() + .any(|app| app.config_version > app.vault_sync_version) + { + reasoning.push("at least one selected app has unsynced config changes".to_string()); + } + + Ok(DeployPlan { + schema_version: DEPLOY_PLAN_SCHEMA_VERSION.to_string(), + deployment_hash: state.deployment.deployment_hash.clone(), + operation, + target: target.to_string(), + fingerprint, + scope: DeployPlanScope { + mode: if requested_app.is_some() { + "app".to_string() + } else { + "deployment".to_string() + }, + app_code: requested_app.map(ToOwned::to_owned), + selected_apps: selected_apps.iter().map(|app| app.code.clone()).collect(), + }, + has_changes: !actions.is_empty(), + actions, + reasoning, + rollback: None, + }) +} + +pub fn build_rollback_plan( + state: &DeploymentState, + target: &str, + rollback: RollbackPlanContext, + expected_fingerprint: Option<&str>, +) -> Result { + let selected_apps = select_apps(state, None)?; + let fingerprint = rollback_fingerprint(state, target, &rollback); + + if let Some(expected) = expected_fingerprint.filter(|value| !value.is_empty()) { + if expected != fingerprint { + return Err(TypedErrorEnvelope::new( + TypedErrorCode::PlanStale, + "Plan input is stale; regenerate the plan before apply", + false, + TypedRemediationClass::State, + ) + .with_context("expectedFingerprint", expected) + .with_context("actualFingerprint", fingerprint.clone()) + .with_context("deploymentHash", state.deployment.deployment_hash.clone())); + } + } + + let has_changes = rollback.current_version != rollback.resolved_version; + let mut reasoning = vec![ + format!( + "rollback preview resolved requested target '{}' to template version {}", + rollback.requested_target, rollback.resolved_version + ), + format!( + "current deployment template version is {}", + rollback.current_version + ), + ]; + + let actions = if has_changes { + vec![DeployPlanAction { + kind: DeployPlanActionKind::RollbackDeploy, + target: "deployment".to_string(), + app_code: None, + reason: format!( + "rollback preview targets marketplace template version {}", + rollback.resolved_version + ), + }] + } else { + reasoning.push("deployment is already on the requested rollback target".to_string()); + Vec::new() + }; + + Ok(DeployPlan { + schema_version: DEPLOY_PLAN_SCHEMA_VERSION.to_string(), + deployment_hash: state.deployment.deployment_hash.clone(), + operation: DeployPlanOperation::RollbackDeploy, + target: target.to_string(), + fingerprint, + scope: DeployPlanScope { + mode: "deployment".to_string(), + app_code: None, + selected_apps: selected_apps.iter().map(|app| app.code.clone()).collect(), + }, + has_changes, + actions, + reasoning, + rollback: Some(DeployPlanRollback { + requested_target: rollback.requested_target, + current_version: rollback.current_version, + resolved_version: rollback.resolved_version, + }), + }) +} + +fn select_apps<'a>( + state: &'a DeploymentState, + requested_app: Option<&str>, +) -> Result, TypedErrorEnvelope> { + match requested_app { + Some(app_code) => state + .apps + .iter() + .find(|app| app.code == app_code) + .map(|app| vec![app]) + .ok_or_else(|| { + TypedErrorEnvelope::invalid_request(format!( + "Requested app '{app_code}' was not found in deployment state" + )) + .with_context("appCode", app_code) + }), + None => Ok(state.apps.iter().collect()), + } +} + +fn plan_fingerprint( + state: &DeploymentState, + target: &str, + operation: &DeployPlanOperation, + selected_apps: &[&DeploymentAppState], +) -> String { + let payload = serde_json::json!({ + "deploymentHash": state.deployment.deployment_hash, + "status": state.deployment.status, + "runtime": state.deployment.runtime, + "target": target, + "operation": operation, + "drift": { + "hasDrift": state.drift.has_drift, + "summary": state.drift.summary, + }, + "apps": selected_apps.iter().map(|app| serde_json::json!({ + "code": app.code, + "configVersion": app.config_version, + "vaultSyncVersion": app.vault_sync_version, + "configHash": app.config_hash, + "enabled": app.enabled, + })).collect::>(), + }); + + format!("{:x}", Sha256::digest(payload.to_string().as_bytes())) +} + +fn rollback_fingerprint( + state: &DeploymentState, + target: &str, + rollback: &RollbackPlanContext, +) -> String { + let payload = serde_json::json!({ + "deploymentHash": state.deployment.deployment_hash, + "status": state.deployment.status, + "runtime": state.deployment.runtime, + "target": target, + "operation": DeployPlanOperation::RollbackDeploy, + "rollback": { + "requestedTarget": rollback.requested_target, + "currentVersion": rollback.current_version, + "resolvedVersion": rollback.resolved_version, + }, + "apps": state.apps.iter().map(|app| serde_json::json!({ + "code": app.code, + "configVersion": app.config_version, + "vaultSyncVersion": app.vault_sync_version, + "configHash": app.config_hash, + "enabled": app.enabled, + })).collect::>(), + }); + + format!("{:x}", Sha256::digest(payload.to_string().as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::{ + DeploymentAgentFeatures, DeploymentAgentState, DeploymentDriftState, + DeploymentProjectState, DeploymentRuntimeState, DeploymentState, DeploymentStateDeployment, + }; + + fn sample_state() -> DeploymentState { + DeploymentState { + schema_version: "v1alpha1".to_string(), + project: DeploymentProjectState { + id: 17, + identity: "syncopia".to_string(), + name: "syncopia".to_string(), + }, + deployment: DeploymentStateDeployment { + id: 31, + deployment_hash: "deployment_state_online".to_string(), + status: "healthy".to_string(), + runtime: "runc".to_string(), + }, + agent: DeploymentAgentState { + id: Some("agent-1".to_string()), + status: "online".to_string(), + version: Some("0.2.8".to_string()), + last_heartbeat: None, + capabilities: vec!["compose".to_string()], + features: DeploymentAgentFeatures { + compose: true, + kata_runtime: false, + backup: false, + pipes: false, + proxy_credentials_vault: false, + }, + }, + runtime: DeploymentRuntimeState { + compose_path: "/home/trydirect/project/docker-compose.yml".to_string(), + env_path: "/home/trydirect/project/.env".to_string(), + }, + apps: vec![ + DeploymentAppState { + code: "device-api".to_string(), + name: "Device API".to_string(), + enabled: true, + config_version: 2, + vault_sync_version: 2, + config_hash: Some("cfg-device-api".to_string()), + }, + DeploymentAppState { + code: "upload".to_string(), + name: "Upload".to_string(), + enabled: true, + config_version: 3, + vault_sync_version: 3, + config_hash: Some("cfg-upload".to_string()), + }, + ], + drift: DeploymentDriftState { + has_drift: false, + summary: "no drift detected".to_string(), + }, + last_command: None, + } + } + + #[test] + fn deploy_plan_snapshot_with_no_changes() { + let plan = build_deploy_plan( + &sample_state(), + DeployPlanOperation::Deploy, + "cloud", + None, + None, + ) + .expect("plan should build"); + + assert_eq!(plan.schema_version, DEPLOY_PLAN_SCHEMA_VERSION); + assert!(!plan.has_changes); + assert!(plan.actions.is_empty()); + assert_eq!(plan.scope.mode, "deployment"); + } + + #[test] + fn deploy_plan_snapshot_with_env_and_config_drift() { + let mut state = sample_state(); + state.drift.has_drift = true; + state.drift.summary = "runtime env drift detected".to_string(); + state.apps[1].config_version = 4; + state.apps[1].vault_sync_version = 3; + + let plan = build_deploy_plan(&state, DeployPlanOperation::Deploy, "cloud", None, None) + .expect("plan should build"); + + assert!(plan.has_changes); + assert!(plan + .actions + .iter() + .any(|action| { matches!(action.kind, DeployPlanActionKind::ReconcileRuntimeEnv) })); + assert!(plan.actions.iter().any(|action| { + matches!(action.kind, DeployPlanActionKind::SyncAppConfig) + && action.app_code.as_deref() == Some("upload") + })); + } + + #[test] + fn deploy_app_plan_targets_single_service() { + let plan = build_deploy_plan( + &sample_state(), + DeployPlanOperation::DeployApp, + "cloud", + Some("upload"), + None, + ) + .expect("plan should build"); + + assert!(plan.has_changes); + assert_eq!(plan.scope.mode, "app"); + assert_eq!(plan.scope.app_code.as_deref(), Some("upload")); + assert_eq!(plan.scope.selected_apps, vec!["upload".to_string()]); + assert!(plan.actions.iter().any(|action| { + matches!(action.kind, DeployPlanActionKind::RedeployApp) + && action.app_code.as_deref() == Some("upload") + })); + } + + #[test] + fn stale_input_detection_returns_plan_stale_error() { + let state = sample_state(); + let error = build_deploy_plan( + &state, + DeployPlanOperation::Deploy, + "cloud", + None, + Some("stale-fingerprint"), + ) + .expect_err("stale plan should be rejected"); + + assert_eq!(error.code, TypedErrorCode::PlanStale); + assert_eq!( + error.context.get("expectedFingerprint").map(String::as_str), + Some("stale-fingerprint") + ); + } + + #[test] + fn rollback_plan_snapshot_targets_resolved_version() { + let plan = build_rollback_plan( + &sample_state(), + "cloud", + RollbackPlanContext { + requested_target: "previous".to_string(), + current_version: "1.2.0".to_string(), + resolved_version: "1.1.0".to_string(), + }, + None, + ) + .expect("rollback plan should build"); + + assert_eq!(plan.operation, DeployPlanOperation::RollbackDeploy); + assert!(plan.has_changes); + assert!(plan + .actions + .iter() + .any(|action| matches!(action.kind, DeployPlanActionKind::RollbackDeploy))); + assert_eq!( + plan.rollback + .as_ref() + .map(|item| item.resolved_version.as_str()), + Some("1.1.0") + ); + } +} diff --git a/src/services/deployment_events.rs b/src/services/deployment_events.rs new file mode 100644 index 00000000..29bef1be --- /dev/null +++ b/src/services/deployment_events.rs @@ -0,0 +1,409 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::types::JsonValue; + +use crate::{ + db, + models::{Command, Deployment}, + services::{TypedErrorEnvelope, TypedRemediationClass}, +}; + +pub const DEPLOYMENT_EVENTS_SCHEMA_VERSION: &str = "v1alpha1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DeploymentEventKind { + DeploymentStatus, + CommandQueued, + CommandSent, + CommandExecuting, + CommandCompleted, + CommandFailed, + CommandCancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DeploymentEventClassification { + Info, + Progress, + Success, + Failure, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentEvent { + pub sequence: usize, + pub kind: DeploymentEventKind, + pub classification: DeploymentEventClassification, + pub occurred_at: DateTime, + pub summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub command_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub command_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retryable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remediation_class: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentEventFeed { + pub schema_version: String, + pub deployment_hash: String, + pub events: Vec, +} + +#[derive(Debug, Clone)] +struct DeploymentEventDraft { + kind: DeploymentEventKind, + classification: DeploymentEventClassification, + occurred_at: DateTime, + summary: String, + command_id: Option, + command_type: Option, + status: Option, + retryable: Option, + remediation_class: Option, + order_key: u8, +} + +impl DeploymentEventFeed { + pub fn from_parts(deployment: &Deployment, commands: &[Command]) -> Self { + let mut drafts = Vec::new(); + + if let Some(status_message) = deployment + .metadata + .get("status_message") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + drafts.push(DeploymentEventDraft { + kind: DeploymentEventKind::DeploymentStatus, + classification: classify_deployment_status(&deployment.status), + occurred_at: deployment.updated_at, + summary: status_message.to_string(), + command_id: None, + command_type: None, + status: Some(deployment.status.clone()), + retryable: None, + remediation_class: None, + order_key: 2, + }); + } + + for command in commands { + drafts.push(DeploymentEventDraft { + kind: DeploymentEventKind::CommandQueued, + classification: DeploymentEventClassification::Info, + occurred_at: command.created_at, + summary: format!("{} queued", command.r#type), + command_id: Some(command.command_id.clone()), + command_type: Some(command.r#type.clone()), + status: Some("queued".to_string()), + retryable: None, + remediation_class: None, + order_key: 0, + }); + + if command.status != "queued" { + let (kind, classification, order_key) = classify_command_status(&command.status); + let (summary, retryable, remediation_class) = + summarize_command_outcome(command, &kind, &classification); + + drafts.push(DeploymentEventDraft { + kind, + classification, + occurred_at: command.updated_at, + summary, + command_id: Some(command.command_id.clone()), + command_type: Some(command.r#type.clone()), + status: Some(command.status.clone()), + retryable, + remediation_class, + order_key, + }); + } + } + + drafts.sort_by(|left, right| { + left.occurred_at + .cmp(&right.occurred_at) + .then_with(|| left.order_key.cmp(&right.order_key)) + .then_with(|| left.command_id.cmp(&right.command_id)) + .then_with(|| left.summary.cmp(&right.summary)) + }); + + let events = drafts + .into_iter() + .enumerate() + .map(|(index, draft)| DeploymentEvent { + sequence: index + 1, + kind: draft.kind, + classification: draft.classification, + occurred_at: draft.occurred_at, + summary: draft.summary, + command_id: draft.command_id, + command_type: draft.command_type, + status: draft.status, + retryable: draft.retryable, + remediation_class: draft.remediation_class, + }) + .collect(); + + Self { + schema_version: DEPLOYMENT_EVENTS_SCHEMA_VERSION.to_string(), + deployment_hash: deployment.deployment_hash.clone(), + events, + } + } + + pub async fn for_deployment_hash( + pool: &sqlx::PgPool, + deployment_hash: &str, + ) -> Result, String> { + let deployment = + match db::deployment::fetch_by_deployment_hash(pool, deployment_hash).await? { + Some(item) => item, + None => return Ok(None), + }; + let commands = db::command::fetch_by_deployment(pool, deployment_hash).await?; + Ok(Some(Self::from_parts(&deployment, &commands))) + } +} + +fn classify_deployment_status(status: &str) -> DeploymentEventClassification { + match status { + "healthy" | "completed" | "active" => DeploymentEventClassification::Success, + "failed" | "error" | "deploy_failed" => DeploymentEventClassification::Failure, + _ => DeploymentEventClassification::Progress, + } +} + +fn classify_command_status( + status: &str, +) -> (DeploymentEventKind, DeploymentEventClassification, u8) { + match status { + "sent" => ( + DeploymentEventKind::CommandSent, + DeploymentEventClassification::Progress, + 1, + ), + "executing" => ( + DeploymentEventKind::CommandExecuting, + DeploymentEventClassification::Progress, + 2, + ), + "completed" => ( + DeploymentEventKind::CommandCompleted, + DeploymentEventClassification::Success, + 3, + ), + "failed" => ( + DeploymentEventKind::CommandFailed, + DeploymentEventClassification::Failure, + 3, + ), + "cancelled" => ( + DeploymentEventKind::CommandCancelled, + DeploymentEventClassification::Failure, + 3, + ), + _ => ( + DeploymentEventKind::CommandQueued, + DeploymentEventClassification::Info, + 0, + ), + } +} + +fn summarize_command_outcome( + command: &Command, + kind: &DeploymentEventKind, + classification: &DeploymentEventClassification, +) -> (String, Option, Option) { + match kind { + DeploymentEventKind::CommandSent => { + (format!("{} sent to agent", command.r#type), None, None) + } + DeploymentEventKind::CommandExecuting => { + (format!("{} executing", command.r#type), None, None) + } + DeploymentEventKind::CommandCompleted => ( + extract_message(command.result.as_ref()) + .unwrap_or_else(|| format!("{} completed", command.r#type)), + None, + None, + ), + DeploymentEventKind::CommandFailed | DeploymentEventKind::CommandCancelled => { + if let Some(error) = parse_typed_error(command.error.as_ref()) { + return ( + error.message, + Some(error.retryable), + Some(error.remediation_class), + ); + } + + ( + extract_message(command.error.as_ref()).unwrap_or_else(|| { + format!( + "{} {}", + command.r#type, + match classification { + DeploymentEventClassification::Failure => "failed", + _ => "ended", + } + ) + }), + Some(false), + Some(TypedRemediationClass::State), + ) + } + _ => (format!("{} queued", command.r#type), None, None), + } +} + +fn extract_message(value: Option<&JsonValue>) -> Option { + let value = value?; + if let Some(message) = value.get("message").and_then(|item| item.as_str()) { + return Some(message.to_string()); + } + if let Some(status) = value.get("status").and_then(|item| item.as_str()) { + return Some(status.to_string()); + } + if let Some(errors) = value.get("errors").and_then(|item| item.as_array()) { + if let Some(message) = errors + .iter() + .find_map(|entry| entry.get("message").and_then(|item| item.as_str())) + { + return Some(message.to_string()); + } + } + value.as_str().map(ToOwned::to_owned) +} + +fn parse_typed_error(value: Option<&JsonValue>) -> Option { + serde_json::from_value(value?.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Command, Deployment}; + use serde_json::json; + + fn sample_deployment() -> Deployment { + let mut deployment = Deployment::new( + 17, + Some("user-a".to_string()), + "deployment_events_online".to_string(), + "in_progress".to_string(), + "runc".to_string(), + json!({ + "status_message": "Provisioning server" + }), + ); + deployment.updated_at = DateTime::parse_from_rfc3339("2026-05-17T08:02:00Z") + .unwrap() + .with_timezone(&Utc); + deployment + } + + fn sample_command( + command_id: &str, + status: &str, + created_at: &str, + updated_at: &str, + ) -> Command { + let mut command = Command::new( + command_id.to_string(), + "deployment_events_online".to_string(), + "deploy_app".to_string(), + "user-a".to_string(), + ); + command.status = status.to_string(); + command.created_at = DateTime::parse_from_rfc3339(created_at) + .unwrap() + .with_timezone(&Utc); + command.updated_at = DateTime::parse_from_rfc3339(updated_at) + .unwrap() + .with_timezone(&Utc); + command + } + + #[test] + fn serializes_event_feed() { + let feed = DeploymentEventFeed::from_parts( + &sample_deployment(), + &[sample_command( + "cmd-1", + "completed", + "2026-05-17T08:00:00Z", + "2026-05-17T08:05:00Z", + )], + ); + + let json = serde_json::to_value(&feed).expect("event feed should serialize"); + assert_eq!( + json["schemaVersion"].as_str().unwrap(), + DEPLOYMENT_EVENTS_SCHEMA_VERSION + ); + assert!(json["events"].as_array().unwrap().len() >= 2); + } + + #[test] + fn orders_events_by_time_then_phase() { + let feed = DeploymentEventFeed::from_parts( + &sample_deployment(), + &[sample_command( + "cmd-1", + "executing", + "2026-05-17T08:00:00Z", + "2026-05-17T08:01:00Z", + )], + ); + + assert_eq!(feed.events[0].kind, DeploymentEventKind::CommandQueued); + assert_eq!(feed.events[1].kind, DeploymentEventKind::CommandExecuting); + assert_eq!(feed.events[2].kind, DeploymentEventKind::DeploymentStatus); + } + + #[test] + fn classifies_failure_events_from_typed_errors() { + let mut command = sample_command( + "cmd-1", + "failed", + "2026-05-17T08:00:00Z", + "2026-05-17T08:03:00Z", + ); + command.error = Some( + serde_json::to_value(TypedErrorEnvelope::deployment_capability_missing( + "Agent cannot run rollback", + )) + .unwrap(), + ); + + let feed = DeploymentEventFeed::from_parts(&sample_deployment(), &[command]); + let failure = feed + .events + .iter() + .find(|event| event.kind == DeploymentEventKind::CommandFailed) + .expect("failed event should exist"); + + assert_eq!( + failure.classification, + DeploymentEventClassification::Failure + ); + assert_eq!(failure.retryable, Some(false)); + assert_eq!( + failure.remediation_class, + Some(TypedRemediationClass::Capability) + ); + } +} diff --git a/src/services/deployment_state.rs b/src/services/deployment_state.rs new file mode 100644 index 00000000..79acb663 --- /dev/null +++ b/src/services/deployment_state.rs @@ -0,0 +1,315 @@ +use chrono::{DateTime, Utc}; +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, + }, + models::{Agent, Command, Deployment, Project, ProjectApp}, +}; + +pub const DEPLOYMENT_STATE_SCHEMA_VERSION: &str = "v1alpha1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentState { + pub schema_version: String, + pub project: DeploymentProjectState, + pub deployment: DeploymentStateDeployment, + pub agent: DeploymentAgentState, + pub runtime: DeploymentRuntimeState, + pub apps: Vec, + pub drift: DeploymentDriftState, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_command: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentProjectState { + pub id: i32, + pub identity: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentStateDeployment { + pub id: i32, + pub deployment_hash: String, + pub status: String, + pub runtime: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentAgentState { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_heartbeat: Option>, + pub capabilities: Vec, + pub features: DeploymentAgentFeatures, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentAgentFeatures { + pub compose: bool, + pub kata_runtime: bool, + pub backup: bool, + pub pipes: bool, + pub proxy_credentials_vault: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentRuntimeState { + pub compose_path: String, + pub env_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentAppState { + pub code: String, + pub name: String, + pub enabled: bool, + pub config_version: i32, + pub vault_sync_version: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub config_hash: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentDriftState { + pub has_drift: bool, + pub summary: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentLastCommandState { + pub r#type: String, + pub status: String, + pub finished_at: DateTime, +} + +impl DeploymentState { + pub fn from_parts( + project: &Project, + deployment: &Deployment, + agent: Option<&Agent>, + apps: &[ProjectApp], + last_command: Option<&Command>, + ) -> Self { + let capabilities = agent + .map(|item| extract_capabilities(item.capabilities.clone())) + .unwrap_or_default(); + + let features = DeploymentAgentFeatures { + compose: has_capability(&capabilities, "compose"), + kata_runtime: has_capability(&capabilities, "kata"), + backup: has_capability(&capabilities, "backup"), + pipes: has_capability(&capabilities, "pipes"), + proxy_credentials_vault: has_capability_value( + &capabilities, + NPM_CREDENTIAL_SOURCE_KEY, + "vault", + ), + }; + + let apps = apps + .iter() + .map(|app| DeploymentAppState { + code: app.code.clone(), + name: app.name.clone(), + enabled: app.enabled.unwrap_or(true), + config_version: app.config_version.unwrap_or(0), + vault_sync_version: app.vault_sync_version.unwrap_or(0), + config_hash: app.config_hash.clone(), + }) + .collect(); + + Self { + schema_version: DEPLOYMENT_STATE_SCHEMA_VERSION.to_string(), + project: DeploymentProjectState { + id: project.id, + identity: project + .metadata + .get("identity") + .and_then(|value| value.as_str()) + .unwrap_or(&project.name) + .to_string(), + name: project.name.clone(), + }, + deployment: DeploymentStateDeployment { + id: deployment.id, + deployment_hash: deployment.deployment_hash.clone(), + status: deployment.status.clone(), + runtime: deployment.runtime.clone(), + }, + agent: DeploymentAgentState { + id: agent.map(|item| item.id.to_string()), + status: agent + .map(|item| item.status.clone()) + .unwrap_or_else(|| "offline".to_string()), + version: agent.and_then(|item| item.version.clone()), + last_heartbeat: agent.and_then(|item| item.last_heartbeat), + capabilities, + features, + }, + runtime: DeploymentRuntimeState { + compose_path: remote_runtime_compose_path().to_string(), + env_path: remote_runtime_env_path().to_string(), + }, + apps, + drift: DeploymentDriftState { + has_drift: false, + summary: "no drift detected".to_string(), + }, + last_command: last_command.map(|command| DeploymentLastCommandState { + r#type: command.r#type.clone(), + status: command.status.clone(), + finished_at: command.updated_at, + }), + } + } + + pub async fn for_deployment_hash( + pool: &sqlx::PgPool, + deployment_hash: &str, + ) -> Result, String> { + let deployment = + match db::deployment::fetch_by_deployment_hash(pool, deployment_hash).await? { + Some(item) => item, + None => return Ok(None), + }; + + let project = db::project::fetch(pool, deployment.project_id) + .await? + .ok_or_else(|| "Project not found for deployment".to_string())?; + let agent = db::agent::fetch_by_deployment_hash(pool, deployment_hash).await?; + let apps = db::project_app::fetch_by_deployment(pool, project.id, deployment.id).await?; + let last_command = db::command::fetch_recent_by_deployment(pool, deployment_hash, 1, true) + .await? + .into_iter() + .next(); + + Ok(Some(Self::from_parts( + &project, + &deployment, + agent.as_ref(), + &apps, + last_command.as_ref(), + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Agent, Command, Deployment, Project, ProjectApp}; + use serde_json::json; + + fn sample_project() -> Project { + let mut project = Project::new( + "user-a".to_string(), + "syncopia".to_string(), + json!({ "identity": "syncopia" }), + json!({}), + ); + project.id = 17; + project.metadata = json!({ "identity": "syncopia" }); + project + } + + fn sample_deployment(hash: &str, status: &str) -> Deployment { + let mut deployment = Deployment::new( + 17, + Some("user-a".to_string()), + hash.to_string(), + status.to_string(), + "runc".to_string(), + json!({}), + ); + deployment.id = 31; + deployment + } + + fn sample_app(code: &str, name: &str, config_version: i32, sync_version: i32) -> ProjectApp { + let mut app = ProjectApp::new( + 17, + code.to_string(), + name.to_string(), + format!("{code}:latest"), + ); + app.config_version = Some(config_version); + app.vault_sync_version = Some(sync_version); + app.config_hash = Some(format!("cfg-{code}")); + app + } + + #[test] + fn serializes_online_state() { + let mut agent = Agent::new("deployment_state_online".to_string()); + agent.mark_online(); + agent.version = Some("0.1.9".to_string()); + agent.capabilities = Some(json!([ + "docker", + "compose", + "logs", + "npm_credential_source=vault" + ])); + + let state = DeploymentState::from_parts( + &sample_project(), + &sample_deployment("deployment_state_online", "healthy"), + Some(&agent), + &[ + sample_app("device-api", "Device API", 3, 3), + sample_app("upload", "Upload", 2, 2), + ], + Some( + &Command::new( + "cmd-1".to_string(), + "deployment_state_online".to_string(), + "deploy_app".to_string(), + "user-a".to_string(), + ) + .mark_completed(), + ), + ); + + let json = serde_json::to_value(&state).expect("state should serialize"); + assert_eq!(json["schemaVersion"], DEPLOYMENT_STATE_SCHEMA_VERSION); + assert_eq!( + json["deployment"]["deploymentHash"], + "deployment_state_online" + ); + assert_eq!(json["agent"]["status"], "online"); + assert_eq!(json["apps"].as_array().unwrap().len(), 2); + } + + #[test] + fn offline_state_omits_optional_agent_fields() { + let state = DeploymentState::from_parts( + &sample_project(), + &sample_deployment("deployment_state_offline", "pending"), + None, + &[], + None, + ); + + let json = serde_json::to_value(&state).expect("state should serialize"); + assert_eq!(json["agent"]["status"], "offline"); + assert!(json["agent"].get("id").is_none()); + assert!(json.get("lastCommand").is_none()); + } +} diff --git a/src/services/env_contract.rs b/src/services/env_contract.rs new file mode 100644 index 00000000..c6abed3b --- /dev/null +++ b/src/services/env_contract.rs @@ -0,0 +1,96 @@ +use serde::Serialize; + +pub const RUNTIME_ENV_CONTRACT_VERSION: &str = "v1"; +pub const RUNTIME_ENV_PRECEDENCE_ORDER: &str = "lowest_to_highest"; + +pub const RUNTIME_ENV_LAYER_BASE: &str = "base"; +pub const RUNTIME_ENV_LAYER_SERVER: &str = "server"; +pub const RUNTIME_ENV_LAYER_SERVICE: &str = "service"; +pub const RUNTIME_ENV_LAYER_COMPOSE: &str = "compose"; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub struct RuntimeEnvLayerContract { + pub name: &'static str, + pub precedence: u8, + #[serde(rename = "appliesWhen")] + pub applies_when: &'static str, + pub description: &'static str, +} + +pub const RUNTIME_ENV_LAYER_CONTRACTS: [RuntimeEnvLayerContract; 4] = [ + RuntimeEnvLayerContract { + name: RUNTIME_ENV_LAYER_BASE, + precedence: 1, + applies_when: "Always", + description: "App env and local authoring inputs provide the base runtime layer.", + }, + RuntimeEnvLayerContract { + name: RUNTIME_ENV_LAYER_SERVER, + precedence: 2, + applies_when: "Only when inherit_server_secrets=true", + description: "Server-scope secrets overlay the base layer when the target opts in.", + }, + RuntimeEnvLayerContract { + name: RUNTIME_ENV_LAYER_SERVICE, + precedence: 3, + applies_when: "When remote service secrets exist for the selected service/app target", + description: "Service-scope secrets override lower layers for the selected target.", + }, + RuntimeEnvLayerContract { + name: RUNTIME_ENV_LAYER_COMPOSE, + precedence: 4, + applies_when: "When the compose service defines environment: keys", + description: "Compose environment keys win over env_file-derived layers at runtime.", + }, +]; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct RuntimeEnvContractResponse { + pub version: &'static str, + pub order: &'static str, + pub layers: Vec, +} + +pub fn runtime_env_contract_response() -> RuntimeEnvContractResponse { + RuntimeEnvContractResponse { + version: RUNTIME_ENV_CONTRACT_VERSION, + order: RUNTIME_ENV_PRECEDENCE_ORDER, + layers: RUNTIME_ENV_LAYER_CONTRACTS.to_vec(), + } +} + +pub fn runtime_env_layer_names() -> Vec<&'static str> { + RUNTIME_ENV_LAYER_CONTRACTS + .iter() + .map(|layer| layer.name) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{ + runtime_env_contract_response, runtime_env_layer_names, RUNTIME_ENV_CONTRACT_VERSION, + RUNTIME_ENV_LAYER_BASE, RUNTIME_ENV_LAYER_COMPOSE, RUNTIME_ENV_LAYER_SERVER, + RUNTIME_ENV_LAYER_SERVICE, RUNTIME_ENV_PRECEDENCE_ORDER, + }; + + #[test] + fn runtime_env_contract_is_stable() { + let contract = runtime_env_contract_response(); + + assert_eq!(contract.version, RUNTIME_ENV_CONTRACT_VERSION); + assert_eq!(contract.order, RUNTIME_ENV_PRECEDENCE_ORDER); + assert_eq!( + runtime_env_layer_names(), + vec![ + RUNTIME_ENV_LAYER_BASE, + RUNTIME_ENV_LAYER_SERVER, + RUNTIME_ENV_LAYER_SERVICE, + RUNTIME_ENV_LAYER_COMPOSE, + ] + ); + assert_eq!(contract.layers.len(), 4); + assert_eq!(contract.layers[0].precedence, 1); + assert_eq!(contract.layers[3].precedence, 4); + } +} diff --git a/src/services/env_model.rs b/src/services/env_model.rs new file mode 100644 index 00000000..2983d337 --- /dev/null +++ b/src/services/env_model.rs @@ -0,0 +1,228 @@ +use serde_json::Value; +use std::collections::{BTreeMap, HashMap, HashSet}; + +pub const RENDER_HEADER: &str = "# stacker-render "; + +#[derive(Debug, Clone, Copy)] +pub struct EnvLayer<'a> { + pub name: &'static str, + pub entries: &'a HashMap, + pub include_in_inputs: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReconciledEnv { + pub entries: BTreeMap, + pub inputs: Vec<&'static str>, +} + +pub fn reconcile_env_layers(layers: &[EnvLayer<'_>]) -> ReconciledEnv { + let mut entries = BTreeMap::new(); + let mut inputs = Vec::new(); + + for layer in layers { + if layer.entries.is_empty() { + continue; + } + + if layer.include_in_inputs { + inputs.push(layer.name); + } + + for (key, value) in layer.entries { + entries.insert(key.clone(), value.clone()); + } + } + + ReconciledEnv { entries, inputs } +} + +pub fn reconcile_env_file_content(existing_content: &str, rendered_env_content: &str) -> String { + let authored_content = strip_rendered_env_block(existing_content); + let rendered_keys: HashSet = parse_env_assignments(rendered_env_content) + .into_keys() + .collect(); + + let authored_lines: Vec<&str> = authored_content + .lines() + .filter(|line| !should_remove_authored_line(line, &rendered_keys)) + .collect(); + + let authored_content = authored_lines.join("\n"); + let authored_content = authored_content.trim_end(); + if authored_content.is_empty() { + return rendered_env_content.to_string(); + } + + format!("{authored_content}\n\n{rendered_env_content}") +} + +pub fn strip_rendered_env_block(existing_content: &str) -> &str { + match existing_content.find(RENDER_HEADER) { + Some(0) => "", + Some(index) => &existing_content[..index], + None => existing_content, + } +} + +pub fn parse_env_assignments(content: &str) -> BTreeMap { + content.lines().filter_map(parse_env_assignment).collect() +} + +pub fn normalize_json_env(env: &Value) -> BTreeMap { + match env { + Value::Object(map) => map + .iter() + .map(|(key, value)| (key.clone(), stringify_json_env_value(value))) + .collect(), + Value::Array(items) => items + .iter() + .filter_map(|item| item.as_str().and_then(parse_env_assignment)) + .collect(), + _ => BTreeMap::new(), + } +} + +pub fn normalize_optional_json_env(env: Option<&Value>) -> BTreeMap { + env.map(normalize_json_env).unwrap_or_default() +} + +fn should_remove_authored_line(line: &str, rendered_keys: &HashSet) -> bool { + parse_env_assignment(line) + .map(|(key, _)| rendered_keys.contains(&key)) + .unwrap_or(false) +} + +fn parse_env_assignment(line: &str) -> Option<(String, String)> { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + + let line = line + .strip_prefix("export ") + .map(str::trim_start) + .unwrap_or(line); + + if let Some((key, value)) = line.split_once('=') { + return Some((key.trim().to_string(), value.trim().to_string())); + } + + line.split_once(':') + .map(|(key, value)| (key.trim().to_string(), value.trim().to_string())) +} + +fn stringify_json_env_value(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Number(number) => number.to_string(), + Value::Bool(flag) => flag.to_string(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::{ + normalize_json_env, normalize_optional_json_env, parse_env_assignments, + reconcile_env_file_content, reconcile_env_layers, EnvLayer, + }; + use serde_json::json; + use std::collections::HashMap; + + #[test] + fn reconcile_env_layers_applies_precedence_by_order() { + let base = HashMap::from([("SHARED".to_string(), "base".to_string())]); + let service = HashMap::from([("SHARED".to_string(), "service".to_string())]); + + let reconciled = reconcile_env_layers(&[ + EnvLayer { + name: "base", + entries: &base, + include_in_inputs: true, + }, + EnvLayer { + name: "service", + entries: &service, + include_in_inputs: true, + }, + ]); + + assert_eq!( + reconciled.entries.get("SHARED").map(String::as_str), + Some("service") + ); + assert_eq!(reconciled.inputs, vec!["base", "service"]); + } + + #[test] + fn reconcile_env_file_content_replaces_overridden_authored_keys() { + let existing = "RUST_LOG=debug\nS3_BUCKET=local\n# comment\n"; + let rendered = + "# stacker-render version=2 hash=new generated_at=now inputs=service\nS3_BUCKET=remote\n"; + + let merged = reconcile_env_file_content(existing, rendered); + + assert_eq!( + merged, + "RUST_LOG=debug\n# comment\n\n# stacker-render version=2 hash=new generated_at=now inputs=service\nS3_BUCKET=remote\n" + ); + } + + #[test] + fn reconcile_env_file_content_removes_previous_rendered_block() { + let existing = "RUST_LOG=debug\n\n# stacker-render version=1 hash=old generated_at=now inputs=service\nOLD_SECRET=outdated\n"; + let rendered = + "# stacker-render version=2 hash=new generated_at=now inputs=service\nNEW_SECRET=fresh\n"; + + let merged = reconcile_env_file_content(existing, rendered); + + assert_eq!( + merged, + "RUST_LOG=debug\n\n# stacker-render version=2 hash=new generated_at=now inputs=service\nNEW_SECRET=fresh\n" + ); + assert!(!merged.contains("OLD_SECRET=outdated")); + } + + #[test] + fn parse_env_assignments_skips_comments_and_headers() { + let parsed = parse_env_assignments( + "# comment\n# stacker-render version=1 hash=abc generated_at=now inputs=base\nFOO=bar\nexport BAR=baz\n", + ); + + assert_eq!(parsed.get("FOO").map(String::as_str), Some("bar")); + assert_eq!(parsed.get("BAR").map(String::as_str), Some("baz")); + assert_eq!(parsed.len(), 2); + } + + #[test] + fn normalize_json_env_handles_object_and_array_inputs() { + let object = normalize_json_env(&json!({ + "DATABASE_URL": "postgres://localhost/db", + "PORT": 8080, + "DEBUG": true + })); + let array = normalize_json_env(&json!([ + "DATABASE_URL=postgres://localhost/db", + "PORT=8080" + ])); + + assert_eq!( + object.get("DATABASE_URL").map(String::as_str), + Some("postgres://localhost/db") + ); + assert_eq!(object.get("PORT").map(String::as_str), Some("8080")); + assert_eq!(object.get("DEBUG").map(String::as_str), Some("true")); + assert_eq!( + array.get("DATABASE_URL").map(String::as_str), + Some("postgres://localhost/db") + ); + assert_eq!(array.get("PORT").map(String::as_str), Some("8080")); + } + + #[test] + fn normalize_optional_json_env_defaults_to_empty_map() { + let normalized = normalize_optional_json_env(None); + assert!(normalized.is_empty()); + } +} diff --git a/src/services/explain.rs b/src/services/explain.rs new file mode 100644 index 00000000..2117ce9e --- /dev/null +++ b/src/services/explain.rs @@ -0,0 +1,267 @@ +use std::collections::{BTreeMap, HashMap}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::services::config_renderer::{render_env, EnvRenderError, EnvRenderInput}; + +pub const EXPLAIN_SCHEMA_VERSION: &str = "v1alpha1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExplainEnv { + pub schema_version: String, + pub deployment_hash: String, + pub app_code: String, + pub local_authoring_env_path: String, + pub runtime_env_path: String, + pub runtime_compose_path: String, + pub layers: Vec, + pub destination: ExplainDestination, + pub rendered_env: ExplainRenderedEnv, + pub reasoning: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExplainEnvLayer { + pub name: String, + pub key_names: Vec, + pub key_count: usize, + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExplainDestination { + pub path: String, + pub write_policy: String, + pub drift_protection: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExplainRenderedEnv { + pub hash: String, + pub inputs: Vec, + pub server_secrets_inherited: bool, + pub service_secrets_override_server_secrets: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExplainTopology { + pub schema_version: String, + pub deployment_hash: String, + pub target: String, + pub local_compose_path: String, + pub runtime_compose_path: String, + pub local_authoring_env_path: String, + pub runtime_env_path: String, + pub services: Vec, + pub reasoning: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExplainTopologyService { + pub code: String, + pub name: String, + pub enabled: bool, +} + +pub fn build_explain_env( + deployment_hash: &str, + app_code: &str, + local_authoring_env_path: &str, + runtime_env_path: &str, + runtime_compose_path: &str, + input: EnvRenderInput, +) -> Result { + let rendered = render_env(input.clone())?; + let overlapping_keys = input + .service + .keys() + .any(|key| input.server.contains_key(key) && input.inherit_server_secrets); + + Ok(ExplainEnv { + schema_version: EXPLAIN_SCHEMA_VERSION.to_string(), + deployment_hash: deployment_hash.to_string(), + app_code: app_code.to_string(), + local_authoring_env_path: local_authoring_env_path.to_string(), + runtime_env_path: runtime_env_path.to_string(), + runtime_compose_path: runtime_compose_path.to_string(), + layers: env_layers(&input), + destination: ExplainDestination { + path: runtime_env_path.to_string(), + write_policy: "drift-protected".to_string(), + drift_protection: true, + }, + rendered_env: ExplainRenderedEnv { + hash: rendered.hash, + inputs: rendered + .inputs + .iter() + .map(|item| item.to_string()) + .collect(), + server_secrets_inherited: input.inherit_server_secrets, + service_secrets_override_server_secrets: overlapping_keys, + }, + reasoning: vec![ + "runtime env path is resolved from the canonical remote env path helper".to_string(), + "env layers are merged in precedence order: base -> generated -> server -> service -> compose" + .to_string(), + ], + }) +} + +pub fn build_explain_topology( + deployment_hash: &str, + target: &str, + local_compose_path: &str, + runtime_compose_path: &str, + local_authoring_env_path: &str, + runtime_env_path: &str, + services: Vec, +) -> ExplainTopology { + ExplainTopology { + schema_version: EXPLAIN_SCHEMA_VERSION.to_string(), + deployment_hash: deployment_hash.to_string(), + target: target.to_string(), + local_compose_path: local_compose_path.to_string(), + runtime_compose_path: runtime_compose_path.to_string(), + local_authoring_env_path: local_authoring_env_path.to_string(), + runtime_env_path: runtime_env_path.to_string(), + services, + reasoning: vec![ + "runtime compose path is fixed to the canonical remote deployment location".to_string(), + "runtime env path is shared across deployed services for the target deployment" + .to_string(), + ], + } +} + +fn env_layers(input: &EnvRenderInput) -> Vec { + let mut layers = Vec::new(); + + if !input.base.is_empty() { + layers.push(to_layer("base", &input.base)); + } + if !input.generated.is_empty() { + layers.push(to_layer("generated", &input.generated)); + } + if input.inherit_server_secrets && !input.server.is_empty() { + layers.push(to_layer("server", &input.server)); + } + if !input.service.is_empty() { + layers.push(to_layer("service", &input.service)); + } + if !input.compose_environment.is_empty() { + layers.push(to_layer("compose", &input.compose_environment)); + } + + layers +} + +fn to_layer(name: &str, layer: &HashMap) -> ExplainEnvLayer { + let ordered = layer + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + let digest_source = ordered + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("\n"); + + ExplainEnvLayer { + name: name.to_string(), + key_names: ordered.keys().cloned().collect(), + key_count: ordered.len(), + hash: format!("{:x}", Sha256::digest(digest_source.as_bytes())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::helpers::{remote_runtime_compose_path, remote_runtime_env_path}; + + fn sample_input() -> EnvRenderInput { + let mut input = EnvRenderInput { + inherit_server_secrets: true, + ..EnvRenderInput::default() + }; + input.base.insert("HOST".to_string(), "0.0.0.0".to_string()); + input.base.insert("PORT".to_string(), "8080".to_string()); + input.server.insert( + "DATABASE_URL".to_string(), + "SUPER_SECRET_SHOULD_NOT_LEAK".to_string(), + ); + input + .service + .insert("DATABASE_URL".to_string(), "service-override".to_string()); + input + .compose_environment + .insert("RUST_LOG".to_string(), "debug".to_string()); + input.generated.insert( + "DEPLOYMENT_HASH".to_string(), + "deployment_state_online".to_string(), + ); + input + } + + #[test] + fn build_explain_env_uses_hashes_and_paths_without_secret_values() { + let explain = build_explain_env( + "deployment_state_online", + "device-api", + "docker/prod/.env", + remote_runtime_env_path(), + remote_runtime_compose_path(), + 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!(explain.rendered_env.service_secrets_override_server_secrets); + assert!(explain.layers.iter().any(|layer| layer.name == "generated")); + assert!(!explain + .rendered_env + .inputs + .contains(&"generated".to_string())); + + let serialized = serde_json::to_string(&explain).expect("serialize explain env"); + assert!(!serialized.contains("SUPER_SECRET_SHOULD_NOT_LEAK")); + assert!(serialized.contains("DATABASE_URL")); + } + + #[test] + fn build_explain_topology_uses_canonical_runtime_paths() { + let topology = build_explain_topology( + "deployment_state_online", + "cloud", + "docker/prod/compose.yml", + remote_runtime_compose_path(), + "docker/prod/.env", + remote_runtime_env_path(), + vec![ + ExplainTopologyService { + code: "device-api".to_string(), + name: "Device API".to_string(), + enabled: true, + }, + ExplainTopologyService { + code: "upload".to_string(), + name: "Upload".to_string(), + enabled: true, + }, + ], + ); + + assert_eq!(topology.runtime_compose_path, remote_runtime_compose_path()); + assert_eq!(topology.runtime_env_path, remote_runtime_env_path()); + assert_eq!(topology.services.len(), 2); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 37c57f27..d68829e4 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,7 +1,13 @@ pub mod agent_dispatcher; pub mod config_renderer; pub mod dag_executor; +pub mod deploy_plan; +pub mod deployment_events; pub mod deployment_identifier; +pub mod deployment_state; +pub mod env_contract; +pub mod env_model; +pub mod explain; pub mod grpc_pipe; pub mod handoff; pub mod log_cache; @@ -11,14 +17,39 @@ pub mod project_app_service; mod rating; pub mod resilience_engine; pub mod step_executor; +pub mod typed_error; pub mod vault_service; pub mod ws_pipe; pub use config_renderer::{AppRenderContext, ConfigBundle, ConfigRenderer, SyncResult}; +pub use deploy_plan::{ + build_deploy_plan, build_rollback_plan, resolve_rollback_plan_context, DeployPlan, + DeployPlanAction, DeployPlanActionKind, DeployPlanOperation, DeployPlanRollback, + DeployPlanScope, RollbackPlanContext, DEPLOY_PLAN_SCHEMA_VERSION, +}; +pub use deployment_events::{ + DeploymentEvent, DeploymentEventClassification, DeploymentEventFeed, DeploymentEventKind, + DEPLOYMENT_EVENTS_SCHEMA_VERSION, +}; pub use deployment_identifier::{ DeploymentIdentifier, DeploymentIdentifierArgs, DeploymentResolveError, DeploymentResolver, StackerDeploymentResolver, }; +pub use deployment_state::{ + DeploymentAgentFeatures, DeploymentAgentState, DeploymentAppState, DeploymentDriftState, + DeploymentLastCommandState, DeploymentProjectState, DeploymentRuntimeState, DeploymentState, + DeploymentStateDeployment, DEPLOYMENT_STATE_SCHEMA_VERSION, +}; +pub use env_contract::{ + runtime_env_contract_response, runtime_env_layer_names, RuntimeEnvContractResponse, + RuntimeEnvLayerContract, RUNTIME_ENV_CONTRACT_VERSION, RUNTIME_ENV_LAYER_BASE, + RUNTIME_ENV_LAYER_COMPOSE, RUNTIME_ENV_LAYER_CONTRACTS, RUNTIME_ENV_LAYER_SERVER, + RUNTIME_ENV_LAYER_SERVICE, RUNTIME_ENV_PRECEDENCE_ORDER, +}; +pub use explain::{ + build_explain_env, build_explain_topology, ExplainDestination, ExplainEnv, ExplainEnvLayer, + ExplainRenderedEnv, ExplainTopology, ExplainTopologyService, EXPLAIN_SCHEMA_VERSION, +}; pub use handoff::InMemoryHandoffStore; pub use log_cache::LogCacheService; pub use marketplace_assets::{ @@ -27,4 +58,8 @@ pub use marketplace_assets::{ MARKETPLACE_ASSET_STORAGE_PROVIDER, }; pub use project_app_service::{ProjectAppError, ProjectAppService, SyncSummary}; +pub use typed_error::{ + ApiTypedError, TypedErrorCode, TypedErrorEnvelope, TypedRemediationClass, + TYPED_ERROR_SCHEMA_VERSION, +}; pub use vault_service::{AppConfig, VaultError, VaultService}; diff --git a/src/services/typed_error.rs b/src/services/typed_error.rs new file mode 100644 index 00000000..c0e76d09 --- /dev/null +++ b/src/services/typed_error.rs @@ -0,0 +1,263 @@ +use std::collections::BTreeMap; +use std::fmt; + +use actix_web::http::StatusCode; +use actix_web::{HttpResponse, ResponseError}; +use serde::{Deserialize, Serialize}; + +pub const TYPED_ERROR_SCHEMA_VERSION: &str = "v1alpha1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TypedErrorCode { + ComposePathUnresolved, + DeploymentCapabilityMissing, + DeploymentNotFound, + InternalError, + InvalidRequest, + PermissionDenied, + PlanStale, + RegistryAuthMissing, + RollbackTargetUnavailable, + RuntimeEnvDriftDetected, + VaultSecretNotFound, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TypedRemediationClass { + Auth, + Capability, + Configuration, + Internal, + Permissions, + Secret, + State, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TypedErrorEnvelope { + pub schema_version: String, + pub code: TypedErrorCode, + pub message: String, + pub retryable: bool, + pub remediation_class: TypedRemediationClass, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub context: BTreeMap, +} + +impl TypedErrorEnvelope { + pub fn new( + code: TypedErrorCode, + message: impl Into, + retryable: bool, + remediation_class: TypedRemediationClass, + ) -> Self { + Self { + schema_version: TYPED_ERROR_SCHEMA_VERSION.to_string(), + code, + message: message.into(), + retryable, + remediation_class, + context: BTreeMap::new(), + } + } + + pub fn invalid_request(message: impl Into) -> Self { + Self::new( + TypedErrorCode::InvalidRequest, + message, + false, + TypedRemediationClass::Configuration, + ) + } + + pub fn deployment_not_found(message: impl Into) -> Self { + Self::new( + TypedErrorCode::DeploymentNotFound, + message, + false, + TypedRemediationClass::State, + ) + } + + pub fn deployment_capability_missing(message: impl Into) -> Self { + Self::new( + TypedErrorCode::DeploymentCapabilityMissing, + message, + false, + TypedRemediationClass::Capability, + ) + } + + pub fn compose_path_unresolved(message: impl Into) -> Self { + Self::new( + TypedErrorCode::ComposePathUnresolved, + message, + false, + TypedRemediationClass::Configuration, + ) + } + + pub fn vault_secret_not_found(message: impl Into) -> Self { + Self::new( + TypedErrorCode::VaultSecretNotFound, + message, + false, + TypedRemediationClass::Secret, + ) + } + + pub fn permission_denied(message: impl Into) -> Self { + Self::new( + TypedErrorCode::PermissionDenied, + message, + false, + TypedRemediationClass::Permissions, + ) + } + + pub fn internal_error(message: impl Into) -> Self { + Self::new( + TypedErrorCode::InternalError, + message, + true, + TypedRemediationClass::Internal, + ) + } + + pub fn with_context(mut self, key: impl Into, value: impl Into) -> Self { + self.context.insert(key.into(), value.into()); + self + } + + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap_or_else(|_| { + format!( + r#"{{"schemaVersion":"{TYPED_ERROR_SCHEMA_VERSION}","code":"internal_error","message":"failed to serialize typed error","retryable":true,"remediationClass":"internal"}}"# + ) + }) + } + + pub fn to_pretty_json(&self) -> String { + serde_json::to_string_pretty(self).unwrap_or_else(|_| self.to_json()) + } + + pub fn from_mcp_error_message(message: &str) -> Self { + if let Ok(error) = serde_json::from_str::(message) { + return error; + } + if message.starts_with("Deployment not found") { + return Self::deployment_not_found(message); + } + if message.starts_with("Forbidden:") + || message.contains("Two-factor authentication is required") + { + return Self::permission_denied(message); + } + if message.starts_with("Invalid arguments:") + || message.starts_with("No deployment apps found") + || message.contains("App or service") + || message.contains("Missing params") + { + return Self::invalid_request(message); + } + Self::internal_error(message) + } +} + +#[derive(Debug, Clone)] +pub struct ApiTypedError { + status: StatusCode, + envelope: TypedErrorEnvelope, +} + +impl ApiTypedError { + pub fn bad_request(envelope: TypedErrorEnvelope) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + envelope, + } + } + + pub fn not_found(envelope: TypedErrorEnvelope) -> Self { + Self { + status: StatusCode::NOT_FOUND, + envelope, + } + } + + pub fn forbidden(envelope: TypedErrorEnvelope) -> Self { + Self { + status: StatusCode::FORBIDDEN, + envelope, + } + } + + pub fn conflict(envelope: TypedErrorEnvelope) -> Self { + Self { + status: StatusCode::CONFLICT, + envelope, + } + } + + pub fn internal(envelope: TypedErrorEnvelope) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + envelope, + } + } +} + +impl fmt::Display for ApiTypedError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.envelope.message) + } +} + +impl std::error::Error for ApiTypedError {} + +impl ResponseError for ApiTypedError { + fn status_code(&self) -> StatusCode { + self.status + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status).json(&self.envelope) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn internal_errors_are_retryable() { + let error = TypedErrorEnvelope::internal_error("temporary backend issue"); + assert!(error.retryable); + assert_eq!(error.remediation_class, TypedRemediationClass::Internal); + } + + #[test] + fn deployment_capability_errors_are_not_retryable() { + let error = TypedErrorEnvelope::deployment_capability_missing("compose logs unsupported"); + assert!(!error.retryable); + assert_eq!(error.remediation_class, TypedRemediationClass::Capability); + } + + #[test] + fn mcp_error_mapping_prefers_known_not_found_code() { + let error = TypedErrorEnvelope::from_mcp_error_message("Deployment not found"); + assert_eq!(error.code, TypedErrorCode::DeploymentNotFound); + } + + #[test] + fn mcp_error_mapping_preserves_pre_serialized_typed_errors() { + let envelope = TypedErrorEnvelope::invalid_request("confirm=true is required") + .with_context("tool", "apply_deployment_plan"); + let error = TypedErrorEnvelope::from_mcp_error_message(&envelope.to_pretty_json()); + + assert_eq!(error, envelope); + } +} diff --git a/src/services/vault_service.rs b/src/services/vault_service.rs index 5b68e147..be818d49 100644 --- a/src/services/vault_service.rs +++ b/src/services/vault_service.rs @@ -220,6 +220,14 @@ impl VaultService { ) } + pub fn status_panel_npm_credentials_path(&self, server_id: i32) -> String { + format!( + "{}/hosts/{}/npm_credentials", + self.prefix.trim_matches('/'), + server_id + ) + } + pub async fn fetch_secret_value(&self, logical_path: &str) -> Result { let response = self .http_client @@ -296,6 +304,36 @@ impl VaultService { Ok(()) } + pub async fn store_structured_secret_value( + &self, + logical_path: &str, + value: &serde_json::Value, + ) -> Result<(), VaultError> { + let payload = serde_json::json!({ + "data": value + }); + + let response = self + .http_client + .post(self.secret_url(logical_path)) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await + .map_err(|e| VaultError::ConnectionFailed(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(VaultError::Other(format!( + "Failed to store secret at {}: {} - {}", + logical_path, status, body + ))); + } + + Ok(()) + } + pub async fn delete_secret_value(&self, logical_path: &str) -> Result<(), VaultError> { let response = self .http_client @@ -761,6 +799,10 @@ mod tests { service.server_secret_path("user-1", 99, "HOST_TOKEN"), "agent/users/user-1/servers/99/secrets/HOST_TOKEN" ); + assert_eq!( + service.status_panel_npm_credentials_path(99), + "agent/hosts/99/npm_credentials" + ); assert_eq!( service.secret_url("agent/users/user-1/projects/42/apps/web/secrets/S3_KEY"), "http://vault.example/v1/agent/users/user-1/projects/42/apps/web/secrets/S3_KEY" diff --git a/src/startup.rs b/src/startup.rs index c1f4bce1..367a09a0 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -293,7 +293,10 @@ pub async fn run( .service( web::scope("/v1/deployments") .service(routes::deployment::capabilities_handler) + .service(routes::deployment::events_handler) .service(routes::deployment::list_handler) + .service(routes::deployment::plan_handler) + .service(routes::deployment::state_handler) .service(routes::deployment::status_by_hash_handler) .service(routes::deployment::status_handler) .service(routes::deployment::status_by_project_handler) diff --git a/tests/agent_command_flow.rs b/tests/agent_command_flow.rs index eabb66a6..96ab4a5f 100644 --- a/tests/agent_command_flow.rs +++ b/tests/agent_command_flow.rs @@ -70,7 +70,7 @@ async fn register_test_agent( }); let register_response = client - .post(&format!("{}/api/v1/agent/register", &app.address)) + .post(format!("{}/api/v1/agent/register", &app.address)) .json(®ister_payload) .send() .await @@ -195,7 +195,7 @@ async fn wait_for_command( agent_token: &str, ) -> serde_json::Value { client - .get(&format!( + .get(format!( "{}/api/v1/agent/commands/wait/{}", &app.address, deployment_hash )) @@ -280,7 +280,7 @@ async fn test_agent_command_flow() { }); let register_response = client - .post(&format!("{}/api/v1/agent/register", &app.address)) + .post(format!("{}/api/v1/agent/register", &app.address)) .json(®ister_payload) .send() .await @@ -330,7 +330,7 @@ async fn test_agent_command_flow() { // Use a test Bearer token - the mock auth server will validate any token let create_command_response = client - .post(&format!("{}/api/v1/commands", &app.address)) + .post(format!("{}/api/v1/commands", &app.address)) .header("Authorization", "Bearer test_token_12345") .json(&command_payload) .send() @@ -372,7 +372,7 @@ async fn test_agent_command_flow() { // Agent should authenticate with X-Agent-Id header and Bearer token let wait_response = client - .get(&format!( + .get(format!( "{}/api/v1/agent/commands/wait/{}", &app.address, deployment_hash )) @@ -429,7 +429,7 @@ async fn test_agent_command_flow() { }); let report_response = client - .post(&format!("{}/api/v1/agent/commands/report", &app.address)) + .post(format!("{}/api/v1/agent/commands/report", &app.address)) .header("X-Agent-Id", &agent_id) .header("Authorization", format!("Bearer {}", agent_token)) .json(&report_payload) @@ -545,7 +545,7 @@ async fn test_trigger_pipe_report_persists_execution_history() { }); let register_response = client - .post(&format!("{}/api/v1/agent/register", &app.address)) + .post(format!("{}/api/v1/agent/register", &app.address)) .json(®ister_payload) .send() .await @@ -593,7 +593,7 @@ async fn test_trigger_pipe_report_persists_execution_history() { .expect("Failed to queue trigger_pipe command"); let wait_response = client - .get(&format!( + .get(format!( "{}/api/v1/agent/commands/wait/{}", &app.address, deployment_hash )) @@ -640,7 +640,7 @@ async fn test_trigger_pipe_report_persists_execution_history() { }); let report_response = client - .post(&format!("{}/api/v1/agent/commands/report", &app.address)) + .post(format!("{}/api/v1/agent/commands/report", &app.address)) .header("X-Agent-Id", &agent_id) .header("Authorization", format!("Bearer {}", agent_token)) .json(&report_payload) @@ -771,7 +771,7 @@ async fn test_agent_heartbeat() { }); let register_response = client - .post(&format!("{}/api/v1/agent/register", &app.address)) + .post(format!("{}/api/v1/agent/register", &app.address)) .json(®ister_payload) .send() .await @@ -797,7 +797,7 @@ async fn test_agent_heartbeat() { // Poll for commands (this updates heartbeat) let wait_response = client - .get(&format!( + .get(format!( "{}/api/v1/agent/commands/wait/{}", &app.address, deployment_hash )) @@ -841,7 +841,7 @@ async fn test_command_priority_ordering() { }); let register_response = client - .post(&format!("{}/api/v1/agent/register", &app.address)) + .post(format!("{}/api/v1/agent/register", &app.address)) .json(®ister_payload) .send() .await @@ -869,7 +869,7 @@ async fn test_command_priority_ordering() { }); client - .post(&format!("{}/api/v1/commands", &app.address)) + .post(format!("{}/api/v1/commands", &app.address)) .json(&cmd_payload) .send() .await @@ -878,7 +878,7 @@ async fn test_command_priority_ordering() { // Agent should receive critical command first let wait_response = client - .get(&format!( + .get(format!( "{}/api/v1/agent/commands/wait/{}", &app.address, deployment_hash )) @@ -956,7 +956,7 @@ async fn test_authenticated_command_creation() { { let anon_client = reqwest::Client::new(); let response_no_auth = anon_client - .post(&format!("{}/api/v1/commands", &app.address)) + .post(format!("{}/api/v1/commands", &app.address)) .json(&cmd_payload) .send() .await @@ -972,7 +972,7 @@ async fn test_authenticated_command_creation() { println!("\n=== Test 2: Command creation with authentication (should succeed) ==="); let response_with_auth = client - .post(&format!("{}/api/v1/commands", &app.address)) + .post(format!("{}/api/v1/commands", &app.address)) .header("Authorization", "Bearer test_token_authenticated") .json(&cmd_payload) .send() @@ -1002,7 +1002,7 @@ async fn test_authenticated_command_creation() { println!("\n=== Test 3: List commands for deployment ==="); let list_response = client - .get(&format!( + .get(format!( "{}/api/v1/commands/{}", &app.address, deployment_hash )) @@ -1085,7 +1085,7 @@ async fn test_command_priorities_and_permissions() { }); let response = client - .post(&format!("{}/api/v1/commands", &app.address)) + .post(format!("{}/api/v1/commands", &app.address)) .header("Authorization", "Bearer test_token") .json(&payload) .send() @@ -1114,7 +1114,7 @@ async fn test_command_priorities_and_permissions() { }); let register_response = client - .post(&format!("{}/api/v1/agent/register", &app.address)) + .post(format!("{}/api/v1/agent/register", &app.address)) .json(®ister_payload) .send() .await @@ -1131,7 +1131,7 @@ async fn test_command_priorities_and_permissions() { // Agent polls - should receive critical priority first println!("\n=== Agent polling for commands (should receive critical first) ==="); let wait_response = client - .get(&format!( + .get(format!( "{}/api/v1/agent/commands/wait/{}", &app.address, deployment_hash )) @@ -1213,7 +1213,7 @@ async fn test_trigger_pipe_failed_report_persists_execution_history() { }); let report_response = client - .post(&format!("{}/api/v1/agent/commands/report", &app.address)) + .post(format!("{}/api/v1/agent/commands/report", &app.address)) .header("X-Agent-Id", &agent_id) .header("Authorization", format!("Bearer {}", agent_token)) .json(&report_payload) @@ -1339,7 +1339,7 @@ async fn test_replay_trigger_pipe_report_updates_existing_replay_execution() { }); let report_response = client - .post(&format!("{}/api/v1/agent/commands/report", &app.address)) + .post(format!("{}/api/v1/agent/commands/report", &app.address)) .header("X-Agent-Id", &agent_id) .header("Authorization", format!("Bearer {}", agent_token)) .json(&report_payload) @@ -1431,7 +1431,7 @@ async fn test_activate_pipe_report_accepts_runtime_lifecycle_shape() { }); let report_response = client - .post(&format!("{}/api/v1/agent/commands/report", &app.address)) + .post(format!("{}/api/v1/agent/commands/report", &app.address)) .header("X-Agent-Id", &agent_id) .header("Authorization", format!("Bearer {}", agent_token)) .json(&report_payload) @@ -1507,7 +1507,7 @@ async fn test_deactivate_pipe_report_accepts_runtime_lifecycle_shape() { }); let report_response = client - .post(&format!("{}/api/v1/agent/commands/report", &app.address)) + .post(format!("{}/api/v1/agent/commands/report", &app.address)) .header("X-Agent-Id", &agent_id) .header("Authorization", format!("Bearer {}", agent_token)) .json(&report_payload) diff --git a/tests/agent_login_link.rs b/tests/agent_login_link.rs index 083adbd0..693b96b7 100644 --- a/tests/agent_login_link.rs +++ b/tests/agent_login_link.rs @@ -43,7 +43,7 @@ async fn test_agent_login_returns_deployments() { }); let resp = client - .post(&format!("{}/api/v1/agent/login", &app.address)) + .post(format!("{}/api/v1/agent/login", &app.address)) .json(&login_payload) .send() .await @@ -76,7 +76,7 @@ async fn test_agent_login_accessible_without_auth() { }); let resp = client - .post(&format!("{}/api/v1/agent/login", &app.address)) + .post(format!("{}/api/v1/agent/login", &app.address)) .json(&login_payload) .send() .await @@ -118,7 +118,7 @@ async fn test_agent_link_rejects_invalid_token() { }); let resp = client - .post(&format!("{}/api/v1/agent/link", &app.address)) + .post(format!("{}/api/v1/agent/link", &app.address)) .json(&link_payload) .send() .await @@ -155,7 +155,7 @@ async fn test_agent_link_accessible_without_auth() { }); let resp = client - .post(&format!("{}/api/v1/agent/link", &app.address)) + .post(format!("{}/api/v1/agent/link", &app.address)) .json(&link_payload) .send() .await @@ -221,7 +221,7 @@ async fn test_agent_link_rejects_non_owner() { }); let resp = client - .post(&format!("{}/api/v1/agent/link", &app.address)) + .post(format!("{}/api/v1/agent/link", &app.address)) .json(&link_payload) .send() .await diff --git a/tests/agreement.rs b/tests/agreement.rs index 89bea0f4..d206f167 100644 --- a/tests/agreement.rs +++ b/tests/agreement.rs @@ -64,7 +64,7 @@ async fn get() { .expect("Failed to insert test agreement"); let response = client - .get(&format!("{}/agreement/{}", &app.address, agreement_id)) + .get(format!("{}/agreement/{}", &app.address, agreement_id)) .header("Authorization", "Bearer test_token") .send() .await @@ -95,7 +95,7 @@ async fn user_add() { let data = serde_json::json!({ "agrt_id": agreement_id }); let response = client - .post(&format!("{}/agreement", &app.address)) + .post(format!("{}/agreement", &app.address)) .header("Authorization", "Bearer test_token") .json(&data) .send() @@ -125,7 +125,7 @@ async fn user_add_via_api_prefix() { let data = serde_json::json!({ "agrt_id": agreement_id }); let response = client - .post(&format!("{}/api/agreement", &app.address)) + .post(format!("{}/api/agreement", &app.address)) .header("Authorization", "Bearer test_token") .json(&data) .send() diff --git a/tests/ai_workflows_contract.rs b/tests/ai_workflows_contract.rs new file mode 100644 index 00000000..974bf10d --- /dev/null +++ b/tests/ai_workflows_contract.rs @@ -0,0 +1,95 @@ +use serde_json::Value; + +fn load_workflows() -> Value { + serde_json::from_str(include_str!("contracts/stacker-ai-workflows.v1alpha1.json")) + .expect("AI workflow fixture should be valid JSON") +} + +#[test] +fn ai_workflows_fixture_metadata_is_correct() { + let workflows = load_workflows(); + + assert_eq!( + workflows["title"].as_str().unwrap(), + "stacker-ai-workflows-v1alpha1" + ); + assert_eq!(workflows["_owner"].as_str().unwrap(), "stacker"); + assert_eq!(workflows["version"].as_str().unwrap(), "v1alpha1"); +} + +#[test] +fn ai_workflows_cover_inspect_explain_plan_apply_and_recover() { + let workflows = load_workflows(); + let workflow_items = workflows["workflows"] + .as_array() + .expect("workflows should be an array"); + + let inspect_apply = workflow_items + .iter() + .find(|workflow| workflow["name"] == "inspect-explain-plan-apply") + .expect("inspect/apply workflow should exist"); + let inspect_apply_steps = inspect_apply["steps"] + .as_array() + .expect("inspect/apply workflow steps should be an array"); + let inspect_apply_tools: Vec<&str> = inspect_apply_steps + .iter() + .filter_map(|step| step["tool"].as_str()) + .collect(); + assert_eq!( + inspect_apply_tools, + vec![ + "get_deployment_state", + "explain_topology", + "get_deployment_plan", + "apply_deployment_plan", + ] + ); + + let recover = workflow_items + .iter() + .find(|workflow| workflow["name"] == "recover-with-rollback") + .expect("rollback recovery workflow should exist"); + let recover_steps = recover["steps"] + .as_array() + .expect("rollback recovery steps should be an array"); + let recover_tools: Vec<&str> = recover_steps + .iter() + .filter_map(|step| step["tool"].as_str()) + .collect(); + assert_eq!( + recover_tools, + vec![ + "get_deployment_state", + "get_deployment_events", + "get_deployment_plan", + "apply_deployment_plan", + "get_deployment_events", + ] + ); +} + +#[test] +fn ai_workflows_require_confirmation_and_fingerprint_for_apply_steps() { + let workflows = load_workflows(); + let workflow_items = workflows["workflows"] + .as_array() + .expect("workflows should be an array"); + + let apply_steps: Vec<&Value> = workflow_items + .iter() + .flat_map(|workflow| { + workflow["steps"] + .as_array() + .into_iter() + .flatten() + .filter(|step| step["tool"] == "apply_deployment_plan") + }) + .collect(); + + assert_eq!(apply_steps.len(), 2, "expected two apply workflow steps"); + for step in apply_steps { + assert_eq!(step["confirmRequired"].as_bool(), Some(true)); + assert_eq!(step["requiresFingerprint"].as_bool(), Some(true)); + assert_eq!(step["requiresMfa"].as_bool(), Some(true)); + } +} diff --git a/tests/cli_apply_plan.rs b/tests/cli_apply_plan.rs new file mode 100644 index 00000000..30926af2 --- /dev/null +++ b/tests/cli_apply_plan.rs @@ -0,0 +1,362 @@ +use assert_cmd::Command; +use chrono::{Duration, Utc}; +use mockito::Server; +use predicates::prelude::*; +use serde_json::json; +use stacker::cli::credentials::StoredCredentials; +use std::fs; +use tempfile::TempDir; + +fn stacker_cmd() -> Command { + Command::cargo_bin("stacker-cli").expect("stacker-cli binary not found") +} + +fn write_stacker_config(dir: &TempDir, deployment_hash: &str) { + let config = format!( + r#" +name: local-name +project: + identity: remote-project +app: + type: static + path: "." +deploy: + target: cloud + deployment_hash: {deployment_hash} + environment: prod +environments: + prod: + compose_file: docker/prod/compose.yml + env_file: docker/prod/.env +"# + ); + fs::create_dir_all(dir.path().join("docker/prod")).expect("create docker/prod"); + fs::write(dir.path().join("stacker.yml"), config).expect("write stacker.yml"); + fs::write(dir.path().join("index.html"), "

Hello

").expect("write index.html"); +} + +fn write_credentials(config_home: &TempDir, server_url: &str) { + let creds = StoredCredentials { + access_token: "tok".to_string(), + refresh_token: Some("rtok".to_string()), + token_type: "Bearer".to_string(), + expires_at: Utc::now() + Duration::hours(1), + email: Some("user@example.com".to_string()), + server_url: Some(server_url.to_string()), + org: None, + domain: None, + }; + + let cred_dir = config_home.path().join("stacker"); + fs::create_dir_all(&cred_dir).expect("create credentials dir"); + fs::write( + cred_dir.join("credentials.json"), + serde_json::to_vec(&creds).expect("serialize credentials"), + ) + .expect("write credentials"); +} + +#[test] +fn deploy_apply_plan_rejects_stale_fingerprint() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + write_stacker_config(&dir, "deployment_state_online"); + + let mut server = Server::new(); + write_credentials(&config_home, &server.url()); + + let stale = server + .mock("GET", "/api/v1/deployments/deployment_state_online/plan") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("operation".into(), "deploy".into()), + mockito::Matcher::UrlEncoded("target".into(), "cloud".into()), + mockito::Matcher::UrlEncoded("expectedFingerprint".into(), "stale-fingerprint".into()), + ])) + .match_header("authorization", "Bearer tok") + .with_status(409) + .with_header("content-type", "application/json") + .with_body( + json!({ + "schemaVersion": "v1alpha1", + "code": "plan_stale", + "message": "Plan input is stale; regenerate the plan before apply", + "retryable": false, + "remediationClass": "state", + "context": { + "expectedFingerprint": "stale-fingerprint", + "actualFingerprint": "fresh-fingerprint" + } + }) + .to_string(), + ) + .create(); + + stacker_cmd() + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .args(["deploy", "--apply-plan", "stale-fingerprint"]) + .assert() + .failure() + .stderr( + predicate::str::contains("plan_stale") + .and(predicate::str::contains("expectedFingerprint")) + .and(predicate::str::contains("fresh-fingerprint")), + ); + + stale.assert(); +} + +#[test] +fn deploy_apply_plan_is_idempotent_when_plan_is_already_satisfied() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + write_stacker_config(&dir, "deployment_state_online"); + + let mut server = Server::new(); + write_credentials(&config_home, &server.url()); + + let body = json!({ + "_status": "OK", + "msg": "Deployment plan fetched", + "item": { + "schemaVersion": "v1alpha1", + "deploymentHash": "deployment_state_online", + "operation": "deploy", + "target": "cloud", + "fingerprint": "plan-no-changes", + "scope": { + "mode": "deployment", + "selectedApps": ["device-api", "upload"] + }, + "hasChanges": false, + "actions": [], + "reasoning": ["no drift detected for the selected scope"] + } + }) + .to_string(); + + let mock1 = server + .mock("GET", "/api/v1/deployments/deployment_state_online/plan") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("operation".into(), "deploy".into()), + mockito::Matcher::UrlEncoded("target".into(), "cloud".into()), + mockito::Matcher::UrlEncoded("expectedFingerprint".into(), "plan-no-changes".into()), + ])) + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body.clone()) + .create(); + + stacker_cmd() + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .args(["deploy", "--apply-plan", "plan-no-changes"]) + .assert() + .success() + .stdout(predicate::str::contains("Plan already satisfied")); + + let mock2 = server + .mock("GET", "/api/v1/deployments/deployment_state_online/plan") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("operation".into(), "deploy".into()), + mockito::Matcher::UrlEncoded("target".into(), "cloud".into()), + mockito::Matcher::UrlEncoded("expectedFingerprint".into(), "plan-no-changes".into()), + ])) + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body) + .create(); + + stacker_cmd() + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .args(["deploy", "--apply-plan", "plan-no-changes"]) + .assert() + .success() + .stdout(predicate::str::contains("Plan already satisfied")); + + mock1.assert(); + mock2.assert(); +} + +#[test] +fn agent_deploy_app_apply_plan_validates_then_executes() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + write_stacker_config(&dir, "deployment_state_online"); + + let mut server = Server::new(); + write_credentials(&config_home, &server.url()); + + let plan = server + .mock("GET", "/api/v1/deployments/deployment_state_online/plan") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("operation".into(), "deploy_app".into()), + mockito::Matcher::UrlEncoded("target".into(), "cloud".into()), + mockito::Matcher::UrlEncoded("appCode".into(), "upload".into()), + mockito::Matcher::UrlEncoded("expectedFingerprint".into(), "plan-apply-upload".into()), + ])) + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "_status": "OK", + "msg": "Deployment plan fetched", + "item": { + "schemaVersion": "v1alpha1", + "deploymentHash": "deployment_state_online", + "operation": "deploy_app", + "target": "cloud", + "fingerprint": "plan-apply-upload", + "scope": { + "mode": "app", + "appCode": "upload", + "selectedApps": ["upload"] + }, + "hasChanges": true, + "actions": [ + { + "kind": "redeploy_app", + "target": "app", + "appCode": "upload", + "reason": "explicit deploy-app plan targets a single app" + } + ], + "reasoning": ["deploy-app scope is restricted to the requested app"] + } + }) + .to_string(), + ) + .create(); + + let enqueue_check = server + .mock("POST", "/api/v1/agent/commands/enqueue") + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "_status": "OK", + "item": { + "command_id": "cmd-check", + "deployment_hash": "deployment_state_online", + "type": "check_connections", + "status": "pending", + "priority": "normal", + "parameters": {}, + "result": null, + "error": null, + "created_at": "2026-05-17T12:00:00Z", + "updated_at": "2026-05-17T12:00:00Z" + } + }) + .to_string(), + ) + .create(); + + let status_check = server + .mock("GET", "/api/v1/commands/deployment_state_online/cmd-check") + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "_status": "OK", + "item": { + "command_id": "cmd-check", + "deployment_hash": "deployment_state_online", + "type": "check_connections", + "status": "completed", + "priority": "normal", + "parameters": {}, + "result": { + "active_connections": 0, + "ports": [] + }, + "error": null, + "created_at": "2026-05-17T12:00:00Z", + "updated_at": "2026-05-17T12:00:01Z" + } + }) + .to_string(), + ) + .create(); + + let enqueue_deploy = server + .mock("POST", "/api/v1/agent/commands/enqueue") + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "_status": "OK", + "item": { + "command_id": "cmd-1", + "deployment_hash": "deployment_state_online", + "type": "deploy_app", + "status": "pending", + "priority": "normal", + "parameters": {}, + "result": null, + "error": null, + "created_at": "2026-05-17T12:00:00Z", + "updated_at": "2026-05-17T12:00:00Z" + } + }) + .to_string(), + ) + .create(); + + let status_deploy = server + .mock("GET", "/api/v1/commands/deployment_state_online/cmd-1") + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "_status": "OK", + "item": { + "command_id": "cmd-1", + "deployment_hash": "deployment_state_online", + "type": "deploy_app", + "status": "completed", + "priority": "normal", + "parameters": {}, + "result": { + "status": "ok", + "message": "upload redeployed" + }, + "error": null, + "created_at": "2026-05-17T12:00:00Z", + "updated_at": "2026-05-17T12:00:03Z" + } + }) + .to_string(), + ) + .create(); + + stacker_cmd() + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .args([ + "agent", + "deploy-app", + "upload", + "--apply-plan", + "plan-apply-upload", + "--deployment", + "deployment_state_online", + ]) + .assert() + .success() + .stdout(predicate::str::contains("cmd-1").and(predicate::str::contains("deploy_app"))); + + plan.assert(); + enqueue_check.assert(); + status_check.assert(); + enqueue_deploy.assert(); + status_deploy.assert(); +} diff --git a/tests/cli_config.rs b/tests/cli_config.rs index e27902f6..f64c2dc2 100644 --- a/tests/cli_config.rs +++ b/tests/cli_config.rs @@ -115,6 +115,12 @@ deploy: .stdout(predicate::str::contains( "config_hash: unavailable_until_deploy", )) + .stdout(predicate::str::contains("runtime_env_contract_version: v1")) + .stdout(predicate::str::contains( + "runtime_env_contract_order: lowest_to_highest", + )) + .stdout(predicate::str::contains("name: base")) + .stdout(predicate::str::contains("name: compose")) .stdout(predicate::str::contains("superbucket").not()); } diff --git a/tests/cli_deploy_plan.rs b/tests/cli_deploy_plan.rs new file mode 100644 index 00000000..77755961 --- /dev/null +++ b/tests/cli_deploy_plan.rs @@ -0,0 +1,189 @@ +use assert_cmd::Command; +use chrono::{Duration, Utc}; +use mockito::Server; +use predicates::prelude::*; +use serde_json::json; +use stacker::cli::credentials::StoredCredentials; +use std::fs; +use tempfile::TempDir; + +fn stacker_cmd() -> Command { + Command::cargo_bin("stacker-cli").expect("stacker-cli binary not found") +} + +fn write_stacker_config(dir: &TempDir, deployment_hash: &str) { + let config = format!( + r#" +name: local-name +project: + identity: remote-project +app: + type: static + path: "." +deploy: + target: cloud + deployment_hash: {deployment_hash} + environment: prod +environments: + prod: + compose_file: docker/prod/compose.yml + env_file: docker/prod/.env +"# + ); + fs::create_dir_all(dir.path().join("docker/prod")).expect("create docker/prod"); + fs::write(dir.path().join("stacker.yml"), config).expect("write stacker.yml"); + fs::write(dir.path().join("index.html"), "

Hello

").expect("write index.html"); +} + +fn write_credentials(config_home: &TempDir, server_url: &str) { + let creds = StoredCredentials { + access_token: "tok".to_string(), + refresh_token: Some("rtok".to_string()), + token_type: "Bearer".to_string(), + expires_at: Utc::now() + Duration::hours(1), + email: Some("user@example.com".to_string()), + server_url: Some(server_url.to_string()), + org: None, + domain: None, + }; + + let cred_dir = config_home.path().join("stacker"); + fs::create_dir_all(&cred_dir).expect("create credentials dir"); + fs::write( + cred_dir.join("credentials.json"), + serde_json::to_vec(&creds).expect("serialize credentials"), + ) + .expect("write credentials"); +} + +#[test] +fn deploy_plan_outputs_read_only_plan_json() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + write_stacker_config(&dir, "deployment_state_online"); + + let mut server = Server::new(); + write_credentials(&config_home, &server.url()); + + let plan = json!({ + "schemaVersion": "v1alpha1", + "deploymentHash": "deployment_state_online", + "operation": "deploy", + "target": "cloud", + "fingerprint": "plan-no-changes", + "scope": { + "mode": "deployment", + "selectedApps": ["device-api", "upload"] + }, + "hasChanges": false, + "actions": [], + "reasoning": ["no drift detected for the selected scope"] + }); + + let mock = server + .mock("GET", "/api/v1/deployments/deployment_state_online/plan") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("operation".into(), "deploy".into()), + mockito::Matcher::UrlEncoded("target".into(), "cloud".into()), + ])) + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "_status": "OK", + "msg": "Deployment plan fetched", + "item": plan + }) + .to_string(), + ) + .create(); + + stacker_cmd() + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .args(["deploy", "--plan"]) + .assert() + .success() + .stdout( + predicate::str::contains("\"schemaVersion\": \"v1alpha1\"") + .and(predicate::str::contains("\"operation\": \"deploy\"")) + .and(predicate::str::contains("\"hasChanges\": false")), + ); + + mock.assert(); +} + +#[test] +fn agent_deploy_app_plan_outputs_scoped_plan_json() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + write_stacker_config(&dir, "deployment_state_online"); + + let mut server = Server::new(); + write_credentials(&config_home, &server.url()); + + let plan = json!({ + "schemaVersion": "v1alpha1", + "deploymentHash": "deployment_state_online", + "operation": "deploy_app", + "target": "cloud", + "fingerprint": "plan-deploy-app", + "scope": { + "mode": "app", + "appCode": "upload", + "selectedApps": ["upload"] + }, + "hasChanges": true, + "actions": [ + { + "kind": "redeploy_app", + "target": "app", + "appCode": "upload", + "reason": "explicit deploy-app plan targets a single app" + } + ], + "reasoning": ["deploy-app scope is restricted to the requested app"] + }); + + let mock = server + .mock("GET", "/api/v1/deployments/deployment_state_online/plan") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("operation".into(), "deploy_app".into()), + mockito::Matcher::UrlEncoded("target".into(), "cloud".into()), + mockito::Matcher::UrlEncoded("appCode".into(), "upload".into()), + ])) + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "_status": "OK", + "msg": "Deployment plan fetched", + "item": plan + }) + .to_string(), + ) + .create(); + + stacker_cmd() + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .args([ + "agent", + "deploy-app", + "upload", + "--plan", + "--deployment", + "deployment_state_online", + ]) + .assert() + .success() + .stdout( + predicate::str::contains("\"schemaVersion\": \"v1alpha1\"") + .and(predicate::str::contains("\"operation\": \"deploy_app\"")) + .and(predicate::str::contains("\"appCode\": \"upload\"")), + ); + + mock.assert(); +} diff --git a/tests/cli_deployment_events.rs b/tests/cli_deployment_events.rs new file mode 100644 index 00000000..bc7fda6b --- /dev/null +++ b/tests/cli_deployment_events.rs @@ -0,0 +1,131 @@ +use assert_cmd::Command; +use chrono::{Duration, Utc}; +use mockito::Server; +use predicates::prelude::*; +use serde_json::json; +use stacker::cli::credentials::StoredCredentials; +use std::fs; +use tempfile::TempDir; + +fn stacker_cmd() -> Command { + Command::cargo_bin("stacker-cli").expect("stacker-cli binary not found") +} + +fn write_stacker_config(dir: &TempDir, deployment_hash: &str) { + let config = format!( + r#" +name: local-name +project: + identity: remote-project +app: + type: static + path: "." +deploy: + target: cloud + deployment_hash: {deployment_hash} +"# + ); + fs::write(dir.path().join("stacker.yml"), config).expect("write stacker.yml"); + fs::write(dir.path().join("index.html"), "

Hello

").expect("write index.html"); +} + +fn write_credentials(config_home: &TempDir, server_url: &str) { + let creds = StoredCredentials { + access_token: "tok".to_string(), + refresh_token: Some("rtok".to_string()), + token_type: "Bearer".to_string(), + expires_at: Utc::now() + Duration::hours(1), + email: Some("user@example.com".to_string()), + server_url: Some(server_url.to_string()), + org: None, + domain: None, + }; + + let cred_dir = config_home.path().join("stacker"); + fs::create_dir_all(&cred_dir).expect("create credentials dir"); + fs::write( + cred_dir.join("credentials.json"), + serde_json::to_vec(&creds).expect("serialize credentials"), + ) + .expect("write credentials"); +} + +#[test] +fn deployment_events_help_shows_json_flag() { + stacker_cmd() + .args(["deployment", "events", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--json").and(predicate::str::contains("--deployment"))); +} + +#[test] +fn deployment_events_json_fetches_structured_feed() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + write_stacker_config(&dir, "deployment_events_online"); + + let mut server = Server::new(); + write_credentials(&config_home, &server.url()); + + let events_mock = server + .mock("GET", "/api/v1/deployments/deployment_events_online/events") + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "_status": "OK", + "msg": "Deployment events fetched", + "item": { + "schemaVersion": "v1alpha1", + "deploymentHash": "deployment_events_online", + "events": [ + { + "sequence": 1, + "kind": "command_queued", + "classification": "info", + "occurredAt": "2026-05-17T08:00:00Z", + "summary": "deploy_app queued", + "commandId": "cmd-1", + "commandType": "deploy_app", + "status": "queued" + }, + { + "sequence": 2, + "kind": "command_failed", + "classification": "failure", + "occurredAt": "2026-05-17T08:03:00Z", + "summary": "Compose path could not be resolved", + "commandId": "cmd-1", + "commandType": "deploy_app", + "status": "failed", + "retryable": false, + "remediationClass": "configuration" + } + ] + } + }) + .to_string(), + ) + .create(); + + stacker_cmd() + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .args(["deployment", "events", "--json"]) + .assert() + .success() + .stdout( + predicate::str::contains("\"schemaVersion\": \"v1alpha1\"") + .and(predicate::str::contains( + "\"deploymentHash\": \"deployment_events_online\"", + )) + .and(predicate::str::contains("\"kind\": \"command_failed\"")) + .and(predicate::str::contains( + "\"remediationClass\": \"configuration\"", + )), + ); + + events_mock.assert(); +} diff --git a/tests/cli_deployment_rollback.rs b/tests/cli_deployment_rollback.rs new file mode 100644 index 00000000..b201a1b3 --- /dev/null +++ b/tests/cli_deployment_rollback.rs @@ -0,0 +1,237 @@ +use assert_cmd::Command; +use chrono::{Duration, Utc}; +use mockito::{Matcher, Server}; +use predicates::prelude::*; +use serde_json::json; +use stacker::cli::credentials::StoredCredentials; +use std::fs; +use tempfile::TempDir; + +fn stacker_cmd() -> Command { + Command::cargo_bin("stacker-cli").expect("stacker-cli binary not found") +} + +fn write_stacker_config(dir: &TempDir, deployment_hash: &str) { + let config = format!( + r#" +name: local-name +project: + identity: remote-project +app: + type: static + path: "." +deploy: + target: cloud + deployment_hash: {deployment_hash} +"# + ); + fs::write(dir.path().join("stacker.yml"), config).expect("write stacker.yml"); + fs::write(dir.path().join("index.html"), "

Hello

").expect("write index.html"); +} + +fn write_credentials(config_home: &TempDir, server_url: &str) { + let creds = StoredCredentials { + access_token: "tok".to_string(), + refresh_token: Some("rtok".to_string()), + token_type: "Bearer".to_string(), + expires_at: Utc::now() + Duration::hours(1), + email: Some("user@example.com".to_string()), + server_url: Some(server_url.to_string()), + org: None, + domain: None, + }; + + let cred_dir = config_home.path().join("stacker"); + fs::create_dir_all(&cred_dir).expect("create credentials dir"); + fs::write( + cred_dir.join("credentials.json"), + serde_json::to_vec(&creds).expect("serialize credentials"), + ) + .expect("write credentials"); +} + +#[test] +fn deployment_rollback_plan_outputs_preview_json() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + write_stacker_config(&dir, "deployment_state_online"); + + let mut server = Server::new(); + write_credentials(&config_home, &server.url()); + + let mock = server + .mock("GET", "/api/v1/deployments/deployment_state_online/plan") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("operation".into(), "rollback_deploy".into()), + Matcher::UrlEncoded("target".into(), "cloud".into()), + Matcher::UrlEncoded("rollbackTarget".into(), "previous".into()), + ])) + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "_status": "OK", + "msg": "Deployment plan fetched", + "item": serde_json::from_str::(include_str!("contracts/stacker-deploy-plan.v1alpha1.rollback-previous.json")).unwrap() + }) + .to_string(), + ) + .create(); + + stacker_cmd() + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .args(["deployment", "rollback", "--to", "previous", "--plan"]) + .assert() + .success() + .stdout( + predicate::str::contains("\"operation\": \"rollback_deploy\"") + .and(predicate::str::contains("\"resolvedVersion\": \"1.1.0\"")), + ); + + mock.assert(); +} + +#[test] +fn deployment_rollback_apply_validates_plan_and_posts_version() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + write_stacker_config(&dir, "deployment_state_online"); + + let mut server = Server::new(); + write_credentials(&config_home, &server.url()); + + let plan = server + .mock("GET", "/api/v1/deployments/deployment_state_online/plan") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("operation".into(), "rollback_deploy".into()), + Matcher::UrlEncoded("target".into(), "cloud".into()), + Matcher::UrlEncoded("rollbackTarget".into(), "previous".into()), + Matcher::UrlEncoded("expectedFingerprint".into(), "plan-rollback-previous".into()), + ])) + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "_status": "OK", + "msg": "Deployment plan fetched", + "item": serde_json::from_str::(include_str!("contracts/stacker-deploy-plan.v1alpha1.rollback-previous.json")).unwrap() + }) + .to_string(), + ) + .create(); + + let list_projects = server + .mock("GET", "/project") + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "message": "OK", + "list": [ + { + "id": 42, + "name": "remote-project", + "user_id": "user-1", + "metadata": {}, + "created_at": "2026-04-13T00:00:00Z", + "updated_at": "2026-04-13T00:00:00Z" + } + ] + }) + .to_string(), + ) + .create(); + + let rollback = server + .mock("POST", "/project/42/rollback") + .match_header("authorization", "Bearer tok") + .match_header( + "content-type", + Matcher::Regex("application/json.*".to_string()), + ) + .match_body(Matcher::Json(json!({ "version": "1.1.0" }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "id": 42, + "_status": "ok", + "msg": "Success", + "meta": { "deployment_id": 99 } + }) + .to_string(), + ) + .create(); + + stacker_cmd() + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .args([ + "deployment", + "rollback", + "--to", + "previous", + "--apply-plan", + "plan-rollback-previous", + "--confirm", + ]) + .assert() + .success() + .stderr(predicate::str::contains("version '1.1.0'")); + + plan.assert(); + list_projects.assert(); + rollback.assert(); +} + +#[test] +fn deployment_rollback_plan_surfaces_typed_unsupported_error() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + write_stacker_config(&dir, "deployment_state_online"); + + let mut server = Server::new(); + write_credentials(&config_home, &server.url()); + + let mock = server + .mock("GET", "/api/v1/deployments/deployment_state_online/plan") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("operation".into(), "rollback_deploy".into()), + Matcher::UrlEncoded("target".into(), "cloud".into()), + Matcher::UrlEncoded("rollbackTarget".into(), "previous".into()), + ])) + .match_header("authorization", "Bearer tok") + .with_status(400) + .with_header("content-type", "application/json") + .with_body( + json!({ + "schemaVersion": "v1alpha1", + "code": "rollback_target_unavailable", + "message": "Rollback is only available for marketplace deployments with an older template version", + "retryable": false, + "remediationClass": "state", + "context": { + "rollbackTarget": "previous" + } + }) + .to_string(), + ) + .create(); + + stacker_cmd() + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .args(["deployment", "rollback", "--to", "previous", "--plan"]) + .assert() + .failure() + .stderr( + predicate::str::contains("rollback_target_unavailable") + .and(predicate::str::contains("rollbackTarget")), + ); + + mock.assert(); +} diff --git a/tests/cli_deployment_state.rs b/tests/cli_deployment_state.rs new file mode 100644 index 00000000..fb5351c7 --- /dev/null +++ b/tests/cli_deployment_state.rs @@ -0,0 +1,141 @@ +use assert_cmd::Command; +use chrono::{Duration, Utc}; +use mockito::Server; +use predicates::prelude::*; +use serde_json::json; +use stacker::cli::credentials::StoredCredentials; +use std::fs; +use tempfile::TempDir; + +fn stacker_cmd() -> Command { + Command::cargo_bin("stacker-cli").expect("stacker-cli binary not found") +} + +fn write_stacker_config(dir: &TempDir, deployment_hash: &str) { + let config = format!( + r#" +name: local-name +project: + identity: remote-project +app: + type: static + path: "." +deploy: + target: cloud + deployment_hash: {deployment_hash} +"# + ); + fs::write(dir.path().join("stacker.yml"), config).expect("write stacker.yml"); + fs::write(dir.path().join("index.html"), "

Hello

").expect("write index.html"); +} + +fn write_credentials(config_home: &TempDir, server_url: &str) { + let creds = StoredCredentials { + access_token: "tok".to_string(), + refresh_token: Some("rtok".to_string()), + token_type: "Bearer".to_string(), + expires_at: Utc::now() + Duration::hours(1), + email: Some("user@example.com".to_string()), + server_url: Some(server_url.to_string()), + org: None, + domain: None, + }; + + let cred_dir = config_home.path().join("stacker"); + fs::create_dir_all(&cred_dir).expect("create credentials dir"); + fs::write( + cred_dir.join("credentials.json"), + serde_json::to_vec(&creds).expect("serialize credentials"), + ) + .expect("write credentials"); +} + +#[test] +fn deployment_state_help_shows_json_flag() { + stacker_cmd() + .args(["deployment", "state", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--json").and(predicate::str::contains("--deployment"))); +} + +#[test] +fn deployment_state_json_fetches_canonical_payload() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + write_stacker_config(&dir, "deployment_state_online"); + + let mut server = Server::new(); + write_credentials(&config_home, &server.url()); + + let state = json!({ + "schemaVersion": "v1alpha1", + "project": { + "id": 17, + "identity": "remote-project", + "name": "Remote Project" + }, + "deployment": { + "id": 31, + "deploymentHash": "deployment_state_online", + "status": "healthy", + "runtime": "runc" + }, + "agent": { + "id": "agent-1", + "status": "online", + "version": "0.1.9", + "lastHeartbeat": "2026-05-17T08:15:00Z", + "capabilities": ["docker", "compose", "logs"], + "features": { + "compose": true, + "kataRuntime": false, + "backup": false, + "pipes": false, + "proxyCredentialsVault": false + } + }, + "runtime": { + "composePath": "/home/trydirect/project/docker-compose.yml", + "envPath": "/home/trydirect/project/.env" + }, + "apps": [], + "drift": { + "hasDrift": false, + "summary": "no drift detected" + } + }); + + let state_mock = server + .mock("GET", "/api/v1/deployments/deployment_state_online/state") + .match_header("authorization", "Bearer tok") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "_status": "OK", + "msg": "Deployment state fetched", + "item": state + }) + .to_string(), + ) + .create(); + + stacker_cmd() + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .args(["deployment", "state", "--json"]) + .assert() + .success() + .stdout( + predicate::str::contains("\"schemaVersion\": \"v1alpha1\"") + .and(predicate::str::contains( + "\"deploymentHash\": \"deployment_state_online\"", + )) + .and(predicate::str::contains( + "\"composePath\": \"/home/trydirect/project/docker-compose.yml\"", + )), + ); + + state_mock.assert(); +} diff --git a/tests/cli_explain.rs b/tests/cli_explain.rs new file mode 100644 index 00000000..e923913e --- /dev/null +++ b/tests/cli_explain.rs @@ -0,0 +1,103 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use tempfile::TempDir; + +fn stacker_cmd() -> Command { + Command::cargo_bin("stacker-cli").expect("stacker-cli binary not found") +} + +fn write_stacker_config(dir: &TempDir) { + let config = r#" +name: local-name +project: + identity: remote-project +app: + type: static + path: "." +deploy: + target: cloud + deployment_hash: deployment_state_online + environment: prod +env_file: docker/prod/.env +env: + HOST: "0.0.0.0" +services: + - name: device-api + image: optimum/device-api + environment: + DATABASE_URL: postgres://secret-value + RUST_LOG: debug +environments: + prod: + compose_file: docker/prod/compose.yml + env_file: docker/prod/.env +"#; + fs::create_dir_all(dir.path().join("docker/prod")).expect("create docker/prod"); + fs::write(dir.path().join("stacker.yml"), config).expect("write stacker.yml"); + fs::write(dir.path().join("index.html"), "

Hello

").expect("write index.html"); + fs::write(dir.path().join("docker/prod/.env"), "HOST=0.0.0.0\n").expect("write env"); + fs::write( + dir.path().join("docker/prod/compose.yml"), + "services:\n device-api:\n image: optimum/device-api\n", + ) + .expect("write compose"); +} + +#[test] +fn explain_env_json_outputs_redacted_provenance() { + let dir = TempDir::new().unwrap(); + write_stacker_config(&dir); + + stacker_cmd() + .current_dir(dir.path()) + .args(["explain", "env", "device-api", "--json"]) + .assert() + .success() + .stdout( + predicate::str::contains("\"schemaVersion\": \"v1alpha1\"") + .and(predicate::str::contains("\"appCode\": \"device-api\"")) + .and(predicate::str::contains( + "\"runtimeEnvPath\": \"/home/trydirect/project/.env\"", + )) + .and(predicate::str::contains("DATABASE_URL")) + .and(predicate::str::contains("secret-value").not()), + ); +} + +#[test] +fn explain_topology_json_outputs_paths_and_services() { + let dir = TempDir::new().unwrap(); + write_stacker_config(&dir); + + stacker_cmd() + .current_dir(dir.path()) + .args(["explain", "topology", "--json"]) + .assert() + .success() + .stdout( + predicate::str::contains("\"schemaVersion\": \"v1alpha1\"") + .and(predicate::str::contains("\"target\": \"cloud\"")) + .and(predicate::str::contains( + "\"runtimeComposePath\": \"/home/trydirect/project/docker-compose.yml\"", + )) + .and(predicate::str::contains("\"code\": \"device-api\"")), + ); +} + +#[test] +fn explain_env_missing_service_returns_typed_error() { + let dir = TempDir::new().unwrap(); + write_stacker_config(&dir); + + stacker_cmd() + .current_dir(dir.path()) + .args(["explain", "env", "missing-service", "--json"]) + .assert() + .failure() + .stderr( + predicate::str::contains("invalid_request") + .and(predicate::str::contains("remediationClass")) + .and(predicate::str::contains("configuration")), + ); +} diff --git a/tests/cli_logs.rs b/tests/cli_logs.rs index 2a7db39a..3527e941 100644 --- a/tests/cli_logs.rs +++ b/tests/cli_logs.rs @@ -2,7 +2,6 @@ use assert_cmd::Command; use predicates::prelude::*; -use std::fs; use tempfile::TempDir; fn stacker_cmd() -> Command { diff --git a/tests/cloud.rs b/tests/cloud.rs index a0326987..dfd238d0 100644 --- a/tests/cloud.rs +++ b/tests/cloud.rs @@ -10,7 +10,7 @@ async fn list() { let client = reqwest::Client::new(); // client let response = client - .get(&format!("{}/cloud", &app.address)) + .get(format!("{}/cloud", &app.address)) .header("Authorization", "Bearer test_token") .send() .await @@ -34,7 +34,7 @@ async fn add_cloud() { }); let response = client - .post(&format!("{}/cloud", &app.address)) + .post(format!("{}/cloud", &app.address)) .header("Authorization", "Bearer test_token") .json(&data) .send() diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 8e585c2f..60739f6d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,3 +1,9 @@ +#![allow( + dead_code, + clippy::field_reassign_with_default, + clippy::let_underscore_future +)] + use actix_web::{get, web, App, HttpServer, Responder}; use sqlx::{Connection, Executor, PgConnection, PgPool}; use stacker::configuration::{get_configuration, DatabaseSettings, Settings}; diff --git a/tests/contracts/pipe-contract/local_pipe.smtp_adapter.secret_ref.v1.json b/tests/contracts/pipe-contract/local_pipe.smtp_adapter.secret_ref.v1.json new file mode 100644 index 00000000..75a62356 --- /dev/null +++ b/tests/contracts/pipe-contract/local_pipe.smtp_adapter.secret_ref.v1.json @@ -0,0 +1,110 @@ +{ + "schema_version": 1, + "id": "status-panel-web-to-smtp-secret-ref", + "name": "status-panel-web-to-smtp-secret-ref", + "created_at": "2026-05-22T10:05:00Z", + "updated_at": "2026-05-22T10:05:00Z", + "status": "draft", + "source": { + "selector": "status-panel-web", + "container": "status-panel-web", + "method": "POST", + "path": "/contact", + "fields": ["name", "email", "subject", "message"] + }, + "target": { + "selector": "smtp", + "adapter": { + "code": "smtp", + "role": "target", + "config": { + "host": "smtp", + "port": 587, + "username": { + "secret_ref": { + "scope": "service", + "service": "smtp", + "name": "SMTP_USERNAME", + "source": "vault" + } + }, + "password": { + "secret_ref": { + "scope": "service", + "service": "smtp", + "name": "SMTP_PASSWORD", + "source": "vault" + } + }, + "from": "info@stacker.my", + "to": ["info@optimum-web.com"], + "tls": true + } + }, + "method": "SEND", + "path": "adapter:smtp", + "fields": ["from_email", "reply_to_email", "subject", "body_text", "body_html"] + }, + "template": { + "description": "POST /contact → SEND adapter:smtp", + "source_app_type": "status-panel-web", + "source_endpoint": { + "path": "/contact", + "method": "POST" + }, + "target_app_type": "smtp", + "target_endpoint": { + "mode": "adapter", + "adapter": "smtp", + "display_name": "SMTP target" + }, + "field_mapping": { + "from_email": "$.email", + "subject": "$.subject", + "body_text": "$.message" + }, + "config": { + "retry_count": 3 + }, + "is_public": false + }, + "instance": { + "source_container": "status-panel-web", + "target_adapter": { + "code": "smtp", + "role": "target", + "config": { + "host": "smtp", + "port": 587, + "username": { + "secret_ref": { + "scope": "service", + "service": "smtp", + "name": "SMTP_USERNAME", + "source": "vault" + } + }, + "password": { + "secret_ref": { + "scope": "service", + "service": "smtp", + "name": "SMTP_PASSWORD", + "source": "vault" + } + }, + "from": "info@stacker.my", + "to": ["info@optimum-web.com"], + "tls": true + } + }, + "trigger_count": 0, + "error_count": 0 + }, + "promotion": {}, + "diagnostics": { + "notes": [ + "source discovery: cached result (protocols: html_forms,openapi,rest, capture_samples: true)", + "target adapter: SMTP target (smtp)" + ] + } +} diff --git a/tests/contracts/pipe-contract/local_pipe.smtp_adapter.v1.json b/tests/contracts/pipe-contract/local_pipe.smtp_adapter.v1.json new file mode 100644 index 00000000..e7d8c30a --- /dev/null +++ b/tests/contracts/pipe-contract/local_pipe.smtp_adapter.v1.json @@ -0,0 +1,78 @@ +{ + "schema_version": 1, + "id": "status-panel-web-to-smtp", + "name": "status-panel-web-to-smtp", + "created_at": "2026-05-22T10:00:00Z", + "updated_at": "2026-05-22T10:00:00Z", + "status": "draft", + "source": { + "selector": "status-panel-web", + "container": "status-panel-web", + "method": "POST", + "path": "/contact", + "fields": ["name", "email", "subject", "message"] + }, + "target": { + "selector": "smtp", + "adapter": { + "code": "smtp", + "role": "target", + "config": { + "host": "smtp", + "port": 1025, + "from": "info@stacker.my", + "to": ["info@optimum-web.com"], + "tls": false + } + }, + "method": "SEND", + "path": "adapter:smtp", + "fields": ["from_email", "reply_to_email", "subject", "body_text", "body_html"] + }, + "template": { + "description": "POST /contact → SEND adapter:smtp", + "source_app_type": "status-panel-web", + "source_endpoint": { + "path": "/contact", + "method": "POST" + }, + "target_app_type": "smtp", + "target_endpoint": { + "mode": "adapter", + "adapter": "smtp", + "display_name": "SMTP target" + }, + "field_mapping": { + "from_email": "$.email", + "subject": "$.subject", + "body_text": "$.message" + }, + "config": { + "retry_count": 3 + }, + "is_public": false + }, + "instance": { + "source_container": "status-panel-web", + "target_adapter": { + "code": "smtp", + "role": "target", + "config": { + "host": "smtp", + "port": 1025, + "from": "info@stacker.my", + "to": ["info@optimum-web.com"], + "tls": false + } + }, + "trigger_count": 0, + "error_count": 0 + }, + "promotion": {}, + "diagnostics": { + "notes": [ + "source discovery: fresh result (protocols: html_forms,openapi,rest, capture_samples: true)", + "target adapter: SMTP target (smtp)" + ] + } +} diff --git a/tests/contracts/pipe-contract/remote_pipe.promote.smtp_adapter.request.json b/tests/contracts/pipe-contract/remote_pipe.promote.smtp_adapter.request.json new file mode 100644 index 00000000..d2d2a9b9 --- /dev/null +++ b/tests/contracts/pipe-contract/remote_pipe.promote.smtp_adapter.request.json @@ -0,0 +1,42 @@ +{ + "template_request": { + "name": "status-panel-web-to-smtp", + "description": "POST /contact → SEND adapter:smtp", + "source_app_type": "status-panel-web", + "source_endpoint": { + "path": "/contact", + "method": "POST" + }, + "target_app_type": "smtp", + "target_endpoint": { + "mode": "adapter", + "adapter": "smtp", + "display_name": "SMTP target" + }, + "field_mapping": { + "from_email": "$.email", + "subject": "$.subject", + "body_text": "$.message" + }, + "config": { + "retry_count": 3 + }, + "is_public": false + }, + "instance_request": { + "deployment_hash": "dep-20260522", + "source_container": "status-panel-web", + "target_adapter": { + "code": "smtp", + "role": "target", + "config": { + "host": "smtp", + "port": 1025, + "from": "info@stacker.my", + "to": ["info@optimum-web.com"], + "tls": false + } + }, + "template_id": "tpl-remote-smtp" + } +} diff --git a/tests/contracts/pipe-contract/remote_pipe.secret_ref.rejected_plaintext.json b/tests/contracts/pipe-contract/remote_pipe.secret_ref.rejected_plaintext.json new file mode 100644 index 00000000..39a5f8d2 --- /dev/null +++ b/tests/contracts/pipe-contract/remote_pipe.secret_ref.rejected_plaintext.json @@ -0,0 +1,72 @@ +{ + "expected_error_contains": "Sensitive adapter config 'target_adapter.config.password' must use a secret reference instead of a plaintext value", + "pipe": { + "schema_version": 1, + "id": "status-panel-web-to-smtp-plaintext", + "name": "status-panel-web-to-smtp-plaintext", + "created_at": "2026-05-22T10:10:00Z", + "updated_at": "2026-05-22T10:10:00Z", + "status": "draft", + "source": { + "selector": "status-panel-web", + "container": "status-panel-web", + "method": "POST", + "path": "/contact", + "fields": ["name", "email", "subject", "message"] + }, + "target": { + "selector": "smtp", + "adapter": { + "code": "smtp", + "role": "target", + "config": { + "host": "smtp", + "password": "super-secret" + } + }, + "method": "SEND", + "path": "adapter:smtp", + "fields": ["from_email", "reply_to_email", "subject", "body_text", "body_html"] + }, + "template": { + "description": "POST /contact → SEND adapter:smtp", + "source_app_type": "status-panel-web", + "source_endpoint": { + "path": "/contact", + "method": "POST" + }, + "target_app_type": "smtp", + "target_endpoint": { + "mode": "adapter", + "adapter": "smtp", + "display_name": "SMTP target" + }, + "field_mapping": { + "from_email": "$.email", + "subject": "$.subject", + "body_text": "$.message" + }, + "config": { + "retry_count": 3 + }, + "is_public": false + }, + "instance": { + "source_container": "status-panel-web", + "target_adapter": { + "code": "smtp", + "role": "target", + "config": { + "host": "smtp", + "password": "super-secret" + } + }, + "trigger_count": 0, + "error_count": 0 + }, + "promotion": {}, + "diagnostics": { + "notes": ["negative fixture for plaintext secret rejection"] + } + } +} diff --git a/tests/contracts/pipe-contract/remote_pipe.trigger.smtp_adapter.report.json b/tests/contracts/pipe-contract/remote_pipe.trigger.smtp_adapter.report.json new file mode 100644 index 00000000..3582d30c --- /dev/null +++ b/tests/contracts/pipe-contract/remote_pipe.trigger.smtp_adapter.report.json @@ -0,0 +1,42 @@ +{ + "type": "trigger_pipe", + "deployment_hash": "dep-123", + "pipe_instance_id": "pipe-adapter-1", + "success": true, + "source_data": { + "subject": "Incident opened", + "body": { + "text": "CPU usage exceeded threshold" + } + }, + "mapped_data": { + "subject": "Incident opened", + "body_text": "CPU usage exceeded threshold" + }, + "target_response": { + "transport": "smtp", + "adapter": "smtp", + "status": null, + "delivered": true, + "body": { + "host": "smtp.example.com", + "port": 587, + "tls": true, + "subject": "Incident opened", + "to": ["ops@example.com"], + "from": "alerts@example.com", + "message_id": "msg-123", + "accepted_recipients": 1 + } + }, + "triggered_at": "2026-05-22T08:00:00Z", + "trigger_type": "manual", + "lifecycle": { + "state": "active", + "activated_at": "2026-05-22T07:59:00Z", + "last_triggered_at": "2026-05-22T08:00:00Z", + "last_error": null, + "trigger_count": 1, + "last_updated_at": "2026-05-22T08:00:00Z" + } +} diff --git a/tests/contracts/runtime-env-contract.contract.json b/tests/contracts/runtime-env-contract.contract.json new file mode 100644 index 00000000..62c1aecc --- /dev/null +++ b/tests/contracts/runtime-env-contract.contract.json @@ -0,0 +1,56 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "runtime-env-contract", + "description": "Canonical runtime environment precedence contract shared across Stacker and downstream consumers.", + "_owner": "trydirect/config", + "_consumers": [ + "stacker", + "status", + "try.direct.admin.frontend" + ], + "_notes": [ + "Rendered runtime env payloads and inspection outputs must use these exact layer names and precedence values.", + "Clients must treat higher precedence layers as authoritative when the same key exists in multiple layers.", + "Inspection/help surfaces should expose the shared contract version and order instead of rewording the precedence rules independently." + ], + "version": "v1", + "order": "lowest_to_highest", + "layers": [ + { + "name": "base", + "precedence": 1, + "appliesWhen": "Always", + "description": "App env and local authoring inputs provide the base runtime layer." + }, + { + "name": "server", + "precedence": 2, + "appliesWhen": "Only when inherit_server_secrets=true", + "description": "Server-scope secrets overlay the base layer when the target opts in." + }, + { + "name": "service", + "precedence": 3, + "appliesWhen": "When remote service secrets exist for the selected service/app target", + "description": "Service-scope secrets override lower layers for the selected target." + }, + { + "name": "compose", + "precedence": 4, + "appliesWhen": "When the compose service defines environment: keys", + "description": "Compose environment keys win over env_file-derived layers at runtime." + } + ], + "inspectionOutputs": { + "runtimeEnvContractField": "runtime_env_contract", + "surfaces": [ + "stacker REST app env/config reads", + "stacker MCP get_app_env_vars/get_app_config", + "stacker-cli config show --resolved" + ], + "remoteSecretMetadata": { + "source": "vault", + "secure": true + } + } +} diff --git a/tests/contracts/stacker-ai-workflows.v1alpha1.json b/tests/contracts/stacker-ai-workflows.v1alpha1.json new file mode 100644 index 00000000..5de3c513 --- /dev/null +++ b/tests/contracts/stacker-ai-workflows.v1alpha1.json @@ -0,0 +1,64 @@ +{ + "title": "stacker-ai-workflows-v1alpha1", + "_owner": "stacker", + "version": "v1alpha1", + "description": "Canonical agent evaluation scenarios for AI-facing deployment workflows.", + "workflows": [ + { + "name": "inspect-explain-plan-apply", + "goal": "Safely inspect a deployment, explain topology, preview a change, and apply it with confirmation.", + "steps": [ + { + "tool": "get_deployment_state", + "purpose": "Inspect health, drift, app inventory, and the latest command state." + }, + { + "tool": "explain_topology", + "purpose": "Explain runtime compose and env paths without exposing secret values." + }, + { + "tool": "get_deployment_plan", + "purpose": "Preview the mutation and capture a stale-plan fingerprint." + }, + { + "tool": "apply_deployment_plan", + "purpose": "Apply the previewed plan after explicit confirmation.", + "confirmRequired": true, + "requiresFingerprint": true, + "requiresMfa": true + } + ] + }, + { + "name": "recover-with-rollback", + "goal": "Read failure signals, preview a rollback, apply it, and confirm recovery.", + "steps": [ + { + "tool": "get_deployment_state", + "purpose": "Confirm failing state or unexpected drift before rollback." + }, + { + "tool": "get_deployment_events", + "purpose": "Read remediation hints and recent command failures." + }, + { + "tool": "get_deployment_plan", + "purpose": "Preview rollback_deploy with previous or explicit target version.", + "operation": "rollback_deploy" + }, + { + "tool": "apply_deployment_plan", + "purpose": "Apply the rollback after explicit confirmation.", + "operation": "rollback_deploy", + "confirmRequired": true, + "requiresFingerprint": true, + "requiresMfa": true + }, + { + "tool": "get_deployment_events", + "purpose": "Observe recovery progress after rollback." + } + ] + } + ] +} diff --git a/tests/contracts/stacker-deploy-plan.v1alpha1.contract.json b/tests/contracts/stacker-deploy-plan.v1alpha1.contract.json new file mode 100644 index 00000000..cd751693 --- /dev/null +++ b/tests/contracts/stacker-deploy-plan.v1alpha1.contract.json @@ -0,0 +1,30 @@ +{ + "title": "stacker-deploy-plan-v1alpha1", + "_owner": "stacker", + "version": "v1alpha1", + "response": { + "type": "object", + "required": [ + "schemaVersion", + "deploymentHash", + "operation", + "target", + "fingerprint", + "scope", + "hasChanges", + "actions", + "reasoning" + ], + "properties": { + "schemaVersion": { "type": "string", "const": "v1alpha1" }, + "deploymentHash": { "type": "string" }, + "operation": { "type": "string" }, + "target": { "type": "string" }, + "fingerprint": { "type": "string" }, + "scope": { "type": "object" }, + "hasChanges": { "type": "boolean" }, + "actions": { "type": "array" }, + "reasoning": { "type": "array" } + } + } +} diff --git a/tests/contracts/stacker-deploy-plan.v1alpha1.deploy-app.json b/tests/contracts/stacker-deploy-plan.v1alpha1.deploy-app.json new file mode 100644 index 00000000..30e55ff7 --- /dev/null +++ b/tests/contracts/stacker-deploy-plan.v1alpha1.deploy-app.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": "v1alpha1", + "deploymentHash": "deployment_state_online", + "operation": "deploy_app", + "target": "cloud", + "fingerprint": "plan-deploy-app", + "scope": { + "mode": "app", + "appCode": "upload", + "selectedApps": ["upload"] + }, + "hasChanges": true, + "actions": [ + { + "kind": "redeploy_app", + "target": "app", + "appCode": "upload", + "reason": "explicit deploy-app plan targets a single app" + } + ], + "reasoning": [ + "deploy-app scope is restricted to the requested app" + ] +} diff --git a/tests/contracts/stacker-deploy-plan.v1alpha1.env-drift.json b/tests/contracts/stacker-deploy-plan.v1alpha1.env-drift.json new file mode 100644 index 00000000..9cd6f7cf --- /dev/null +++ b/tests/contracts/stacker-deploy-plan.v1alpha1.env-drift.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": "v1alpha1", + "deploymentHash": "deployment_state_online", + "operation": "deploy", + "target": "cloud", + "fingerprint": "plan-env-drift", + "scope": { + "mode": "deployment", + "selectedApps": ["device-api", "upload"] + }, + "hasChanges": true, + "actions": [ + { + "kind": "reconcile_runtime_env", + "target": "deployment", + "reason": "runtime env drift detected" + }, + { + "kind": "sync_app_config", + "target": "app", + "appCode": "upload", + "reason": "app config version is ahead of the synced Vault/runtime version" + } + ], + "reasoning": [ + "deployment drift requires runtime env reconciliation before apply", + "at least one selected app has unsynced config changes" + ] +} diff --git a/tests/contracts/stacker-deploy-plan.v1alpha1.no-changes.json b/tests/contracts/stacker-deploy-plan.v1alpha1.no-changes.json new file mode 100644 index 00000000..fb0e61da --- /dev/null +++ b/tests/contracts/stacker-deploy-plan.v1alpha1.no-changes.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": "v1alpha1", + "deploymentHash": "deployment_state_online", + "operation": "deploy", + "target": "cloud", + "fingerprint": "plan-no-changes", + "scope": { + "mode": "deployment", + "selectedApps": ["device-api", "upload"] + }, + "hasChanges": false, + "actions": [], + "reasoning": [ + "no drift detected for the selected scope", + "all selected apps are already synced with their current config versions" + ] +} diff --git a/tests/contracts/stacker-deploy-plan.v1alpha1.rollback-previous.json b/tests/contracts/stacker-deploy-plan.v1alpha1.rollback-previous.json new file mode 100644 index 00000000..e5504d78 --- /dev/null +++ b/tests/contracts/stacker-deploy-plan.v1alpha1.rollback-previous.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": "v1alpha1", + "deploymentHash": "deployment_state_online", + "operation": "rollback_deploy", + "target": "cloud", + "fingerprint": "plan-rollback-previous", + "scope": { + "mode": "deployment", + "selectedApps": ["device-api", "upload"] + }, + "hasChanges": true, + "actions": [ + { + "kind": "rollback_deploy", + "target": "deployment", + "reason": "rollback preview targets marketplace template version 1.1.0" + } + ], + "reasoning": [ + "rollback preview resolved requested target 'previous' to template version 1.1.0", + "current deployment template version is 1.2.0" + ], + "rollback": { + "requestedTarget": "previous", + "currentVersion": "1.2.0", + "resolvedVersion": "1.1.0" + } +} diff --git a/tests/contracts/stacker-deployment-events.v1alpha1.contract.json b/tests/contracts/stacker-deployment-events.v1alpha1.contract.json new file mode 100644 index 00000000..45fd5faf --- /dev/null +++ b/tests/contracts/stacker-deployment-events.v1alpha1.contract.json @@ -0,0 +1,42 @@ +{ + "title": "stacker-deployment-events-v1alpha1", + "_owner": "stacker", + "version": "v1alpha1", + "response": { + "type": "object", + "required": [ + "schemaVersion", + "deploymentHash", + "events" + ], + "properties": { + "schemaVersion": { "type": "string", "const": "v1alpha1" }, + "deploymentHash": { "type": "string" }, + "events": { + "type": "array", + "items": { + "type": "object", + "required": [ + "sequence", + "kind", + "classification", + "occurredAt", + "summary" + ], + "properties": { + "sequence": { "type": "integer" }, + "kind": { "type": "string" }, + "classification": { "type": "string" }, + "occurredAt": { "type": "string", "format": "date-time" }, + "summary": { "type": "string" }, + "commandId": { "type": "string" }, + "commandType": { "type": "string" }, + "status": { "type": "string" }, + "retryable": { "type": "boolean" }, + "remediationClass": { "type": "string" } + } + } + } + } + } +} diff --git a/tests/contracts/stacker-deployment-events.v1alpha1.sample.json b/tests/contracts/stacker-deployment-events.v1alpha1.sample.json new file mode 100644 index 00000000..19e436f2 --- /dev/null +++ b/tests/contracts/stacker-deployment-events.v1alpha1.sample.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": "v1alpha1", + "deploymentHash": "deployment_events_online", + "events": [ + { + "sequence": 1, + "kind": "command_queued", + "classification": "info", + "occurredAt": "2026-05-17T08:00:00Z", + "summary": "deploy_app queued", + "commandId": "cmd-1", + "commandType": "deploy_app", + "status": "queued" + }, + { + "sequence": 2, + "kind": "command_completed", + "classification": "success", + "occurredAt": "2026-05-17T08:05:00Z", + "summary": "upload redeployed", + "commandId": "cmd-1", + "commandType": "deploy_app", + "status": "completed" + }, + { + "sequence": 3, + "kind": "deployment_status", + "classification": "progress", + "occurredAt": "2026-05-17T08:06:00Z", + "summary": "Provisioning server", + "status": "in_progress" + } + ] +} diff --git a/tests/contracts/stacker-deployment-state.v1alpha1.contract.json b/tests/contracts/stacker-deployment-state.v1alpha1.contract.json new file mode 100644 index 00000000..28a6d9c0 --- /dev/null +++ b/tests/contracts/stacker-deployment-state.v1alpha1.contract.json @@ -0,0 +1,131 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "stacker-deployment-state-v1alpha1", + "description": "Canonical read-only deployment state contract for AI, CLI, API, and MCP consumers.", + "_owner": "stacker", + "_consumers": ["stacker", "status", "mcp", "ai-clients"], + "endpoint": { + "method": "GET", + "path": "/api/v1/deployments/{deployment_hash}/state", + "auth": "OAuth Bearer (deployment owner only)" + }, + "response": { + "type": "object", + "required": [ + "schemaVersion", + "project", + "deployment", + "agent", + "runtime", + "apps", + "drift" + ], + "properties": { + "schemaVersion": { + "type": "string", + "enum": ["v1alpha1"] + }, + "project": { + "type": "object", + "required": ["id", "identity", "name"], + "properties": { + "id": { "type": "integer" }, + "identity": { "type": "string" }, + "name": { "type": "string" } + } + }, + "deployment": { + "type": "object", + "required": ["id", "deploymentHash", "status", "runtime"], + "properties": { + "id": { "type": "integer" }, + "deploymentHash": { "type": "string" }, + "status": { "type": "string" }, + "runtime": { "type": "string" } + } + }, + "agent": { + "type": "object", + "required": ["status", "capabilities", "features"], + "properties": { + "id": { "type": "string" }, + "status": { "type": "string" }, + "version": { "type": "string" }, + "lastHeartbeat": { "type": "string", "format": "date-time" }, + "capabilities": { + "type": "array", + "items": { "type": "string" } + }, + "features": { + "type": "object", + "required": [ + "compose", + "kataRuntime", + "backup", + "pipes", + "proxyCredentialsVault" + ], + "properties": { + "compose": { "type": "boolean" }, + "kataRuntime": { "type": "boolean" }, + "backup": { "type": "boolean" }, + "pipes": { "type": "boolean" }, + "proxyCredentialsVault": { "type": "boolean" } + } + } + } + }, + "runtime": { + "type": "object", + "required": ["composePath", "envPath"], + "properties": { + "composePath": { "type": "string" }, + "envPath": { "type": "string" } + } + }, + "apps": { + "type": "array", + "items": { + "type": "object", + "required": [ + "code", + "name", + "enabled", + "configVersion", + "vaultSyncVersion" + ], + "properties": { + "code": { "type": "string" }, + "name": { "type": "string" }, + "enabled": { "type": "boolean" }, + "configVersion": { "type": "integer" }, + "vaultSyncVersion": { "type": "integer" }, + "configHash": { "type": "string" } + } + } + }, + "drift": { + "type": "object", + "required": ["hasDrift", "summary"], + "properties": { + "hasDrift": { "type": "boolean" }, + "summary": { "type": "string" } + } + }, + "lastCommand": { + "type": "object", + "required": ["type", "status"], + "properties": { + "type": { "type": "string" }, + "status": { "type": "string" }, + "finishedAt": { "type": "string", "format": "date-time" } + } + } + } + }, + "_notes": [ + "Secret values must never appear in this payload.", + "Paths and hashes are allowed; environment key/value pairs are not.", + "Consumers should tolerate missing optional objects such as lastCommand." + ] +} diff --git a/tests/contracts/stacker-deployment-state.v1alpha1.offline.json b/tests/contracts/stacker-deployment-state.v1alpha1.offline.json new file mode 100644 index 00000000..3f107161 --- /dev/null +++ b/tests/contracts/stacker-deployment-state.v1alpha1.offline.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": "v1alpha1", + "project": { + "id": 18, + "identity": "offline-demo", + "name": "Offline Demo" + }, + "deployment": { + "id": 32, + "deploymentHash": "deployment_state_offline", + "status": "pending", + "runtime": "runc" + }, + "agent": { + "status": "offline", + "capabilities": [], + "features": { + "compose": false, + "kataRuntime": false, + "backup": false, + "pipes": false, + "proxyCredentialsVault": false + } + }, + "runtime": { + "composePath": "/home/trydirect/project/docker-compose.yml", + "envPath": "/home/trydirect/project/.env" + }, + "apps": [], + "drift": { + "hasDrift": false, + "summary": "no drift detected" + } +} diff --git a/tests/contracts/stacker-deployment-state.v1alpha1.online.json b/tests/contracts/stacker-deployment-state.v1alpha1.online.json new file mode 100644 index 00000000..b59cf9be --- /dev/null +++ b/tests/contracts/stacker-deployment-state.v1alpha1.online.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": "v1alpha1", + "project": { + "id": 17, + "identity": "syncopia", + "name": "Syncopia" + }, + "deployment": { + "id": 31, + "deploymentHash": "deployment_state_online", + "status": "healthy", + "runtime": "runc" + }, + "agent": { + "id": "89f4c7dd-f41c-4f43-ae0d-e164d8f2f77d", + "status": "online", + "version": "0.1.9", + "lastHeartbeat": "2026-05-17T08:00:00Z", + "capabilities": ["docker", "compose", "logs"], + "features": { + "compose": true, + "kataRuntime": false, + "backup": false, + "pipes": false, + "proxyCredentialsVault": true + } + }, + "runtime": { + "composePath": "/home/trydirect/project/docker-compose.yml", + "envPath": "/home/trydirect/project/.env" + }, + "apps": [ + { + "code": "device-api", + "name": "Device API", + "enabled": true, + "configVersion": 3, + "vaultSyncVersion": 3, + "configHash": "cfg-device-api" + }, + { + "code": "upload", + "name": "Upload", + "enabled": true, + "configVersion": 2, + "vaultSyncVersion": 2, + "configHash": "cfg-upload" + } + ], + "drift": { + "hasDrift": false, + "summary": "no drift detected" + }, + "lastCommand": { + "type": "deploy_app", + "status": "success", + "finishedAt": "2026-05-17T08:05:00Z" + } +} diff --git a/tests/contracts/stacker-explain-env.v1alpha1.contract.json b/tests/contracts/stacker-explain-env.v1alpha1.contract.json new file mode 100644 index 00000000..09f99033 --- /dev/null +++ b/tests/contracts/stacker-explain-env.v1alpha1.contract.json @@ -0,0 +1,19 @@ +{ + "title": "stacker-explain-env-v1alpha1", + "_owner": "stacker", + "schemaVersion": "v1alpha1", + "response": { + "required": [ + "schemaVersion", + "deploymentHash", + "appCode", + "localAuthoringEnvPath", + "runtimeEnvPath", + "runtimeComposePath", + "layers", + "destination", + "renderedEnv", + "reasoning" + ] + } +} diff --git a/tests/contracts/stacker-explain-env.v1alpha1.json b/tests/contracts/stacker-explain-env.v1alpha1.json new file mode 100644 index 00000000..9d110d31 --- /dev/null +++ b/tests/contracts/stacker-explain-env.v1alpha1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": "v1alpha1", + "deploymentHash": "deployment_state_online", + "appCode": "device-api", + "localAuthoringEnvPath": "docker/prod/.env", + "runtimeEnvPath": "/home/trydirect/project/.env", + "runtimeComposePath": "/home/trydirect/project/docker-compose.yml", + "layers": [ + { + "name": "base", + "keyNames": ["HOST", "PORT"], + "keyCount": 2, + "hash": "f89ee522d1c00c23ec3fc267fef028f44c92ec4fb2c2fdb3dbffdd1248a0ba32" + }, + { + "name": "server", + "keyNames": ["DATABASE_URL"], + "keyCount": 1, + "hash": "18895d1f5c7f8f22974911d8626ee4dc9e7e00edcca8bbd8050550c1cbb34f84" + }, + { + "name": "service", + "keyNames": ["DATABASE_URL"], + "keyCount": 1, + "hash": "9862a0f6f605f149bdbaf0ea47b4601bdc7884b42d0bf50a4a87f6d096bcda7e" + } + ], + "destination": { + "path": "/home/trydirect/project/.env", + "writePolicy": "drift-protected", + "driftProtection": true + }, + "renderedEnv": { + "hash": "6333a2f8f2782f6ddbce4f8f16118bd38edb542cdbf2136097b89fdad2cbe64d", + "inputs": ["base", "server", "service"], + "serverSecretsInherited": true, + "serviceSecretsOverrideServerSecrets": true + }, + "reasoning": [ + "runtime env path is resolved from the canonical remote env path helper", + "env layers are merged in precedence order: base -> server -> service -> compose" + ] +} diff --git a/tests/contracts/stacker-explain-topology.v1alpha1.contract.json b/tests/contracts/stacker-explain-topology.v1alpha1.contract.json new file mode 100644 index 00000000..03aac3df --- /dev/null +++ b/tests/contracts/stacker-explain-topology.v1alpha1.contract.json @@ -0,0 +1,18 @@ +{ + "title": "stacker-explain-topology-v1alpha1", + "_owner": "stacker", + "schemaVersion": "v1alpha1", + "response": { + "required": [ + "schemaVersion", + "deploymentHash", + "target", + "localComposePath", + "runtimeComposePath", + "localAuthoringEnvPath", + "runtimeEnvPath", + "services", + "reasoning" + ] + } +} diff --git a/tests/contracts/stacker-explain-topology.v1alpha1.json b/tests/contracts/stacker-explain-topology.v1alpha1.json new file mode 100644 index 00000000..6c86924f --- /dev/null +++ b/tests/contracts/stacker-explain-topology.v1alpha1.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": "v1alpha1", + "deploymentHash": "deployment_state_online", + "target": "cloud", + "localComposePath": "docker/prod/compose.yml", + "runtimeComposePath": "/home/trydirect/project/docker-compose.yml", + "localAuthoringEnvPath": "docker/prod/.env", + "runtimeEnvPath": "/home/trydirect/project/.env", + "services": [ + { + "code": "device-api", + "name": "Device API", + "enabled": true + }, + { + "code": "upload", + "name": "Upload", + "enabled": true + } + ], + "reasoning": [ + "runtime compose path is fixed to the canonical remote deployment location", + "runtime env path is shared across deployed services for the target deployment" + ] +} diff --git a/tests/contracts/stacker-typed-error.v1alpha1.contract.json b/tests/contracts/stacker-typed-error.v1alpha1.contract.json new file mode 100644 index 00000000..404cfbe8 --- /dev/null +++ b/tests/contracts/stacker-typed-error.v1alpha1.contract.json @@ -0,0 +1,27 @@ +{ + "title": "stacker-typed-error-v1alpha1", + "_owner": "stacker", + "version": "v1alpha1", + "surfaces": ["cli", "api", "mcp"], + "response": { + "type": "object", + "required": [ + "schemaVersion", + "code", + "message", + "retryable", + "remediationClass" + ], + "properties": { + "schemaVersion": { "type": "string", "const": "v1alpha1" }, + "code": { "type": "string" }, + "message": { "type": "string" }, + "retryable": { "type": "boolean" }, + "remediationClass": { "type": "string" }, + "context": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + } +} diff --git a/tests/contracts/stacker-typed-error.v1alpha1.deployment-capability-missing.json b/tests/contracts/stacker-typed-error.v1alpha1.deployment-capability-missing.json new file mode 100644 index 00000000..224f904b --- /dev/null +++ b/tests/contracts/stacker-typed-error.v1alpha1.deployment-capability-missing.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": "v1alpha1", + "code": "deployment_capability_missing", + "message": "Deployment agent does not support compose logs", + "retryable": false, + "remediationClass": "capability", + "context": { + "deploymentHash": "deployment_demo", + "capability": "compose_logs" + } +} diff --git a/tests/deploy_plan_contract.rs b/tests/deploy_plan_contract.rs new file mode 100644 index 00000000..75fa34a8 --- /dev/null +++ b/tests/deploy_plan_contract.rs @@ -0,0 +1,105 @@ +use serde_json::Value; + +use stacker::services::{DeployPlan, DEPLOY_PLAN_SCHEMA_VERSION}; + +fn load_contract() -> Value { + serde_json::from_str(include_str!( + "contracts/stacker-deploy-plan.v1alpha1.contract.json" + )) + .expect("deploy plan contract JSON should be valid") +} + +#[test] +fn deploy_plan_contract_metadata_is_correct() { + let contract = load_contract(); + + assert_eq!( + contract["title"].as_str().unwrap(), + "stacker-deploy-plan-v1alpha1" + ); + assert_eq!(contract["_owner"].as_str().unwrap(), "stacker"); +} + +#[test] +fn deploy_plan_contract_requires_core_fields() { + let contract = load_contract(); + let required = contract["response"]["required"] + .as_array() + .expect("required should be an array"); + let required_names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); + + for field in [ + "schemaVersion", + "deploymentHash", + "operation", + "target", + "fingerprint", + "scope", + "hasChanges", + "actions", + "reasoning", + ] { + assert!( + required_names.contains(&field), + "required fields must include {field}" + ); + } +} + +#[test] +fn no_changes_fixture_deserializes_into_shared_type() { + let plan: DeployPlan = serde_json::from_str(include_str!( + "contracts/stacker-deploy-plan.v1alpha1.no-changes.json" + )) + .expect("no changes fixture should deserialize"); + + assert_eq!(plan.schema_version, DEPLOY_PLAN_SCHEMA_VERSION); + assert!(!plan.has_changes); + assert!(plan.actions.is_empty()); +} + +#[test] +fn env_drift_fixture_deserializes_into_shared_type() { + let plan: DeployPlan = serde_json::from_str(include_str!( + "contracts/stacker-deploy-plan.v1alpha1.env-drift.json" + )) + .expect("env drift fixture should deserialize"); + + assert_eq!(plan.schema_version, DEPLOY_PLAN_SCHEMA_VERSION); + assert!(plan.has_changes); + assert_eq!(plan.actions.len(), 2); +} + +#[test] +fn deploy_app_fixture_deserializes_into_shared_type() { + let plan: DeployPlan = serde_json::from_str(include_str!( + "contracts/stacker-deploy-plan.v1alpha1.deploy-app.json" + )) + .expect("deploy-app fixture should deserialize"); + + assert_eq!(plan.schema_version, DEPLOY_PLAN_SCHEMA_VERSION); + assert!(plan.has_changes); + assert_eq!(plan.scope.mode, "app"); + assert_eq!(plan.scope.app_code.as_deref(), Some("upload")); +} + +#[test] +fn rollback_fixture_deserializes_into_shared_type() { + let plan: DeployPlan = serde_json::from_str(include_str!( + "contracts/stacker-deploy-plan.v1alpha1.rollback-previous.json" + )) + .expect("rollback fixture should deserialize"); + + assert_eq!(plan.schema_version, DEPLOY_PLAN_SCHEMA_VERSION); + assert!(plan.has_changes); + assert_eq!( + plan.operation, + stacker::services::DeployPlanOperation::RollbackDeploy + ); + assert_eq!( + plan.rollback + .as_ref() + .map(|item| item.resolved_version.as_str()), + Some("1.1.0") + ); +} diff --git a/tests/deployment_events_contract.rs b/tests/deployment_events_contract.rs new file mode 100644 index 00000000..f36ea232 --- /dev/null +++ b/tests/deployment_events_contract.rs @@ -0,0 +1,49 @@ +use serde_json::Value; + +use stacker::services::{DeploymentEventFeed, DEPLOYMENT_EVENTS_SCHEMA_VERSION}; + +fn load_contract() -> Value { + serde_json::from_str(include_str!( + "contracts/stacker-deployment-events.v1alpha1.contract.json" + )) + .expect("deployment events contract JSON should be valid") +} + +#[test] +fn deployment_events_contract_metadata_is_correct() { + let contract = load_contract(); + + assert_eq!( + contract["title"].as_str().unwrap(), + "stacker-deployment-events-v1alpha1" + ); + assert_eq!(contract["_owner"].as_str().unwrap(), "stacker"); +} + +#[test] +fn deployment_events_contract_requires_core_fields() { + let contract = load_contract(); + let required = contract["response"]["required"] + .as_array() + .expect("required should be an array"); + let required_names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); + + for field in ["schemaVersion", "deploymentHash", "events"] { + assert!( + required_names.contains(&field), + "required fields must include {field}" + ); + } +} + +#[test] +fn deployment_events_fixture_deserializes_into_shared_type() { + let feed: DeploymentEventFeed = serde_json::from_str(include_str!( + "contracts/stacker-deployment-events.v1alpha1.sample.json" + )) + .expect("deployment events fixture should deserialize"); + + assert_eq!(feed.schema_version, DEPLOYMENT_EVENTS_SCHEMA_VERSION); + assert_eq!(feed.events.len(), 3); + assert_eq!(feed.events[0].sequence, 1); +} diff --git a/tests/deployment_plan_api.rs b/tests/deployment_plan_api.rs new file mode 100644 index 00000000..15b3035b --- /dev/null +++ b/tests/deployment_plan_api.rs @@ -0,0 +1,30 @@ +mod common; + +use reqwest::StatusCode; + +#[tokio::test] +async fn owner_can_fetch_deployment_plan() { + let Some(app) = common::spawn_app_two_users().await else { + return; + }; + let client = reqwest::Client::new(); + + let project_id = common::create_test_project(&app.db_pool, common::USER_A_ID).await; + let hash = format!("dpl-{}", uuid::Uuid::new_v4()); + let _deployment_id = + common::create_test_deployment(&app.db_pool, common::USER_A_ID, project_id, &hash).await; + + let resp = client + .get(format!("{}/api/v1/deployments/{}/plan", app.address, hash)) + .query(&[("operation", "deploy"), ("target", "cloud")]) + .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) + .send() + .await + .expect("request failed"); + + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["item"]["schemaVersion"].as_str().unwrap(), "v1alpha1"); + assert_eq!(body["item"]["deploymentHash"].as_str().unwrap(), hash); + assert_eq!(body["item"]["operation"].as_str().unwrap(), "deploy"); +} diff --git a/tests/deployment_state_contract.rs b/tests/deployment_state_contract.rs new file mode 100644 index 00000000..45ad514a --- /dev/null +++ b/tests/deployment_state_contract.rs @@ -0,0 +1,80 @@ +use serde_json::Value; + +use stacker::services::deployment_state::{DeploymentState, DEPLOYMENT_STATE_SCHEMA_VERSION}; + +fn load_contract() -> Value { + serde_json::from_str(include_str!( + "contracts/stacker-deployment-state.v1alpha1.contract.json" + )) + .expect("deployment state contract JSON should be valid") +} + +fn load_online_fixture() -> &'static str { + include_str!("contracts/stacker-deployment-state.v1alpha1.online.json") +} + +fn load_offline_fixture() -> &'static str { + include_str!("contracts/stacker-deployment-state.v1alpha1.offline.json") +} + +#[test] +fn deployment_state_contract_metadata_is_correct() { + let contract = load_contract(); + + assert_eq!( + contract["title"].as_str().unwrap(), + "stacker-deployment-state-v1alpha1" + ); + assert_eq!(contract["_owner"].as_str().unwrap(), "stacker"); + assert_eq!( + contract["endpoint"]["path"].as_str().unwrap(), + "/api/v1/deployments/{deployment_hash}/state" + ); +} + +#[test] +fn deployment_state_contract_requires_canonical_top_level_fields() { + let contract = load_contract(); + let required = contract["response"]["required"] + .as_array() + .expect("required should be an array"); + let required_names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); + + for field in [ + "schemaVersion", + "project", + "deployment", + "agent", + "runtime", + "apps", + "drift", + ] { + assert!( + required_names.contains(&field), + "required fields must include {field}" + ); + } +} + +#[test] +fn online_fixture_deserializes_into_shared_deployment_state_type() { + let state: DeploymentState = + serde_json::from_str(load_online_fixture()).expect("online fixture should deserialize"); + + assert_eq!(state.schema_version, DEPLOYMENT_STATE_SCHEMA_VERSION); + assert_eq!(state.deployment.deployment_hash, "deployment_state_online"); + assert_eq!(state.agent.status, "online"); + assert_eq!(state.apps.len(), 2); +} + +#[test] +fn offline_fixture_deserializes_and_omits_optional_agent_fields() { + let state: DeploymentState = + serde_json::from_str(load_offline_fixture()).expect("offline fixture should deserialize"); + + assert_eq!(state.schema_version, DEPLOYMENT_STATE_SCHEMA_VERSION); + assert_eq!(state.deployment.deployment_hash, "deployment_state_offline"); + assert_eq!(state.agent.status, "offline"); + assert!(state.agent.id.is_none()); + assert!(state.last_command.is_none()); +} diff --git a/tests/dockerhub.rs b/tests/dockerhub.rs index fed8257c..e12709cb 100644 --- a/tests/dockerhub.rs +++ b/tests/dockerhub.rs @@ -5,10 +5,8 @@ use docker_compose_types::ComposeVolume; mod common; use stacker::forms::project::DockerImage; // use stacker::helpers::project::dctypes::{ComposeVolume, SingleValue}; -use serde_yaml; use stacker::forms::project::Volume; -const DOCKER_USERNAME: &str = "trydirect"; const DOCKER_PASSWORD: &str = "**********"; // Unit Test @@ -73,7 +71,7 @@ async fn test_docker_hub_successful_login() { dockerhub_image: None, dockerhub_password: Some(String::from(DOCKER_PASSWORD)), }; - assert_eq!(di.is_active().await.unwrap(), true); + assert!(di.is_active().await.unwrap()); } #[tokio::test] @@ -88,7 +86,7 @@ async fn test_docker_private_exists() { dockerhub_image: None, dockerhub_password: Some(String::from(DOCKER_PASSWORD)), }; - assert_eq!(di.is_active().await.unwrap(), true); + assert!(di.is_active().await.unwrap()); } #[tokio::test] @@ -102,7 +100,7 @@ async fn test_public_repo_is_accessible() { dockerhub_image: None, dockerhub_password: Some(String::from("")), }; - assert_eq!(di.is_active().await.unwrap(), true); + assert!(di.is_active().await.unwrap()); } #[tokio::test] async fn test_docker_non_existent_repo() { @@ -116,7 +114,7 @@ async fn test_docker_non_existent_repo() { dockerhub_password: Some(String::from("")), }; println!("{}", di.is_active().await.unwrap()); - assert_eq!(di.is_active().await.unwrap(), false); + assert!(!di.is_active().await.unwrap()); } #[tokio::test] @@ -130,7 +128,7 @@ async fn test_docker_non_existent_repo_empty_namespace() { dockerhub_image: None, // namesps/reponame:tag full docker image string dockerhub_password: Some(String::from("")), }; - assert_eq!(di.is_active().await.unwrap(), true); + assert!(di.is_active().await.unwrap()); } #[tokio::test] diff --git a/tests/explain_contract.rs b/tests/explain_contract.rs new file mode 100644 index 00000000..7ab234c7 --- /dev/null +++ b/tests/explain_contract.rs @@ -0,0 +1,56 @@ +use serde_json::Value; + +use stacker::services::{ExplainEnv, ExplainTopology, EXPLAIN_SCHEMA_VERSION}; + +fn load_contract(path: &str) -> Value { + serde_json::from_str(path).expect("contract JSON should be valid") +} + +#[test] +fn explain_env_contract_metadata_is_correct() { + let contract = load_contract(include_str!( + "contracts/stacker-explain-env.v1alpha1.contract.json" + )); + + assert_eq!( + contract["title"].as_str().unwrap(), + "stacker-explain-env-v1alpha1" + ); + assert_eq!(contract["_owner"].as_str().unwrap(), "stacker"); +} + +#[test] +fn explain_topology_contract_metadata_is_correct() { + let contract = load_contract(include_str!( + "contracts/stacker-explain-topology.v1alpha1.contract.json" + )); + + assert_eq!( + contract["title"].as_str().unwrap(), + "stacker-explain-topology-v1alpha1" + ); + assert_eq!(contract["_owner"].as_str().unwrap(), "stacker"); +} + +#[test] +fn explain_env_fixture_deserializes_and_stays_redacted() { + let fixture = include_str!("contracts/stacker-explain-env.v1alpha1.json"); + let explain: ExplainEnv = + serde_json::from_str(fixture).expect("env fixture should deserialize"); + + assert_eq!(explain.schema_version, EXPLAIN_SCHEMA_VERSION); + assert_eq!(explain.deployment_hash, "deployment_state_online"); + assert_eq!(explain.app_code, "device-api"); + assert!(!fixture.contains("SUPER_SECRET_SHOULD_NOT_LEAK")); +} + +#[test] +fn explain_topology_fixture_deserializes() { + let fixture = include_str!("contracts/stacker-explain-topology.v1alpha1.json"); + let explain: ExplainTopology = + serde_json::from_str(fixture).expect("topology fixture should deserialize"); + + assert_eq!(explain.schema_version, EXPLAIN_SCHEMA_VERSION); + assert_eq!(explain.target, "cloud"); + assert_eq!(explain.services.len(), 2); +} diff --git a/tests/features/deployment_state.feature b/tests/features/deployment_state.feature new file mode 100644 index 00000000..78bffc77 --- /dev/null +++ b/tests/features/deployment_state.feature @@ -0,0 +1,14 @@ +Feature: Deployment state + As an AI-aware operator + I want a canonical deployment state endpoint + So that I can inspect current deployment reality without stitching commands + + Background: + Given I am authenticated as User A + And I have a test deployment with hash "bdd-deploy-state" + + Scenario: Get canonical deployment state for a deployment + When I get deployment state for "bdd-deploy-state" + Then the response status should be 200 + And the response JSON at "/item/schemaVersion" should be "v1alpha1" + And the response JSON at "/item/deployment/deploymentHash" should be "bdd-deploy-state" diff --git a/tests/features/mcp.feature b/tests/features/mcp.feature index 46483992..9043f449 100644 --- a/tests/features/mcp.feature +++ b/tests/features/mcp.feature @@ -51,6 +51,39 @@ Feature: MCP WebSocket Server Then the MCP response should have result And the MCP tool response should not be an error + Scenario: Canonical deployment inspection workflow returns stable MCP payloads + Given I have a test deployment with hash "deployment_ai_eval" and status "running" + When I connect to the MCP WebSocket endpoint + And I send an MCP initialize request + And I send an MCP tools/call request for "get_deployment_state" with arguments: + """ + {"deployment_hash":"deployment_ai_eval"} + """ + 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 "\"schemaVersion\":\"v1alpha1\"" + When I send an MCP tools/call request for "explain_topology" with arguments: + """ + {"deployment_hash":"deployment_ai_eval"} + """ + 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\"" + When I send an MCP tools/call request for "get_deployment_plan" with arguments: + """ + {"deployment_hash":"deployment_ai_eval","operation":"deploy"} + """ + 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 "\"fingerprint\":\"" + When I send an MCP tools/call request for "get_deployment_events" with arguments: + """ + {"deployment_hash":"deployment_ai_eval"} + """ + 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 "\"events\":[]" + Scenario: Call unknown tool returns error When I connect to the MCP WebSocket endpoint And I send an MCP initialize request diff --git a/tests/fixtures/ai/deployment_state.offline.json b/tests/fixtures/ai/deployment_state.offline.json new file mode 100644 index 00000000..78405ac8 --- /dev/null +++ b/tests/fixtures/ai/deployment_state.offline.json @@ -0,0 +1,45 @@ +{ + "agent": { + "capabilities": [], + "features": { + "backup": false, + "compose": false, + "kata_runtime": false, + "pipes": false, + "proxy_credentials_vault": false + }, + "online": false, + "status": "offline" + }, + "apps": [ + { + "code": "upload", + "config_hash": "hash-upload-v4", + "config_version": 4, + "deployment_id": 77, + "enabled": true, + "image": "optimum/syncopia-upload:latest", + "needs_vault_sync": true, + "vault_sync_version": 3 + } + ], + "deployment": { + "created_at": "2026-05-14T09:00:00Z", + "deployment_hash": "deployment_120254c6-598e-47a1-83ca-690840edd906", + "id": 77, + "project_id": 12, + "runtime": "runc", + "status": "degraded", + "updated_at": "2026-05-14T09:05:00Z", + "user_id": "user_123" + }, + "drift": { + "config_sync_pending": true + }, + "runtime": { + "compose_env_file": ".env", + "compose_file": "/home/trydirect/project/docker-compose.yml", + "env_file": "/home/trydirect/project/.env" + }, + "schema_version": "v1alpha1" +} diff --git a/tests/fixtures/ai/deployment_state.online.json b/tests/fixtures/ai/deployment_state.online.json new file mode 100644 index 00000000..54452c95 --- /dev/null +++ b/tests/fixtures/ai/deployment_state.online.json @@ -0,0 +1,55 @@ +{ + "agent": { + "agent_id": "36cf6fd2-6d76-4faf-9310-8f264c28fdb0", + "capabilities": [ + "docker", + "compose", + "logs", + "npm_credential_source=vault" + ], + "features": { + "backup": false, + "compose": true, + "kata_runtime": false, + "pipes": false, + "proxy_credentials_vault": true + }, + "last_heartbeat": "2026-05-14T09:06:00Z", + "online": true, + "status": "online", + "version": "0.42.0" + }, + "apps": [ + { + "code": "upload", + "config_hash": "hash-upload-v3", + "config_version": 3, + "deployment_id": 77, + "enabled": true, + "image": "optimum/syncopia-upload:latest", + "needs_vault_sync": false, + "vault_sync_version": 3 + } + ], + "deployment": { + "created_at": "2026-05-14T09:00:00Z", + "deployment_hash": "deployment_120254c6-598e-47a1-83ca-690840edd906", + "id": 77, + "last_command_status": "completed", + "project_id": 12, + "runtime": "runc", + "status": "deployed", + "status_message": "Deployment complete", + "updated_at": "2026-05-14T09:05:00Z", + "user_id": "user_123" + }, + "drift": { + "config_sync_pending": false + }, + "runtime": { + "compose_env_file": ".env", + "compose_file": "/home/trydirect/project/docker-compose.yml", + "env_file": "/home/trydirect/project/.env" + }, + "schema_version": "v1alpha1" +} diff --git a/tests/fixtures/pipe-contract/activate_pipe.adapter.command.json b/tests/fixtures/pipe-contract/activate_pipe.adapter.command.json new file mode 100644 index 00000000..17eba211 --- /dev/null +++ b/tests/fixtures/pipe-contract/activate_pipe.adapter.command.json @@ -0,0 +1,30 @@ +{ + "pipe_instance_id": "pipe-adapter-1", + "source_adapter": { + "code": "imap", + "role": "source", + "config": { + "host": "imap.example.com", + "port": 993, + "mailbox": "INBOX", + "username": "alerts@example.com", + "password": "example-password" + } + }, + "target_adapter": { + "code": "smtp", + "role": "target", + "config": { + "host": "smtp.example.com", + "port": 587, + "username": "mailer@example.com", + "password": "example-password", + "from": "alerts@example.com" + } + }, + "field_mapping": { + "subject": "$.subject", + "body.text": "$.body.text" + }, + "trigger_type": "poll" +} diff --git a/tests/fixtures/pipe-contract/trigger_pipe.adapter.command.json b/tests/fixtures/pipe-contract/trigger_pipe.adapter.command.json new file mode 100644 index 00000000..3697cda9 --- /dev/null +++ b/tests/fixtures/pipe-contract/trigger_pipe.adapter.command.json @@ -0,0 +1,39 @@ +{ + "pipe_instance_id": "pipe-adapter-1", + "input_data": { + "subject": "Incident opened", + "body": { + "text": "CPU usage exceeded threshold" + }, + "from": [ + { + "email": "alerts@example.com" + } + ], + "to": [ + { + "email": "ops@example.com" + } + ] + }, + "source_adapter": { + "code": "imap", + "role": "source", + "config": { + "mailbox": "INBOX" + } + }, + "target_adapter": { + "code": "smtp", + "role": "target", + "config": { + "host": "smtp.example.com", + "from": "alerts@example.com" + } + }, + "field_mapping": { + "subject": "$.subject", + "body": "$.body.text" + }, + "trigger_type": "manual" +} diff --git a/tests/fixtures/pipe-contract/trigger_pipe.smtp_adapter.report.json b/tests/fixtures/pipe-contract/trigger_pipe.smtp_adapter.report.json new file mode 100644 index 00000000..aa05739b --- /dev/null +++ b/tests/fixtures/pipe-contract/trigger_pipe.smtp_adapter.report.json @@ -0,0 +1,44 @@ +{ + "type": "trigger_pipe", + "deployment_hash": "dep-123", + "pipe_instance_id": "pipe-adapter-1", + "success": true, + "source_data": { + "subject": "Incident opened", + "body": { + "text": "CPU usage exceeded threshold" + } + }, + "mapped_data": { + "subject": "Incident opened", + "body_text": "CPU usage exceeded threshold" + }, + "target_response": { + "transport": "smtp", + "adapter": "smtp", + "status": null, + "delivered": true, + "body": { + "host": "smtp.example.com", + "port": 587, + "tls": true, + "subject": "Incident opened", + "to": [ + "ops@example.com" + ], + "from": "alerts@example.com", + "message_id": "msg-123", + "accepted_recipients": 1 + } + }, + "triggered_at": "2026-05-22T08:00:00Z", + "trigger_type": "manual", + "lifecycle": { + "state": "active", + "activated_at": "2026-05-22T07:59:00Z", + "last_triggered_at": "2026-05-22T08:00:00Z", + "last_error": null, + "trigger_count": 1, + "last_updated_at": "2026-05-22T08:00:00Z" + } +} diff --git a/tests/health_check.rs b/tests/health_check.rs index c2b7b440..0ec8e046 100644 --- a/tests/health_check.rs +++ b/tests/health_check.rs @@ -11,7 +11,7 @@ async fn health_check_works() { let client = reqwest::Client::new(); let response = client - .get(&format!("{}/health_check", &app.address)) + .get(format!("{}/health_check", &app.address)) .send() .await .expect("Failed to execute request."); diff --git a/tests/local_pipe_contract.rs b/tests/local_pipe_contract.rs new file mode 100644 index 00000000..28dbb9df --- /dev/null +++ b/tests/local_pipe_contract.rs @@ -0,0 +1,117 @@ +use std::fs; +use std::path::PathBuf; + +use serde_json::{json, Value}; +use stacker::cli::local_pipe_store::LocalPipeDocument; + +fn contract_path(filename: &str) -> PathBuf { + let shared = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../config/shared-fixtures/pipe-contract") + .join(filename); + if shared.exists() { + return shared; + } + + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/contracts/pipe-contract") + .join(filename) +} + +fn load_contract(filename: &str) -> Value { + let path = contract_path(filename); + let content = fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read contract {}: {}", path.display(), err)); + serde_json::from_str(&content) + .unwrap_or_else(|err| panic!("failed to parse contract {}: {}", path.display(), err)) +} + +#[test] +fn local_pipe_fixture_round_trips_and_stays_adapter_only() { + let fixture = load_contract("local_pipe.smtp_adapter.v1.json"); + let pipe: LocalPipeDocument = + serde_json::from_value(fixture.clone()).expect("fixture should deserialize"); + + pipe.validate().expect("fixture should validate"); + assert_eq!(pipe.target.selector, "smtp"); + assert!(pipe.instance.target_adapter.is_some()); + assert!(pipe.instance.target_container.is_none()); + assert!(pipe.instance.target_url.is_none()); + assert_eq!( + pipe.instance + .target_adapter + .as_ref() + .and_then(|adapter| adapter.config.as_ref()) + .and_then(|config| config.get("host")) + .and_then(|value| value.as_str()), + Some("smtp") + ); +} + +#[test] +fn local_pipe_secret_ref_fixture_validates_without_plaintext_secrets() { + let fixture = load_contract("local_pipe.smtp_adapter.secret_ref.v1.json"); + let pipe: LocalPipeDocument = + serde_json::from_value(fixture).expect("secret-ref fixture should deserialize"); + + pipe.validate() + .expect("secret-ref fixture should validate without plaintext secrets"); + let password = pipe + .instance + .target_adapter + .as_ref() + .and_then(|adapter| adapter.config.as_ref()) + .and_then(|config| config.get("password")) + .expect("password config should exist"); + assert_eq!( + password["secret_ref"]["name"].as_str(), + Some("SMTP_PASSWORD") + ); +} + +#[test] +fn promotion_request_fixture_matches_local_pipe_projection() { + let pipe_fixture = load_contract("local_pipe.smtp_adapter.v1.json"); + let expected = load_contract("remote_pipe.promote.smtp_adapter.request.json"); + let pipe: LocalPipeDocument = + serde_json::from_value(pipe_fixture).expect("promotion source should deserialize"); + + let actual = json!({ + "template_request": pipe.to_template_request(), + "instance_request": pipe.to_instance_request( + "dep-20260522".to_string(), + "tpl-remote-smtp".to_string() + ) + }); + + assert_eq!(actual, expected); + assert!(actual["instance_request"].get("target_container").is_none()); + assert!(actual["instance_request"].get("target_url").is_none()); +} + +#[test] +fn plaintext_secret_fixture_stays_rejected() { + let fixture = load_contract("remote_pipe.secret_ref.rejected_plaintext.json"); + let expected = fixture["expected_error_contains"] + .as_str() + .expect("negative fixture must declare expected error"); + let pipe: LocalPipeDocument = + serde_json::from_value(fixture["pipe"].clone()).expect("negative pipe should deserialize"); + + let err = pipe + .validate() + .expect_err("plaintext secret fixture must fail"); + assert!(err.to_string().contains(expected)); +} + +#[test] +fn smtp_trigger_report_fixture_has_expected_delivery_shape() { + let report = load_contract("remote_pipe.trigger.smtp_adapter.report.json"); + + assert_eq!(report["type"].as_str(), Some("trigger_pipe")); + assert_eq!(report["target_response"]["adapter"].as_str(), Some("smtp")); + assert_eq!(report["target_response"]["delivered"].as_bool(), Some(true)); + assert_eq!( + report["target_response"]["body"]["accepted_recipients"].as_i64(), + Some(1) + ); +} diff --git a/tests/marketplace_admin_review.rs b/tests/marketplace_admin_review.rs index 95e18984..98fdd534 100644 --- a/tests/marketplace_admin_review.rs +++ b/tests/marketplace_admin_review.rs @@ -1,3 +1,5 @@ +#![allow(clippy::await_holding_lock)] + mod common; use chrono::{Duration, Utc}; @@ -81,10 +83,7 @@ fn env_lock() -> &'static Mutex<()> { LOCK.get_or_init(|| Mutex::new(())) } -fn find_marketplace_sync_payload<'a>( - requests: &'a [Request], - expected_action: &str, -) -> Option { +fn find_marketplace_sync_payload(requests: &[Request], expected_action: &str) -> Option { requests.iter().find_map(|request| { if request.url.path() != "/marketplace/sync" { return None; diff --git a/tests/marketplace_create_template.rs b/tests/marketplace_create_template.rs index 8450a876..e7ca3175 100644 --- a/tests/marketplace_create_template.rs +++ b/tests/marketplace_create_template.rs @@ -1,3 +1,5 @@ +#![allow(clippy::await_holding_lock)] + mod common; use reqwest::{Client, Response, StatusCode}; diff --git a/tests/marketplace_creator_vendor_profile_status.rs b/tests/marketplace_creator_vendor_profile_status.rs index ad4845c9..d6bdda22 100644 --- a/tests/marketplace_creator_vendor_profile_status.rs +++ b/tests/marketplace_creator_vendor_profile_status.rs @@ -62,12 +62,9 @@ async fn creator_vendor_profile_status_returns_default_profile_when_missing() { assert_eq!(template_id, body["item"]["template_id"]); assert_eq!("test_user_id", body["item"]["creator_user_id"]); - assert_eq!( - false, - body["item"]["payout_ready"] - .as_bool() - .expect("payout_ready should be bool") - ); + assert!(!body["item"]["payout_ready"] + .as_bool() + .expect("payout_ready should be bool")); let vendor_profile = &body["item"]["vendor_profile"]; assert_eq!("unverified", vendor_profile["verification_status"]); diff --git a/tests/marketplace_deploy_complete.rs b/tests/marketplace_deploy_complete.rs index 3db69938..773d7b06 100644 --- a/tests/marketplace_deploy_complete.rs +++ b/tests/marketplace_deploy_complete.rs @@ -1,3 +1,5 @@ +#![allow(clippy::await_holding_lock)] + mod common; use reqwest::{Client, StatusCode}; diff --git a/tests/mcp_integration.rs b/tests/mcp_integration.rs index 484fc8c3..cf1a81a0 100644 --- a/tests/mcp_integration.rs +++ b/tests/mcp_integration.rs @@ -40,7 +40,7 @@ async fn get_auth_token(config: &IntegrationConfig) -> Result { let client = reqwest::Client::new(); let response = client - .post(&format!("{}/oauth_server/token", config.user_service_url)) + .post(format!("{}/oauth_server/token", config.user_service_url)) .form(&[ ("grant_type", "password"), ("username", &config.test_user_email), @@ -87,7 +87,7 @@ async fn test_get_user_profile() { let client = reqwest::Client::new(); let response = client - .get(&format!("{}/auth/me", config.user_service_url)) + .get(format!("{}/auth/me", config.user_service_url)) .header("Authorization", format!("Bearer {}", token)) .send() .await @@ -124,7 +124,7 @@ async fn test_get_subscription_plan() { let client = reqwest::Client::new(); let response = client - .get(&format!("{}/oauth_server/api/me", config.user_service_url)) + .get(format!("{}/oauth_server/api/me", config.user_service_url)) .header("Authorization", format!("Bearer {}", token)) .send() .await @@ -163,7 +163,7 @@ async fn test_list_installations() { let client = reqwest::Client::new(); let response = client - .get(&format!("{}/installations", config.user_service_url)) + .get(format!("{}/installations", config.user_service_url)) .header("Authorization", format!("Bearer {}", token)) .send() .await @@ -229,7 +229,7 @@ async fn test_get_installation_details() { let client = reqwest::Client::new(); let response = client - .get(&format!( + .get(format!( "{}/installations/{}", config.user_service_url, deployment_id )) @@ -267,7 +267,7 @@ async fn test_search_applications() { let client = reqwest::Client::new(); let response = client - .get(&format!("{}/applications", config.user_service_url)) + .get(format!("{}/applications", config.user_service_url)) .header("Authorization", format!("Bearer {}", token)) .send() .await @@ -328,7 +328,7 @@ async fn test_mcp_workflow_stack_configuration() { // Step 1: Get user profile println!("Step 1: get_user_profile"); let profile_resp = client - .get(&format!("{}/auth/me", config.user_service_url)) + .get(format!("{}/auth/me", config.user_service_url)) .header("Authorization", format!("Bearer {}", token)) .send() .await @@ -347,7 +347,7 @@ async fn test_mcp_workflow_stack_configuration() { // Step 2: Get subscription plan println!("Step 2: get_subscription_plan"); let plan_resp = client - .get(&format!("{}/oauth_server/api/me", config.user_service_url)) + .get(format!("{}/oauth_server/api/me", config.user_service_url)) .header("Authorization", format!("Bearer {}", token)) .send() .await @@ -369,7 +369,7 @@ async fn test_mcp_workflow_stack_configuration() { // Step 3: List installations (as proxy for checking deployment limits) println!("Step 3: list_installations"); let installs_resp = client - .get(&format!("{}/installations", config.user_service_url)) + .get(format!("{}/installations", config.user_service_url)) .header("Authorization", format!("Bearer {}", token)) .send() .await @@ -383,7 +383,7 @@ async fn test_mcp_workflow_stack_configuration() { // Step 4: Search applications println!("Step 4: search_applications"); let apps_resp = client - .get(&format!("{}/applications", config.user_service_url)) + .get(format!("{}/applications", config.user_service_url)) .header("Authorization", format!("Bearer {}", token)) .send() .await @@ -478,7 +478,7 @@ async fn test_confirmation_flow_restart_container() { //! 3. AI calls restart_container with requires_confirmation: true (execute) //! 4. Returns result - let stacker_url = + let _stacker_url = env::var("STACKER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); println!("\n=== Confirmation Flow Test: restart_container ===\n"); diff --git a/tests/middleware_client.rs b/tests/middleware_client.rs index d4f63a38..8fad999b 100644 --- a/tests/middleware_client.rs +++ b/tests/middleware_client.rs @@ -15,7 +15,7 @@ async fn middleware_client_works() { let client = reqwest::Client::new(); // client let response = client - .get(&format!("{}/health_check", &app.address)) + .get(format!("{}/health_check", &app.address)) .send() .await .expect("Failed to execute request."); diff --git a/tests/middleware_trydirect.rs b/tests/middleware_trydirect.rs index 3132ae54..0dacb938 100644 --- a/tests/middleware_trydirect.rs +++ b/tests/middleware_trydirect.rs @@ -4,7 +4,7 @@ use wiremock::MockServer; #[tokio::test] async fn middleware_trydirect_works() { // 1. Arrange - let trydirect_auth_server = MockServer::start().await; + let _trydirect_auth_server = MockServer::start().await; // 2. Act // 3. Assert @@ -18,7 +18,7 @@ async fn middleware_trydirect_works() { let client = reqwest::Client::new(); // client let response = client - .get(&format!("{}/health_check", &app.address)) + .get(format!("{}/health_check", &app.address)) .send() .await .expect("Failed to execute request."); diff --git a/tests/model_project.rs b/tests/model_project.rs index 22e190d2..3435d439 100644 --- a/tests/model_project.rs +++ b/tests/model_project.rs @@ -1,7 +1,5 @@ -use stacker::forms::project::App; use stacker::forms::project::DockerImage; use stacker::forms::project::ProjectForm; -use std::collections::HashMap; // Unit Test @@ -30,7 +28,7 @@ fn test_deserialize_project() { env!("CARGO_MANIFEST_DIR"), "/tests/mock_data/custom.json" )); - let form = serde_json::from_str::(&body_str).unwrap(); + let form = serde_json::from_str::(body_str).unwrap(); println!("{:?}", form); // @todo assert required data diff --git a/tests/remote_secrets.rs b/tests/remote_secrets.rs index 53d8b0a2..28c639fe 100644 --- a/tests/remote_secrets.rs +++ b/tests/remote_secrets.rs @@ -1,12 +1,19 @@ mod common; +use actix_web::web; use reqwest::StatusCode; use serde_json::{json, Value}; use stacker::configuration::get_configuration; use stacker::db; +use stacker::mcp::protocol::ToolContent; +use stacker::mcp::tools::{ + GetAppConfigTool, GetAppEnvVarsTool, GetRemoteServiceSecretTool, SetAppEnvVarTool, +}; +use stacker::mcp::{ToolContext, ToolHandler}; use stacker::models::ProjectApp; -use stacker::services::{ConfigRenderer, VaultService}; -use wiremock::matchers::{method, path_regex}; +use stacker::services::{runtime_env_contract_response, ConfigRenderer, VaultService}; +use std::sync::Arc; +use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; struct TwoUserVaultApp { @@ -57,6 +64,39 @@ fn server_secret_path_regex(user_id: &str, server_id: i32, name: &str) -> String ) } +fn status_panel_npm_credentials_path(server_id: i32) -> String { + format!( + "/v1/secret/debug/status_panel/hosts/{}/npm_credentials", + server_id + ) +} + +fn tool_context(pool: &sqlx::PgPool) -> ToolContext { + ToolContext { + user: Arc::new(stacker::models::User { + id: common::USER_A_ID.to_string(), + first_name: "Test".to_string(), + last_name: "User".to_string(), + email: common::USER_A_EMAIL.to_string(), + role: "group_user".to_string(), + email_confirmed: true, + mfa_verified: true, + access_token: None, + }), + pg_pool: pool.clone(), + settings: web::Data::new(stacker::configuration::Settings::default()), + } +} + +fn tool_text_json(content: ToolContent) -> Value { + match content { + ToolContent::Text { text } => { + serde_json::from_str(&text).expect("tool response should be valid json") + } + ToolContent::Image { .. } => panic!("expected text tool response"), + } +} + #[tokio::test] async fn test_service_secret_crud_returns_metadata_only_and_uses_vault_v1() { let Some(app) = common::spawn_app_with_vault().await else { @@ -103,6 +143,7 @@ async fn test_service_secret_crud_returns_metadata_only_and_uses_vault_v1() { assert_eq!(put_body["item"]["scope"], "service"); assert_eq!(put_body["item"]["app_code"], project_app.code); assert_eq!(put_body["item"]["source"], "vault"); + assert_eq!(put_body["item"]["secure"], true); assert!(put_body["item"].get("value").is_none()); let get_response = client @@ -118,6 +159,7 @@ async fn test_service_secret_crud_returns_metadata_only_and_uses_vault_v1() { assert_eq!(get_response.status(), StatusCode::OK); let get_body: Value = get_response.json().await.unwrap(); assert_eq!(get_body["item"]["name"], secret_name); + assert_eq!(get_body["item"]["secure"], true); assert!(get_body["item"].get("value").is_none()); let list_response = client @@ -134,6 +176,7 @@ async fn test_service_secret_crud_returns_metadata_only_and_uses_vault_v1() { let list_body: Value = list_response.json().await.unwrap(); assert_eq!(list_body["list"].as_array().unwrap().len(), 1); assert_eq!(list_body["list"][0]["name"], secret_name); + assert_eq!(list_body["list"][0]["secure"], true); assert!(list_body["list"][0].get("value").is_none()); let delete_response = client @@ -281,6 +324,110 @@ async fn test_server_secret_crud_returns_metadata_only_and_uses_vault_v1() { .all(|request| !request.url.path().contains("/metadata/"))); } +#[tokio::test] +async fn test_server_npm_credentials_use_status_panel_host_path() { + let mut configuration = get_configuration().expect("Failed to get configuration"); + let vault_server = MockServer::start().await; + + configuration.vault.address = vault_server.uri(); + configuration.vault.token = "test-vault-token".to_string(); + configuration.vault.api_prefix = "v1".to_string(); + configuration.vault.agent_path_prefix = "secret/debug/status_panel".to_string(); + configuration.vault.ssh_key_path_prefix = Some("users".to_string()); + configuration.connectors.install_service = + Some(stacker::connectors::InstallServiceConfig { enabled: false }); + + let Some(app) = common::spawn_app_with_test_auth_configuration(configuration).await else { + return; + }; + + let project_id = common::create_test_project(&app.db_pool, common::USER_A_ID).await; + let server_id = + common::create_test_server(&app.db_pool, common::USER_A_ID, project_id, "none", None).await; + + Mock::given(method("POST")) + .and(path(status_panel_npm_credentials_path(server_id))) + .respond_with(ResponseTemplate::new(200)) + .mount(&vault_server) + .await; + + Mock::given(method("DELETE")) + .and(path(status_panel_npm_credentials_path(server_id))) + .respond_with(ResponseTemplate::new(204)) + .mount(&vault_server) + .await; + + let payload = json!({ + "schema_version": 1, + "host": "http://nginx-proxy-manager:81", + "email": "admin@example.com", + "password": "secret", + "auth_mode": "email_password" + }); + + let client = reqwest::Client::new(); + let put_response = client + .put(format!( + "{}/server/{}/secrets/npm_credentials", + app.address, server_id + )) + .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) + .json(&json!({ "value": payload.to_string() })) + .send() + .await + .expect("server npm_credentials PUT failed"); + + assert_eq!(put_response.status(), StatusCode::OK); + let put_body: Value = put_response.json().await.unwrap(); + assert_eq!(put_body["item"]["name"], "npm_credentials"); + assert_eq!(put_body["item"]["scope"], "server"); + assert_eq!(put_body["item"]["server_id"], server_id); + assert_eq!(put_body["item"]["source"], "vault"); + assert!(put_body["item"].get("value").is_none()); + + let get_response = client + .get(format!( + "{}/server/{}/secrets/npm_credentials", + app.address, server_id + )) + .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) + .send() + .await + .expect("server npm_credentials GET failed"); + + assert_eq!(get_response.status(), StatusCode::OK); + let get_body: Value = get_response.json().await.unwrap(); + assert_eq!(get_body["item"]["name"], "npm_credentials"); + + let delete_response = client + .delete(format!( + "{}/server/{}/secrets/npm_credentials", + app.address, server_id + )) + .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) + .send() + .await + .expect("server npm_credentials DELETE failed"); + + assert_eq!(delete_response.status(), StatusCode::OK); + + let requests = vault_server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].method.to_string(), "POST"); + assert_eq!( + requests[0].url.path(), + status_panel_npm_credentials_path(server_id) + ); + let request_body: Value = + serde_json::from_slice(&requests[0].body).expect("npm_credentials body should be json"); + assert_eq!(request_body, json!({ "data": payload })); + assert_eq!(requests[1].method.to_string(), "DELETE"); + assert_eq!( + requests[1].url.path(), + status_panel_npm_credentials_path(server_id) + ); +} + #[tokio::test] async fn test_service_secret_idor_returns_404_without_touching_vault() { let Some(app) = spawn_two_user_app_with_vault().await else { @@ -564,6 +711,10 @@ async fn test_get_env_vars_includes_remote_secret_placeholders() { .expect("response body should be valid json"); assert_eq!(body["item"]["variables"]["VISIBLE_KEY"], "plain-value"); assert_eq!(body["item"]["variables"]["S3_SECRET"], "[REDACTED]"); + assert_eq!( + body["item"]["runtime_env_contract"], + serde_json::to_value(runtime_env_contract_response()).unwrap() + ); } #[tokio::test] @@ -619,6 +770,10 @@ async fn test_get_app_redacts_plain_and_remote_secret_values() { assert_eq!(body["item"]["environment"]["VISIBLE_KEY"], "plain-value"); assert_eq!(body["item"]["environment"]["LOCAL_PASSWORD"], "[REDACTED]"); assert_eq!(body["item"]["environment"]["S3_SECRET"], "[REDACTED]"); + assert_eq!( + body["item"]["runtime_env_contract"], + serde_json::to_value(runtime_env_contract_response()).unwrap() + ); } #[tokio::test] @@ -724,3 +879,229 @@ async fn test_update_env_vars_rejects_remote_secret_name_collision() { assert_eq!(response.status(), StatusCode::CONFLICT); } + +#[tokio::test] +async fn test_mcp_get_app_env_vars_redacts_remote_service_secret_values() { + let Some(app) = common::spawn_app_with_vault().await else { + return; + }; + + let project_id = common::create_test_project(&app.db_pool, common::USER_A_ID).await; + let mut project_app = create_test_project_app(&app.db_pool, project_id, "web").await; + project_app.environment = Some(json!({ + "VISIBLE_KEY": "plain-value", + "LOCAL_PASSWORD": "db-secret" + })); + db::project_app::update(&app.db_pool, &project_app) + .await + .expect("project app update failed"); + + let vault_path = format!( + "agent/users/{}/projects/{}/apps/{}/secrets/S3_SECRET", + common::USER_A_ID, + project_id, + project_app.code + ); + db::remote_secret::upsert_service_secret( + &app.db_pool, + common::USER_A_ID, + project_id, + &project_app.code, + "S3_SECRET", + &vault_path, + common::USER_A_ID, + "synced", + ) + .await + .expect("service secret metadata insert failed"); + + let context = tool_context(&app.db_pool); + let result = GetAppEnvVarsTool + .execute( + json!({ + "project_id": project_id, + "app_code": project_app.code + }), + &context, + ) + .await + .expect("mcp get_app_env_vars should succeed"); + + let body = tool_text_json(result); + assert_eq!(body["environment_variables"]["VISIBLE_KEY"], "plain-value"); + assert_eq!( + body["environment_variables"]["LOCAL_PASSWORD"], + "[REDACTED]" + ); + assert_eq!(body["environment_variables"]["S3_SECRET"], "[REDACTED]"); + assert_eq!(body["count"], 3); + assert_eq!( + body["runtime_env_contract"], + serde_json::to_value(runtime_env_contract_response()).unwrap() + ); +} + +#[tokio::test] +async fn test_mcp_get_app_config_redacts_remote_service_secret_values() { + let Some(app) = common::spawn_app_with_vault().await else { + return; + }; + + let project_id = common::create_test_project(&app.db_pool, common::USER_A_ID).await; + let mut project_app = create_test_project_app(&app.db_pool, project_id, "web").await; + project_app.environment = Some(json!({ + "VISIBLE_KEY": "plain-value", + "LOCAL_PASSWORD": "db-secret" + })); + db::project_app::update(&app.db_pool, &project_app) + .await + .expect("project app update failed"); + + let vault_path = format!( + "agent/users/{}/projects/{}/apps/{}/secrets/S3_SECRET", + common::USER_A_ID, + project_id, + project_app.code + ); + db::remote_secret::upsert_service_secret( + &app.db_pool, + common::USER_A_ID, + project_id, + &project_app.code, + "S3_SECRET", + &vault_path, + common::USER_A_ID, + "synced", + ) + .await + .expect("service secret metadata insert failed"); + + let context = tool_context(&app.db_pool); + let result = GetAppConfigTool + .execute( + json!({ + "project_id": project_id, + "app_code": project_app.code + }), + &context, + ) + .await + .expect("mcp get_app_config should succeed"); + + let body = tool_text_json(result); + assert_eq!(body["environment_variables"]["VISIBLE_KEY"], "plain-value"); + assert_eq!( + body["environment_variables"]["LOCAL_PASSWORD"], + "[REDACTED]" + ); + assert_eq!(body["environment_variables"]["S3_SECRET"], "[REDACTED]"); + assert_eq!( + body["runtime_env_contract"], + serde_json::to_value(runtime_env_contract_response()).unwrap() + ); +} + +#[tokio::test] +async fn test_mcp_set_app_env_var_rejects_remote_secret_name_collision() { + let Some(app) = common::spawn_app_with_vault().await else { + return; + }; + + let project_id = common::create_test_project(&app.db_pool, common::USER_A_ID).await; + let mut project_app = create_test_project_app(&app.db_pool, project_id, "web").await; + project_app.environment = Some(json!({ + "VISIBLE_KEY": "plain-value" + })); + db::project_app::update(&app.db_pool, &project_app) + .await + .expect("project app update failed"); + + let vault_path = format!( + "agent/users/{}/projects/{}/apps/{}/secrets/S3_SECRET", + common::USER_A_ID, + project_id, + project_app.code + ); + db::remote_secret::upsert_service_secret( + &app.db_pool, + common::USER_A_ID, + project_id, + &project_app.code, + "S3_SECRET", + &vault_path, + common::USER_A_ID, + "synced", + ) + .await + .expect("service secret metadata insert failed"); + + let context = tool_context(&app.db_pool); + let error = SetAppEnvVarTool + .execute( + json!({ + "project_id": project_id, + "app_code": project_app.code, + "name": "S3_SECRET", + "value": "plain-value" + }), + &context, + ) + .await + .expect_err("mcp set_app_env_var should reject remote secret collision"); + + assert!(error.contains("managed as a remote service secret")); + + let updated = db::project_app::fetch_by_project_and_code(&app.db_pool, project_id, "web") + .await + .expect("app fetch failed") + .expect("app should exist"); + let environment = updated.environment.expect("app environment should exist"); + assert_eq!(environment["VISIBLE_KEY"], "plain-value"); + assert!(environment.get("S3_SECRET").is_none()); +} + +#[tokio::test] +async fn test_mcp_get_remote_service_secret_marks_metadata_secure() { + let Some(app) = common::spawn_app_with_vault().await else { + return; + }; + + let project_id = common::create_test_project(&app.db_pool, common::USER_A_ID).await; + let project_app = create_test_project_app(&app.db_pool, project_id, "web").await; + let vault_path = format!( + "agent/users/{}/projects/{}/apps/{}/secrets/S3_SECRET", + common::USER_A_ID, + project_id, + project_app.code + ); + db::remote_secret::upsert_service_secret( + &app.db_pool, + common::USER_A_ID, + project_id, + &project_app.code, + "S3_SECRET", + &vault_path, + common::USER_A_ID, + "synced", + ) + .await + .expect("service secret metadata insert failed"); + + let context = tool_context(&app.db_pool); + let result = GetRemoteServiceSecretTool + .execute( + json!({ + "project_id": project_id, + "target_code": project_app.code, + "name": "S3_SECRET" + }), + &context, + ) + .await + .expect("mcp get_remote_service_secret should succeed"); + + let body = tool_text_json(result); + assert_eq!(body["secret"]["name"], "S3_SECRET"); + assert_eq!(body["secret"]["source"], "vault"); + assert_eq!(body["secret"]["secure"], true); +} diff --git a/tests/runtime_env_contract.rs b/tests/runtime_env_contract.rs new file mode 100644 index 00000000..dee9ba27 --- /dev/null +++ b/tests/runtime_env_contract.rs @@ -0,0 +1,52 @@ +use serde_json::Value; +use stacker::services::runtime_env_contract_response; + +/// Load the shared runtime env contract. +/// +/// Note: This contract is mirrored from +/// ../config/shared-fixtures/runtime-env-contract.json +/// to stacker/tests/contracts/runtime-env-contract.contract.json +/// for reliable CI access. +fn load_contract() -> Value { + let contract_json = include_str!("contracts/runtime-env-contract.contract.json"); + serde_json::from_str(contract_json).expect("contract JSON should be valid") +} + +#[test] +fn runtime_env_contract_has_expected_metadata() { + let contract = load_contract(); + + assert_eq!(contract["title"].as_str(), Some("runtime-env-contract")); + assert_eq!(contract["_owner"].as_str(), Some("trydirect/config")); + assert_eq!(contract["version"].as_str(), Some("v1")); + assert_eq!(contract["order"].as_str(), Some("lowest_to_highest")); +} + +#[test] +fn stacker_runtime_env_contract_matches_shared_fixture() { + let contract = load_contract(); + let exported = serde_json::to_value(runtime_env_contract_response()) + .expect("runtime env contract should serialize"); + + assert_eq!(exported["version"], contract["version"]); + assert_eq!(exported["order"], contract["order"]); + assert_eq!(exported["layers"], contract["layers"]); +} + +#[test] +fn runtime_env_contract_inspection_fields_match_expected_outputs() { + let contract = load_contract(); + + assert_eq!( + contract["inspectionOutputs"]["runtimeEnvContractField"].as_str(), + Some("runtime_env_contract") + ); + assert_eq!( + contract["inspectionOutputs"]["remoteSecretMetadata"]["source"].as_str(), + Some("vault") + ); + assert_eq!( + contract["inspectionOutputs"]["remoteSecretMetadata"]["secure"].as_bool(), + Some(true) + ); +} diff --git a/tests/security_admin.rs b/tests/security_admin.rs index 6cabc318..ee68f2cc 100644 --- a/tests/security_admin.rs +++ b/tests/security_admin.rs @@ -5,7 +5,6 @@ use common::{USER_A_TOKEN, USER_B_TOKEN}; /// Admin endpoints (/admin/*) are protected by Casbin RBAC. /// Mock users have role "group_user" which has no admin policies. /// Requests should be denied with 403 Forbidden. - #[tokio::test] async fn test_admin_list_users_rejects_non_admin() { let Some(app) = common::spawn_app_two_users().await else { diff --git a/tests/security_agent.rs b/tests/security_agent.rs index be7404a6..51790975 100644 --- a/tests/security_agent.rs +++ b/tests/security_agent.rs @@ -32,6 +32,31 @@ async fn insert_test_command( cmd_id } +async fn insert_test_agent( + pool: &sqlx::PgPool, + deployment_hash: &str, + capabilities: serde_json::Value, +) { + sqlx::query( + r#" + INSERT INTO agents ( + deployment_hash, + capabilities, + status, + last_heartbeat, + created_at, + updated_at + ) + VALUES ($1, $2, 'online', NOW(), NOW(), NOW()) + "#, + ) + .bind(deployment_hash) + .bind(capabilities) + .execute(pool) + .await + .expect("Failed to insert test agent"); +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Enqueue — User B should NOT enqueue on User A's deployment // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -96,6 +121,162 @@ async fn test_owner_can_enqueue_on_own_deployment() { ); } +#[tokio::test] +async fn test_pipe_enqueue_rejects_agent_without_pipes_capability() { + let Some(app) = spawn_app_two_users().await else { + return; + }; + let client = reqwest::Client::new(); + + let project_id = create_test_project(&app.db_pool, USER_A_ID).await; + let _dep_id = + create_test_deployment(&app.db_pool, USER_A_ID, project_id, "dep-pipe-cap-001").await; + + insert_test_agent( + &app.db_pool, + "dep-pipe-cap-001", + serde_json::json!(["docker", "compose", "logs"]), + ) + .await; + + let resp = client + .post(format!("{}/api/v1/agent/commands/enqueue", &app.address)) + .header("Authorization", format!("Bearer {}", USER_A_TOKEN)) + .json(&serde_json::json!({ + "deployment_hash": "dep-pipe-cap-001", + "command_type": "activate_pipe", + "parameters": { + "pipe_instance_id": "11111111-1111-1111-1111-111111111111", + "target_url": "https://example.com/hook", + "trigger_type": "webhook" + } + })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!( + resp.status(), + 400, + "Pipe enqueue should be rejected when agent lacks pipes capability. Got: {}", + resp.status() + ); + + let body: serde_json::Value = resp.json().await.expect("Response should be JSON"); + let body_text = body.to_string(); + assert!( + body_text.contains("does not support pipe commands"), + "Expected pipe capability error, got: {}", + body_text + ); +} + +#[tokio::test] +async fn test_capabilities_rejects_unauthenticated() { + let Some(app) = spawn_app_two_users().await else { + return; + }; + let client = reqwest::Client::new(); + + let project_id = create_test_project(&app.db_pool, USER_A_ID).await; + let _dep_id = create_test_deployment(&app.db_pool, USER_A_ID, project_id, "dep-cap-anon").await; + insert_test_agent( + &app.db_pool, + "dep-cap-anon", + serde_json::json!(["docker", "compose", "logs"]), + ) + .await; + + let resp = client + .get(format!( + "{}/api/v1/deployments/dep-cap-anon/capabilities", + &app.address + )) + .send() + .await + .expect("Failed to send request"); + + assert_eq!( + resp.status(), + 403, + "Capabilities should not be visible anonymously. Got: {}", + resp.status() + ); +} + +#[tokio::test] +async fn test_capabilities_rejects_other_user() { + let Some(app) = spawn_app_two_users().await else { + return; + }; + let client = reqwest::Client::new(); + + let project_id = create_test_project(&app.db_pool, USER_A_ID).await; + let _dep_id = + create_test_deployment(&app.db_pool, USER_A_ID, project_id, "dep-cap-owner").await; + insert_test_agent( + &app.db_pool, + "dep-cap-owner", + serde_json::json!(["docker", "compose", "logs"]), + ) + .await; + + let resp = client + .get(format!( + "{}/api/v1/deployments/dep-cap-owner/capabilities", + &app.address + )) + .header("Authorization", format!("Bearer {}", USER_B_TOKEN)) + .send() + .await + .expect("Failed to send request"); + + assert_eq!( + resp.status(), + 404, + "Capabilities for another user's deployment should be hidden. Got: {}", + resp.status() + ); +} + +#[tokio::test] +async fn test_owner_can_read_deployment_capabilities() { + let Some(app) = spawn_app_two_users().await else { + return; + }; + let client = reqwest::Client::new(); + + let project_id = create_test_project(&app.db_pool, USER_A_ID).await; + let _dep_id = create_test_deployment(&app.db_pool, USER_A_ID, project_id, "dep-cap-own").await; + insert_test_agent( + &app.db_pool, + "dep-cap-own", + serde_json::json!(["docker", "compose", "logs", "pipes"]), + ) + .await; + + let resp = client + .get(format!( + "{}/api/v1/deployments/dep-cap-own/capabilities", + &app.address + )) + .header("Authorization", format!("Bearer {}", USER_A_TOKEN)) + .send() + .await + .expect("Failed to send request"); + + assert_eq!( + resp.status(), + 200, + "Owner should read deployment capabilities. Got: {}", + resp.status() + ); + + let body: serde_json::Value = resp.json().await.expect("Response should be JSON"); + assert_eq!(body["deployment_hash"], "dep-cap-own"); + assert_eq!(body["features"]["pipes"], true); +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Commands list — User B should NOT list User A's commands // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/tests/security_chat.rs b/tests/security_chat.rs index ef00eb4b..9879ebfa 100644 --- a/tests/security_chat.rs +++ b/tests/security_chat.rs @@ -5,7 +5,6 @@ use common::{USER_A_ID, USER_A_TOKEN, USER_B_TOKEN}; /// Chat endpoints use (user_id, project_id) as the lookup key. /// Isolation is enforced server-side: the handler always uses the authenticated /// user's ID, so User B cannot see or mutate User A's chat history. - const TEST_PROJECT_ID: i32 = 9999; async fn insert_chat(pool: &sqlx::PgPool, user_id: &str, project_id: i32) { diff --git a/tests/security_client.rs b/tests/security_client.rs index ea5acecc..e4af9a9d 100644 --- a/tests/security_client.rs +++ b/tests/security_client.rs @@ -5,7 +5,6 @@ use sqlx::Row; /// User A creates a client. User B tries to update/enable/disable → rejected (400). /// Verifies cross-user data isolation on client endpoints. - async fn insert_client(pool: &sqlx::PgPool, user_id: &str) -> i32 { let rec = sqlx::query( "INSERT INTO client (user_id, secret, created_at, updated_at) \ diff --git a/tests/security_cloud.rs b/tests/security_cloud.rs index cbb3ae5c..4baafca4 100644 --- a/tests/security_cloud.rs +++ b/tests/security_cloud.rs @@ -2,7 +2,6 @@ mod common; /// IDOR security tests for /cloud endpoints. /// Verify that User B cannot list, read, update, or delete User A's cloud credentials. - #[tokio::test] async fn test_list_clouds_only_returns_own() { let Some(app) = common::spawn_app_two_users().await else { @@ -19,7 +18,7 @@ async fn test_list_clouds_only_returns_own() { // User A lists → sees exactly 2 let resp = client - .get(&format!("{}/cloud", &app.address)) + .get(format!("{}/cloud", &app.address)) .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) .send() .await @@ -31,7 +30,7 @@ async fn test_list_clouds_only_returns_own() { // User B lists → sees exactly 1 let resp = client - .get(&format!("{}/cloud", &app.address)) + .get(format!("{}/cloud", &app.address)) .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) .send() .await @@ -53,7 +52,7 @@ async fn test_get_cloud_rejects_other_user() { // User B tries to GET User A's cloud → 404 let resp = client - .get(&format!("{}/cloud/{}", &app.address, cloud_id)) + .get(format!("{}/cloud/{}", &app.address, cloud_id)) .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) .send() .await @@ -76,7 +75,7 @@ async fn test_update_cloud_rejects_other_user() { // User B tries to PUT User A's cloud → 400 (bad_request = IDOR guard) let resp = client - .put(&format!("{}/cloud/{}", &app.address, cloud_id)) + .put(format!("{}/cloud/{}", &app.address, cloud_id)) .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) .header("Content-Type", "application/json") .body(r#"{"provider":"htz","cloud_token":"stolen","save_token":true}"#) @@ -101,7 +100,7 @@ async fn test_delete_cloud_rejects_other_user() { // User B tries to DELETE User A's cloud → 400 (bad_request = IDOR guard) let resp = client - .delete(&format!("{}/cloud/{}", &app.address, cloud_id)) + .delete(format!("{}/cloud/{}", &app.address, cloud_id)) .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) .send() .await @@ -124,7 +123,7 @@ async fn test_owner_can_access_own_cloud() { // User A GETs own cloud → 200 let resp = client - .get(&format!("{}/cloud/{}", &app.address, cloud_id)) + .get(format!("{}/cloud/{}", &app.address, cloud_id)) .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) .send() .await diff --git a/tests/security_deployment.rs b/tests/security_deployment.rs index e14e1734..bf370581 100644 --- a/tests/security_deployment.rs +++ b/tests/security_deployment.rs @@ -104,6 +104,59 @@ async fn test_get_deployment_by_hash_rejects_other_user() { ); } +#[tokio::test] +async fn test_get_deployment_state_by_hash_rejects_other_user() { + let Some(app) = common::spawn_app_two_users().await else { + return; + }; + let client = reqwest::Client::new(); + + let (_pid, _did, hash) = seed_deployment(&app.db_pool, common::USER_A_ID).await; + + let resp = client + .get(format!("{}/api/v1/deployments/{}/state", app.address, hash)) + .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) + .send() + .await + .expect("request failed"); + + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "User B must not access User A's deployment state by hash" + ); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["schemaVersion"].as_str().unwrap(), "v1alpha1"); + assert_eq!(body["code"].as_str().unwrap(), "deployment_not_found"); + assert_eq!(body["remediationClass"].as_str().unwrap(), "state"); +} + +#[tokio::test] +async fn test_get_deployment_events_by_hash_rejects_other_user() { + let Some(app) = common::spawn_app_two_users().await else { + return; + }; + let client = reqwest::Client::new(); + + let (_pid, _did, hash) = seed_deployment(&app.db_pool, common::USER_A_ID).await; + + let resp = client + .get(format!( + "{}/api/v1/deployments/{}/events", + app.address, hash + )) + .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) + .send() + .await + .expect("request failed"); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["schemaVersion"].as_str().unwrap(), "v1alpha1"); + assert_eq!(body["code"].as_str().unwrap(), "deployment_not_found"); + assert_eq!(body["remediationClass"].as_str().unwrap(), "state"); +} + // ── Get by project ────────────────────────────────────────────────────── #[tokio::test] @@ -162,4 +215,20 @@ async fn test_owner_can_access_own_deployment() { .await .expect("request failed"); assert_eq!(resp.status(), StatusCode::OK); + + let resp = client + .get(format!("{}/api/v1/deployments/{}/state", app.address, hash)) + .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) + .send() + .await + .expect("request failed"); + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["item"]["schemaVersion"].as_str().unwrap(), "v1alpha1"); + assert_eq!( + body["item"]["deployment"]["deploymentHash"] + .as_str() + .unwrap(), + hash + ); } diff --git a/tests/security_pipes.rs b/tests/security_pipes.rs index b6651cce..e9e1d1b2 100644 --- a/tests/security_pipes.rs +++ b/tests/security_pipes.rs @@ -6,6 +6,7 @@ mod common; use reqwest::StatusCode; +use serde_json::json; use sqlx::Row; /// Insert a private pipe template for the given user. Returns its UUID. @@ -214,3 +215,67 @@ async fn test_owner_can_list_own_pipe_instances() { assert_eq!(resp.status(), StatusCode::OK); } + +#[tokio::test] +async fn test_update_pipe_instance_rejects_other_user() { + let Some(app) = common::spawn_app_two_users().await else { + return; + }; + let client = reqwest::Client::new(); + + let (_hash_a, inst_id) = seed_pipe_instance(&app.db_pool, common::USER_A_ID).await; + + let resp = client + .put(format!( + "{}/api/v1/pipes/instances/{}/status", + app.address, inst_id + )) + .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) + .json(&json!({ "status": "paused" })) + .send() + .await + .expect("request failed"); + + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "User B must not update User A's pipe instance" + ); + + let status: String = sqlx::query_scalar("SELECT status FROM pipe_instances WHERE id = $1") + .bind(inst_id) + .fetch_one(&app.db_pool) + .await + .expect("Failed to fetch pipe status"); + assert_eq!(status, "active"); +} + +#[tokio::test] +async fn test_owner_can_update_own_pipe_instance_status() { + let Some(app) = common::spawn_app_two_users().await else { + return; + }; + let client = reqwest::Client::new(); + + let (_hash_a, inst_id) = seed_pipe_instance(&app.db_pool, common::USER_A_ID).await; + + let resp = client + .put(format!( + "{}/api/v1/pipes/instances/{}/status", + app.address, inst_id + )) + .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) + .json(&json!({ "status": "paused" })) + .send() + .await + .expect("request failed"); + + assert_eq!(resp.status(), StatusCode::OK); + + let status: String = sqlx::query_scalar("SELECT status FROM pipe_instances WHERE id = $1") + .bind(inst_id) + .fetch_one(&app.db_pool) + .await + .expect("Failed to fetch pipe status"); + assert_eq!(status, "paused"); +} diff --git a/tests/security_project.rs b/tests/security_project.rs index a0a1c486..03a7c118 100644 --- a/tests/security_project.rs +++ b/tests/security_project.rs @@ -2,7 +2,6 @@ mod common; /// IDOR security tests for /project endpoints. /// Verify that User B cannot list, read, update, or delete User A's projects. - #[tokio::test] async fn test_list_projects_only_returns_own() { let Some(app) = common::spawn_app_two_users().await else { @@ -18,7 +17,7 @@ async fn test_list_projects_only_returns_own() { // User A lists → sees exactly 2 let resp = client - .get(&format!("{}/project", &app.address)) + .get(format!("{}/project", &app.address)) .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) .send() .await @@ -30,7 +29,7 @@ async fn test_list_projects_only_returns_own() { // User B lists → sees exactly 1 let resp = client - .get(&format!("{}/project", &app.address)) + .get(format!("{}/project", &app.address)) .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) .send() .await @@ -52,7 +51,7 @@ async fn test_get_project_rejects_other_user() { // User B tries to GET User A's project → 404 let resp = client - .get(&format!("{}/project/{}", &app.address, project_id)) + .get(format!("{}/project/{}", &app.address, project_id)) .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) .send() .await @@ -75,7 +74,7 @@ async fn test_update_project_rejects_other_user() { // User B tries to PUT User A's project → 400 (bad_request = IDOR guard) let resp = client - .put(&format!("{}/project/{}", &app.address, project_id)) + .put(format!("{}/project/{}", &app.address, project_id)) .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) .header("Content-Type", "application/json") .body(r#"{"custom_stack_code":"hijacked","commonDomain":"test.com","dockerhub_user":"x","dockerhub_password":"x","apps":[]}"#) @@ -100,7 +99,7 @@ async fn test_delete_project_rejects_other_user() { // User B tries to DELETE User A's project → 400 (bad_request = IDOR guard) let resp = client - .delete(&format!("{}/project/{}", &app.address, project_id)) + .delete(format!("{}/project/{}", &app.address, project_id)) .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) .send() .await @@ -123,7 +122,7 @@ async fn test_owner_can_access_own_project() { // User A GETs own project → 200 let resp = client - .get(&format!("{}/project/{}", &app.address, project_id)) + .get(format!("{}/project/{}", &app.address, project_id)) .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) .send() .await diff --git a/tests/security_rating.rs b/tests/security_rating.rs index eb1b2d73..b017223e 100644 --- a/tests/security_rating.rs +++ b/tests/security_rating.rs @@ -5,7 +5,6 @@ use sqlx::Row; /// Rating edit/delete endpoints check `rating.user_id == user.id`. /// Non-owner attempts return 404 (the handler treats missing-or-not-owned as "not found"). - async fn insert_rating(pool: &sqlx::PgPool, user_id: &str) -> i32 { let rec = sqlx::query( "INSERT INTO rating (user_id, obj_id, rating, comment, category) \ diff --git a/tests/security_server.rs b/tests/security_server.rs index c0a56acd..5a4be062 100644 --- a/tests/security_server.rs +++ b/tests/security_server.rs @@ -2,7 +2,6 @@ mod common; /// IDOR security tests for /server endpoints. /// Verify that User B cannot list, read, or delete User A's servers. - #[tokio::test] async fn test_list_servers_only_returns_own() { let Some(app) = common::spawn_app_two_users().await else { @@ -25,7 +24,7 @@ async fn test_list_servers_only_returns_own() { // User A lists → sees exactly 2 let resp = client - .get(&format!("{}/server", &app.address)) + .get(format!("{}/server", &app.address)) .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) .send() .await @@ -37,7 +36,7 @@ async fn test_list_servers_only_returns_own() { // User B lists → sees exactly 1 let resp = client - .get(&format!("{}/server", &app.address)) + .get(format!("{}/server", &app.address)) .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) .send() .await @@ -62,7 +61,7 @@ async fn test_get_server_rejects_other_user() { // User B tries to GET User A's server → 404 let resp = client - .get(&format!("{}/server/{}", &app.address, server_id)) + .get(format!("{}/server/{}", &app.address, server_id)) .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) .send() .await @@ -88,7 +87,7 @@ async fn test_get_server_by_project_rejects_other_user() { // User B tries to GET servers by User A's project → 404 let resp = client - .get(&format!("{}/server/project/{}", &app.address, proj_a)) + .get(format!("{}/server/project/{}", &app.address, proj_a)) .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) .send() .await @@ -114,7 +113,7 @@ async fn test_delete_server_rejects_other_user() { // User B tries to DELETE User A's server → 400 (bad_request = IDOR guard) let resp = client - .delete(&format!("{}/server/{}", &app.address, server_id)) + .delete(format!("{}/server/{}", &app.address, server_id)) .header("Authorization", format!("Bearer {}", common::USER_B_TOKEN)) .send() .await @@ -140,7 +139,7 @@ async fn test_owner_can_access_own_server() { // User A GETs own server → 200 let resp = client - .get(&format!("{}/server/{}", &app.address, server_id)) + .get(format!("{}/server/{}", &app.address, server_id)) .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) .send() .await diff --git a/tests/server_ssh.rs b/tests/server_ssh.rs index 79e6686f..f737dcc2 100644 --- a/tests/server_ssh.rs +++ b/tests/server_ssh.rs @@ -56,7 +56,7 @@ async fn test_get_public_key_vault_path_null_returns_400() { let client = reqwest::Client::new(); let resp = client - .get(&format!( + .get(format!( "{}/server/{}/ssh-key/public", &app.address, server_id )) @@ -106,7 +106,7 @@ async fn test_get_public_key_vault_returns_404_propagates_as_404() { let client = reqwest::Client::new(); let resp = client - .get(&format!( + .get(format!( "{}/server/{}/ssh-key/public", &app.address, server_id )) @@ -143,7 +143,7 @@ async fn test_get_public_key_no_active_key_returns_404() { let client = reqwest::Client::new(); let resp = client - .get(&format!( + .get(format!( "{}/server/{}/ssh-key/public", &app.address, server_id )) @@ -185,7 +185,7 @@ async fn test_get_public_key_success() { let client = reqwest::Client::new(); let resp = client - .get(&format!( + .get(format!( "{}/server/{}/ssh-key/public", &app.address, server_id )) @@ -228,7 +228,7 @@ async fn test_generate_key_vault_down_returns_private_key_inline() { let client = reqwest::Client::new(); let resp = client - .post(&format!( + .post(format!( "{}/server/{}/ssh-key/generate", &app.address, server_id )) @@ -290,7 +290,7 @@ async fn test_generate_key_success_stores_in_vault_no_private_key_exposed() { let client = reqwest::Client::new(); let resp = client - .post(&format!( + .post(format!( "{}/server/{}/ssh-key/generate", &app.address, server_id )) @@ -347,7 +347,7 @@ async fn test_generate_key_already_active_returns_400() { let client = reqwest::Client::new(); let resp = client - .post(&format!( + .post(format!( "{}/server/{}/ssh-key/generate", &app.address, server_id )) @@ -391,7 +391,7 @@ async fn test_delete_key_clears_vault_and_db() { let client = reqwest::Client::new(); let resp = client - .delete(&format!("{}/server/{}/ssh-key", &app.address, server_id)) + .delete(format!("{}/server/{}/ssh-key", &app.address, server_id)) .header("Authorization", "Bearer test-token") .send() .await @@ -424,7 +424,7 @@ async fn test_delete_key_none_returns_400() { let client = reqwest::Client::new(); let resp = client - .delete(&format!("{}/server/{}/ssh-key", &app.address, server_id)) + .delete(format!("{}/server/{}/ssh-key", &app.address, server_id)) .header("Authorization", "Bearer test-token") .send() .await @@ -456,7 +456,7 @@ async fn test_authorize_public_key_invalid_public_key_returns_400_before_vault() let client = reqwest::Client::new(); let resp = client - .post(&format!( + .post(format!( "{}/server/{}/ssh-key/authorize-public-key", &app.address, server_id )) @@ -486,7 +486,7 @@ async fn test_authorize_public_key_vault_path_null_returns_400_before_vault() { let client = reqwest::Client::new(); let resp = client - .post(&format!( + .post(format!( "{}/server/{}/ssh-key/authorize-public-key", &app.address, server_id )) @@ -522,7 +522,7 @@ async fn test_authorize_public_key_missing_server_ip_returns_400_before_vault() let client = reqwest::Client::new(); let resp = client - .post(&format!( + .post(format!( "{}/server/{}/ssh-key/authorize-public-key", &app.address, server_id )) @@ -565,7 +565,7 @@ async fn test_authorize_public_key_vault_read_failure_does_not_leak_private_key( let client = reqwest::Client::new(); let resp = client - .post(&format!( + .post(format!( "{}/server/{}/ssh-key/authorize-public-key", &app.address, server_id )) @@ -604,9 +604,9 @@ async fn test_ssh_key_endpoints_require_auth() { for (verb, path) in endpoints { let req = match *verb { - "GET" => client.get(&format!("{}{}", &app.address, path)), - "POST" => client.post(&format!("{}{}", &app.address, path)), - "DELETE" => client.delete(&format!("{}{}", &app.address, path)), + "GET" => client.get(format!("{}{}", &app.address, path)), + "POST" => client.post(format!("{}{}", &app.address, path)), + "DELETE" => client.delete(format!("{}{}", &app.address, path)), _ => unreachable!(), }; let resp = req.send().await.expect("request failed"); diff --git a/tests/steps/agent_executor.rs b/tests/steps/agent_executor.rs index 72361a6c..83c92b75 100644 --- a/tests/steps/agent_executor.rs +++ b/tests/steps/agent_executor.rs @@ -1,9 +1,7 @@ use crate::steps::StepWorld; use cucumber::{given, then, when}; use serde_json::{json, Value as JsonValue}; -use stacker::models::agent_protocol::{ - routing, RetryPolicy, StepCommand, StepResultMsg, StepStatus, -}; +use stacker::models::agent_protocol::{routing, RetryPolicy, StepCommand, StepResultMsg}; use stacker::services::resilience_engine::{CircuitBreakerConfig, InMemoryCircuitBreaker}; use stacker::services::step_executor; use std::time::Duration; @@ -269,7 +267,7 @@ async fn then_cb_allows(world: &mut StepWorld) { } #[when(expr = "I wait {int} seconds for recovery")] -async fn when_wait_recovery(world: &mut StepWorld, seconds: u64) { +async fn when_wait_recovery(_world: &mut StepWorld, seconds: u64) { tokio::time::sleep(Duration::from_secs(seconds)).await; } diff --git a/tests/steps/common.rs b/tests/steps/common.rs index a6358001..91baa317 100644 --- a/tests/steps/common.rs +++ b/tests/steps/common.rs @@ -1,3 +1,5 @@ +#![allow(dead_code, clippy::let_underscore_future)] + use actix_web::{get, web, App, HttpServer, Responder}; use sqlx::{Connection, Executor, PgConnection, PgPool}; use stacker::configuration::{get_configuration, DatabaseSettings}; diff --git a/tests/steps/dag.rs b/tests/steps/dag.rs index f3a1d9fc..51a9ee61 100644 --- a/tests/steps/dag.rs +++ b/tests/steps/dag.rs @@ -7,7 +7,7 @@ use super::StepWorld; #[given(regex = r#"^I have a DAG pipe template named "([^"]+)"$"#)] async fn given_dag_pipe_template(world: &mut StepWorld, name: String) { - let pool = world.db_pool.as_ref().expect("no db_pool"); + let _pool = world.db_pool.as_ref().expect("no db_pool"); // Use unique name per scenario to avoid parallel conflicts let unique_name = format!("{}-{}", name, uuid::Uuid::new_v4()); @@ -95,12 +95,12 @@ async fn create_dag_edge(world: &mut StepWorld, from_name: &str, to_name: &str) let from_id = world .stored_ids .get(&format!("dag_step:{}", from_name)) - .expect(&format!("No step named '{}'", from_name)) + .unwrap_or_else(|| panic!("No step named '{}'", from_name)) .clone(); let to_id = world .stored_ids .get(&format!("dag_step:{}", to_name)) - .expect(&format!("No step named '{}'", to_name)) + .unwrap_or_else(|| panic!("No step named '{}'", to_name)) .clone(); let body = json!({ @@ -253,12 +253,12 @@ async fn when_add_dag_edge(world: &mut StepWorld, from_name: String, to_name: St let from_id = world .stored_ids .get(&format!("dag_step:{}", from_name)) - .expect(&format!("No step named '{}'", from_name)) + .unwrap_or_else(|| panic!("No step named '{}'", from_name)) .clone(); let to_id = world .stored_ids .get(&format!("dag_step:{}", to_name)) - .expect(&format!("No step named '{}'", to_name)) + .unwrap_or_else(|| panic!("No step named '{}'", to_name)) .clone(); let body = json!({ @@ -296,12 +296,12 @@ async fn when_add_dag_edge_with_condition( let from_id = world .stored_ids .get(&format!("dag_step:{}", from_name)) - .expect(&format!("No step named '{}'", from_name)) + .unwrap_or_else(|| panic!("No step named '{}'", from_name)) .clone(); let to_id = world .stored_ids .get(&format!("dag_step:{}", to_name)) - .expect(&format!("No step named '{}'", to_name)) + .unwrap_or_else(|| panic!("No step named '{}'", to_name)) .clone(); let docstring = step.docstring.as_ref().expect("Missing docstring"); diff --git a/tests/steps/deployment.rs b/tests/steps/deployment.rs index 8645cf67..3be8efb1 100644 --- a/tests/steps/deployment.rs +++ b/tests/steps/deployment.rs @@ -1,4 +1,4 @@ -use cucumber::{given, then, when}; +use cucumber::{given, when}; use serde_json::json; use super::StepWorld; diff --git a/tests/steps/deployment_state.rs b/tests/steps/deployment_state.rs new file mode 100644 index 00000000..ddc262af --- /dev/null +++ b/tests/steps/deployment_state.rs @@ -0,0 +1,10 @@ +use cucumber::when; + +use super::StepWorld; + +#[when(regex = r#"^I get deployment state for "([^"]+)"$"#)] +async fn get_deployment_state(world: &mut StepWorld, deployment_hash: String) { + world + .get(&format!("/api/v1/deployments/{}/state", deployment_hash)) + .await; +} diff --git a/tests/steps/mcp.rs b/tests/steps/mcp.rs index 817686e3..6279e0eb 100644 --- a/tests/steps/mcp.rs +++ b/tests/steps/mcp.rs @@ -1,3 +1,5 @@ +#![allow(clippy::manual_try_fold)] + use crate::steps::StepWorld; use cucumber::{then, when}; use futures_util::{SinkExt, StreamExt}; @@ -268,6 +270,43 @@ async fn assert_tool_not_error(world: &mut StepWorld) { ); } +#[then("the MCP tool response should be an error")] +async fn assert_tool_is_error(world: &mut StepWorld) { + let resp = world.mcp_response.as_ref().expect("No MCP response"); + let result = resp.get("result").expect("No result in MCP response"); + let is_error = result.get("isError").and_then(|v| v.as_bool()); + assert_eq!( + is_error, + Some(true), + "Expected tool call to fail, got: {}", + result + ); +} + +#[then(regex = r#"^the MCP tool text response should contain "(.+)"$"#)] +async fn assert_tool_text_contains(world: &mut StepWorld, expected: String) { + let resp = world.mcp_response.as_ref().expect("No MCP response"); + let result = resp.get("result").expect("No result in MCP response"); + let content = result + .get("content") + .and_then(|v| v.as_array()) + .expect("No content array in MCP tool response"); + let text = content + .first() + .and_then(|item| item.get("text")) + .and_then(|value| value.as_str()) + .expect("No text content in MCP tool response"); + + let expected = expected.replace("\\\"", "\""); + + assert!( + text.contains(&expected), + "Expected MCP tool text to contain '{}', got: {}", + expected, + text + ); +} + #[then(regex = r#"^no MCP response should be received within (\d+)ms$"#)] async fn assert_no_response(world: &mut StepWorld, _millis: u64) { assert!( diff --git a/tests/steps/ml_field_match.rs b/tests/steps/ml_field_match.rs index 1e0673bb..b04ff4d6 100644 --- a/tests/steps/ml_field_match.rs +++ b/tests/steps/ml_field_match.rs @@ -89,7 +89,7 @@ async fn then_unmatched_target(world: &mut StepWorld, field_name: String) { let found = unmatched .iter() - .any(|v| v.as_str().map_or(false, |s| s == field_name)); + .any(|v| v.as_str().is_some_and(|s| s == field_name)); assert!( found, diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index 211865a4..9e149818 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + pub mod agent; pub mod agent_executor; pub mod cdc; @@ -6,6 +8,7 @@ pub mod common; pub mod dag; pub mod dag_execution; pub mod deployment; +pub mod deployment_state; pub mod health; pub mod marketplace; pub mod mcp; diff --git a/tests/steps/pipe.rs b/tests/steps/pipe.rs index 29d05143..3c5f84a8 100644 --- a/tests/steps/pipe.rs +++ b/tests/steps/pipe.rs @@ -1,4 +1,4 @@ -use cucumber::{given, then, when}; +use cucumber::{given, when}; use serde_json::json; use super::StepWorld; diff --git a/tests/steps/visual_editor.rs b/tests/steps/visual_editor.rs index d56e17b9..07f331ec 100644 --- a/tests/steps/visual_editor.rs +++ b/tests/steps/visual_editor.rs @@ -142,12 +142,12 @@ async fn when_add_edge(world: &mut StepWorld, from: String, to: String) { let from_id = world .stored_ids .get(&format!("dag_step:{}", from)) - .expect(&format!("No step '{}'", from)) + .unwrap_or_else(|| panic!("No step '{}'", from)) .clone(); let to_id = world .stored_ids .get(&format!("dag_step:{}", to)) - .expect(&format!("No step '{}'", to)) + .unwrap_or_else(|| panic!("No step '{}'", to)) .clone(); let body = json!({ "from_step_id": from_id, "to_step_id": to_id }); @@ -248,7 +248,7 @@ async fn when_add_all_step_types(world: &mut StepWorld) { ]; let mut created = 0; - for (_i, step_type) in types.iter().enumerate() { + for step_type in types.iter() { let body = json!({ "name": step_type, "step_type": step_type, diff --git a/tests/typed_error_contract.rs b/tests/typed_error_contract.rs new file mode 100644 index 00000000..57f406d0 --- /dev/null +++ b/tests/typed_error_contract.rs @@ -0,0 +1,70 @@ +use serde_json::Value; + +use stacker::services::{ + TypedErrorCode, TypedErrorEnvelope, TypedRemediationClass, TYPED_ERROR_SCHEMA_VERSION, +}; + +fn load_contract() -> Value { + serde_json::from_str(include_str!( + "contracts/stacker-typed-error.v1alpha1.contract.json" + )) + .expect("typed error contract JSON should be valid") +} + +fn load_fixture() -> &'static str { + include_str!("contracts/stacker-typed-error.v1alpha1.deployment-capability-missing.json") +} + +#[test] +fn typed_error_contract_metadata_is_correct() { + let contract = load_contract(); + + assert_eq!( + contract["title"].as_str().unwrap(), + "stacker-typed-error-v1alpha1" + ); + assert_eq!(contract["_owner"].as_str().unwrap(), "stacker"); + assert!(contract["surfaces"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .any(|surface| surface == "mcp")); +} + +#[test] +fn typed_error_contract_requires_core_fields() { + let contract = load_contract(); + let required = contract["response"]["required"] + .as_array() + .expect("required should be an array"); + let required_names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); + + for field in [ + "schemaVersion", + "code", + "message", + "retryable", + "remediationClass", + ] { + assert!( + required_names.contains(&field), + "required fields must include {field}" + ); + } +} + +#[test] +fn typed_error_fixture_deserializes_into_shared_type() { + let error: TypedErrorEnvelope = + serde_json::from_str(load_fixture()).expect("typed error fixture should deserialize"); + + assert_eq!(error.schema_version, TYPED_ERROR_SCHEMA_VERSION); + assert_eq!(error.code, TypedErrorCode::DeploymentCapabilityMissing); + assert_eq!(error.remediation_class, TypedRemediationClass::Capability); + assert!(!error.retryable); + assert_eq!( + error.context.get("capability").map(String::as_str), + Some("compose_logs") + ); +} diff --git a/website/dist/index.html b/website/dist/index.html index 53918cc5..edd79692 100644 --- a/website/dist/index.html +++ b/website/dist/index.html @@ -351,68 +351,30 @@

Supported AI Providers

- -

Real commands.
Real operator output.

-

Examples from the current CLI, including AI guidance for bare-metal servers and remote operations on live deployments.

+ +

Paste-ready prompts.
Website ideas you can ship.

+

Use these examples with stacker ai ask when you want Stacker to scaffold a site, adapt a template, and prepare deployment to Hetzner or your own server.

-
Real AI example
-

Ask how to connect a metal bare server

-
❯ ./stacker ai ask "how do I connect my metal bare server"
-To connect your metal bare server using the provided `stacker.yml` configuration, you need to update the `deploy.server` section with the necessary details. Here is an example of how to configure it:
-
-```yaml
-deploy:
-  target: server
-  compose_file: null
-  cloud: null
-  server:
-    host: your_server_ip_or_hostname
-    user: root
-    port: 22
-    ssh_key: ~/.ssh/id_rsa
-```
-
-Replace `your_server_ip_or_hostname` with the actual IP address or hostname of your metal bare server.
-
-After updating the `deploy.server` section, you can deploy to your server using:
-
-```sh
-stacker deploy --target server
-```
-
-Make sure that your SSH key is correctly set up and that the user has the necessary permissions on the server.
+
Next.js starter
+

Reggae fan site from a template link

+
❯ stacker ai ask "Create a website dedicated to reggae using https://example.com/reggae as the design template. The site should be built with Next.js. Start with just two pages: home page and contact us page. Deploy it to Hetzner Cloud using Stacker CLI."
-
New in v0.2.8
-

Open ports and keep your database private

-
❯ stacker agent configure-firewall \
-    --public-ports 80/tcp,443/tcp \
-    --private-ports 5432/tcp:10.0.0.0/8
-▸ Checking deployment and agent capabilities...
-▸ Applying iptables rules on target server...
-✓ Opened 80/tcp and 443/tcp to the public internet
-✓ Restricted 5432/tcp to 10.0.0.0/8
-✓ Firewall rules persisted for future reboots
+
Marketing site
+

Launch a product landing page with blog support

+
❯ stacker ai ask "Build a modern SaaS landing page using https://example.com/saas-launch as the visual template. Use Next.js with a homepage, pricing page, blog index, and contact form. Add PostgreSQL for newsletter signups and deploy the stack to Hetzner with Stacker."
-
Agent compose essentials
-

Mount the host paths agent operations need

-
agent:
-  image: trydirect/status:unstable
-  container_name: statuspanel_agent
-  volumes:
-    # Agent needs Docker socket for container monitoring and logs
-    - /var/run/docker.sock:/var/run/docker.sock
-    # Mount docker CLI from host for deploy_app/remove_app commands
-    - /usr/bin/docker:/usr/bin/docker:ro
-    - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro
-    # Mount host paths for compose, config files, and managed installs
-    - /home/trydirect:/home/trydirect
-    - /opt:/opt
-  env_file:
-    - .env
+
Portfolio
+

Create a studio website with CMS

+
❯ stacker ai ask "Create a portfolio website for an interior design studio based on https://example.com/studio-portfolio. Use Next.js, include home, projects, about, and contact pages, and add a lightweight admin CMS for editing project entries. Prepare the deployment for Hetzner Cloud with Stacker CLI."
+
+
+
Docs + community
+

Ship a docs-driven open source site

+
❯ stacker ai ask "Create a documentation website for an open source CLI using https://example.com/docs-template as the template reference. Use Next.js and add home, docs, changelog, and contact pages. Include search-ready content structure and deploy it to Hetzner using Stacker."
diff --git a/website/src/index.html b/website/src/index.html index 53918cc5..edd79692 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -351,68 +351,30 @@

Supported AI Providers

- -

Real commands.
Real operator output.

-

Examples from the current CLI, including AI guidance for bare-metal servers and remote operations on live deployments.

+ +

Paste-ready prompts.
Website ideas you can ship.

+

Use these examples with stacker ai ask when you want Stacker to scaffold a site, adapt a template, and prepare deployment to Hetzner or your own server.

-
Real AI example
-

Ask how to connect a metal bare server

-
❯ ./stacker ai ask "how do I connect my metal bare server"
-To connect your metal bare server using the provided `stacker.yml` configuration, you need to update the `deploy.server` section with the necessary details. Here is an example of how to configure it:
-
-```yaml
-deploy:
-  target: server
-  compose_file: null
-  cloud: null
-  server:
-    host: your_server_ip_or_hostname
-    user: root
-    port: 22
-    ssh_key: ~/.ssh/id_rsa
-```
-
-Replace `your_server_ip_or_hostname` with the actual IP address or hostname of your metal bare server.
-
-After updating the `deploy.server` section, you can deploy to your server using:
-
-```sh
-stacker deploy --target server
-```
-
-Make sure that your SSH key is correctly set up and that the user has the necessary permissions on the server.
+
Next.js starter
+

Reggae fan site from a template link

+
❯ stacker ai ask "Create a website dedicated to reggae using https://example.com/reggae as the design template. The site should be built with Next.js. Start with just two pages: home page and contact us page. Deploy it to Hetzner Cloud using Stacker CLI."
-
New in v0.2.8
-

Open ports and keep your database private

-
❯ stacker agent configure-firewall \
-    --public-ports 80/tcp,443/tcp \
-    --private-ports 5432/tcp:10.0.0.0/8
-▸ Checking deployment and agent capabilities...
-▸ Applying iptables rules on target server...
-✓ Opened 80/tcp and 443/tcp to the public internet
-✓ Restricted 5432/tcp to 10.0.0.0/8
-✓ Firewall rules persisted for future reboots
+
Marketing site
+

Launch a product landing page with blog support

+
❯ stacker ai ask "Build a modern SaaS landing page using https://example.com/saas-launch as the visual template. Use Next.js with a homepage, pricing page, blog index, and contact form. Add PostgreSQL for newsletter signups and deploy the stack to Hetzner with Stacker."
-
Agent compose essentials
-

Mount the host paths agent operations need

-
agent:
-  image: trydirect/status:unstable
-  container_name: statuspanel_agent
-  volumes:
-    # Agent needs Docker socket for container monitoring and logs
-    - /var/run/docker.sock:/var/run/docker.sock
-    # Mount docker CLI from host for deploy_app/remove_app commands
-    - /usr/bin/docker:/usr/bin/docker:ro
-    - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro
-    # Mount host paths for compose, config files, and managed installs
-    - /home/trydirect:/home/trydirect
-    - /opt:/opt
-  env_file:
-    - .env
+
Portfolio
+

Create a studio website with CMS

+
❯ stacker ai ask "Create a portfolio website for an interior design studio based on https://example.com/studio-portfolio. Use Next.js, include home, projects, about, and contact pages, and add a lightweight admin CMS for editing project entries. Prepare the deployment for Hetzner Cloud with Stacker CLI."
+
+
+
Docs + community
+

Ship a docs-driven open source site

+
❯ stacker ai ask "Create a documentation website for an open source CLI using https://example.com/docs-template as the template reference. Use Next.js and add home, docs, changelog, and contact pages. Include search-ready content structure and deploy it to Hetzner using Stacker."