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/.github/workflows/docker.yml b/.github/workflows/docker.yml index aa13b880..4a2e46d0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -17,8 +17,22 @@ jobs: cicd-docker: name: Cargo and npm build - #runs-on: ubuntu-latest - runs-on: [self-hosted, linux] + runs-on: ubuntu-latest + #runs-on: [self-hosted, linux] + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432/tcp + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 env: SQLX_OFFLINE: true steps: @@ -26,12 +40,18 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.ref }} + - name: Export PostgreSQL connection env + run: | + echo "PGHOST=127.0.0.1" >> "$GITHUB_ENV" + echo "PGPORT=${{ job.services.postgres.ports['5432'] }}" >> "$GITHUB_ENV" + echo "PGUSER=postgres" >> "$GITHUB_ENV" + echo "PGPASSWORD=postgres" >> "$GITHUB_ENV" - - name: Install OpenSSL build deps + - name: Install OpenSSL and protoc build deps if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y pkg-config libssl-dev + sudo apt-get install -y pkg-config libssl-dev protobuf-compiler - name: Verify .sqlx cache exists run: | @@ -68,6 +88,17 @@ jobs: run: | head -c16 /dev/urandom > src/secret.key + - name: Wait for PostgreSQL + run: | + for _ in $(seq 1 60); do + if bash -lc "exec 3<>/dev/tcp/${PGHOST}/${PGPORT}" 2>/dev/null; then + exit 0 + fi + sleep 1 + done + echo "PostgreSQL did not become ready in time" >&2 + exit 1 + - name: Cache cargo build uses: actions/cache@v4 with: @@ -114,6 +145,14 @@ jobs: command: build args: --release --bin server + - name: Set up Node.js + if: ${{ hashFiles('web/package.json') != '' }} + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: web/package-lock.json + - name: npm install, build, and test if: ${{ hashFiles('web/package.json') != '' }} working-directory: ./web @@ -155,14 +194,17 @@ jobs: cicd-linux-docker: name: CICD Docker - #runs-on: ubuntu-latest - runs-on: [self-hosted, linux] + runs-on: ubuntu-latest + #runs-on: [self-hosted, linux] needs: cicd-docker steps: - name: Checkout sources uses: actions/checkout@v4 with: ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }} + - name: Verify shared pipe fixtures + run: | + test -d "${GITHUB_WORKSPACE}/tests/fixtures/pipe-contract" - name: Set up QEMU @@ -189,13 +231,15 @@ jobs: uses: docker/build-push-action@v6 with: context: . + build-contexts: | + shared_fixtures=${{ github.workspace }}/tests/fixtures push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.docker_tags.outputs.tags }} stackerdb-docker: name: StackerDB Docker - #runs-on: ubuntu-latest - runs-on: [self-hosted, linux] + runs-on: ubuntu-latest + #runs-on: [self-hosted, linux] needs: cicd-docker steps: - name: Checkout sources @@ -229,4 +273,3 @@ jobs: file: ./stackerdb/Dockerfile push: true tags: ${{ steps.stackerdb_tags.outputs.tags }} - diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d628cfe5..1f724658 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,14 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Install protoc + run: | + if [ "$RUNNER_OS" = "Linux" ]; then + sudo apt-get update -qq && sudo apt-get install -y protobuf-compiler + elif [ "$RUNNER_OS" = "macOS" ]; then + brew install protobuf + fi + - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index be903eca..2ebf5add 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -31,6 +31,13 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 + - name: Install protoc + run: | + if [ "$RUNNER_OS" = "Linux" ]; then + sudo apt-get update -qq && sudo apt-get install -y protobuf-compiler + elif [ "$RUNNER_OS" = "macOS" ]; then + brew install protobuf + fi - name: Verify .sqlx cache exists run: | ls -lh .sqlx/ || echo ".sqlx directory not found" @@ -41,27 +48,11 @@ jobs: toolchain: stable target: ${{ matrix.target }} override: true - - name: Cache cargo registry - uses: actions/cache@v5 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-registry- - - name: Cache cargo index - uses: actions/cache@v5 + - name: Cache Cargo registry/git (no target — cross-compiled release builds cause cache bloat) + uses: Swatinem/rust-cache@v2 with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-index- - - name: Cache target directory - uses: actions/cache@v5 - with: - path: target - key: ${{ runner.os }}-target-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-target-${{ matrix.target }}- + cache-targets: "false" + key: ${{ matrix.target }} - name: Build server (release) run: cargo build --release --target ${{ matrix.target }} --bin server --verbose @@ -87,35 +78,49 @@ jobs: test: name: Run CLI tests runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432/tcp + options: >- + --init + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 env: SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 + - name: Export PostgreSQL connection env + run: | + echo "PGHOST=127.0.0.1" >> "$GITHUB_ENV" + echo "PGPORT=${{ job.services.postgres.ports['5432'] }}" >> "$GITHUB_ENV" + echo "PGUSER=postgres" >> "$GITHUB_ENV" + echo "PGPASSWORD=postgres" >> "$GITHUB_ENV" + - name: Install protoc + run: sudo apt-get update -qq && sudo apt-get install -y protobuf-compiler + - name: Wait for PostgreSQL + run: | + for _ in $(seq 1 60); do + if bash -lc "exec 3<>/dev/tcp/${PGHOST}/${PGPORT}" 2>/dev/null; then + exit 0 + fi + sleep 1 + done + echo "PostgreSQL did not become ready in time" >&2 + exit 1 - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: toolchain: stable override: true - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: ~/.cargo/registry - key: ubuntu-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-registry- - - name: Cache cargo index - uses: actions/cache@v4 - with: - path: ~/.cargo/git - key: ubuntu-cargo-index-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-index- - - name: Cache target directory - uses: actions/cache@v4 - with: - path: target - key: ubuntu-target-tests-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-target-tests- + - name: Cache Cargo registry/git/target (with stale-artifact pruning) + uses: Swatinem/rust-cache@v2 - name: Run CLI integration tests run: cargo test --tests --verbose diff --git a/.gitignore b/.gitignore index 9218add7..ad2a7530 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,10 @@ configuration.yaml.orig docker/local/ docs/*.sql config-to-validate.yaml -*.bak \ No newline at end of file +*.bak +.claude/ + +plan/ +.stacker +done.txt +post-deploy-ran.txt diff --git a/.sqlx/query-1108f78f1238d79a63ed5872b40a61e5bf9278b220373771cecb87850002e58e.json b/.sqlx/query-1108f78f1238d79a63ed5872b40a61e5bf9278b220373771cecb87850002e58e.json deleted file mode 100644 index 78373b6c..00000000 --- a/.sqlx/query-1108f78f1238d79a63ed5872b40a61e5bf9278b220373771cecb87850002e58e.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE stack_template SET \n name = COALESCE($2, name),\n short_description = COALESCE($3, short_description),\n long_description = COALESCE($4, long_description),\n category_id = COALESCE((SELECT id FROM stack_category WHERE name = $5), category_id),\n tags = COALESCE($6, tags),\n tech_stack = COALESCE($7, tech_stack),\n price = COALESCE($8, price),\n billing_cycle = COALESCE($9, billing_cycle),\n currency = COALESCE($10, currency)\n WHERE id = $1::uuid", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Varchar", - "Text", - "Text", - "Text", - "Jsonb", - "Jsonb", - "Float8", - "Varchar", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "1108f78f1238d79a63ed5872b40a61e5bf9278b220373771cecb87850002e58e" -} diff --git a/.sqlx/query-1f1b8182d59d8253662da0ea73b69b6857e5f3c8f4292ba9c4491e062591575b.json b/.sqlx/query-1f1b8182d59d8253662da0ea73b69b6857e5f3c8f4292ba9c4491e062591575b.json deleted file mode 100644 index 4fe673bd..00000000 --- a/.sqlx/query-1f1b8182d59d8253662da0ea73b69b6857e5f3c8f4292ba9c4491e062591575b.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO project (stack_id, user_id, name, metadata, created_at, updated_at, request_json)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar", - "Text", - "Json", - "Timestamptz", - "Timestamptz", - "Json" - ] - }, - "nullable": [ - false - ] - }, - "hash": "1f1b8182d59d8253662da0ea73b69b6857e5f3c8f4292ba9c4491e062591575b" -} diff --git a/.sqlx/query-55573922a4b559fe1ceadd9a8bf7ce4e35e61a132eb25c7178b1f96733f6cd34.json b/.sqlx/query-55573922a4b559fe1ceadd9a8bf7ce4e35e61a132eb25c7178b1f96733f6cd34.json new file mode 100644 index 00000000..543f2e90 --- /dev/null +++ b/.sqlx/query-55573922a4b559fe1ceadd9a8bf7ce4e35e61a132eb25c7178b1f96733f6cd34.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO marketplace_event (\n template_id,\n event_type,\n viewer_user_id,\n deployer_user_id,\n cloud_provider,\n occurred_at,\n metadata\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "55573922a4b559fe1ceadd9a8bf7ce4e35e61a132eb25c7178b1f96733f6cd34" +} diff --git a/.sqlx/query-8572f1b25eb32d67fa2e8a11e2fdc1e9ccacb150cdba0063dd71ff6133eef99c.json b/.sqlx/query-8572f1b25eb32d67fa2e8a11e2fdc1e9ccacb150cdba0063dd71ff6133eef99c.json new file mode 100644 index 00000000..f85f5f13 --- /dev/null +++ b/.sqlx/query-8572f1b25eb32d67fa2e8a11e2fdc1e9ccacb150cdba0063dd71ff6133eef99c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE stack_template \n SET view_count = 100, deploy_count = 50\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "8572f1b25eb32d67fa2e8a11e2fdc1e9ccacb150cdba0063dd71ff6133eef99c" +} diff --git a/.sqlx/query-9297aaf7dfb0d285baa3e6cb471753d2d19be70b8fd380a73c704e4ae51b3ae8.json b/.sqlx/query-9297aaf7dfb0d285baa3e6cb471753d2d19be70b8fd380a73c704e4ae51b3ae8.json new file mode 100644 index 00000000..d1b50301 --- /dev/null +++ b/.sqlx/query-9297aaf7dfb0d285baa3e6cb471753d2d19be70b8fd380a73c704e4ae51b3ae8.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT \n template_id,\n event_type,\n deployer_user_id,\n cloud_provider,\n occurred_at,\n metadata\n FROM marketplace_event\n WHERE template_id = $1 AND event_type = 'deploy'\n ORDER BY occurred_at DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "template_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "event_type", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "deployer_user_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "cloud_provider", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "occurred_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + true, + true, + true + ] + }, + "hash": "9297aaf7dfb0d285baa3e6cb471753d2d19be70b8fd380a73c704e4ae51b3ae8" +} diff --git a/.sqlx/query-a4a4fd9930446021a166ead8216aaa891d03210da3c4ffe2bc7be18efcfc52bd.json b/.sqlx/query-a4a4fd9930446021a166ead8216aaa891d03210da3c4ffe2bc7be18efcfc52bd.json new file mode 100644 index 00000000..b3d411be --- /dev/null +++ b/.sqlx/query-a4a4fd9930446021a166ead8216aaa891d03210da3c4ffe2bc7be18efcfc52bd.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO stack_template (\n id,\n creator_user_id,\n creator_name,\n name,\n slug,\n short_description,\n status,\n tags,\n tech_stack,\n view_count,\n deploy_count,\n created_at,\n updated_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, 'approved', '[]'::jsonb, '{}'::jsonb, 0, 0, NOW(), NOW())\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "a4a4fd9930446021a166ead8216aaa891d03210da3c4ffe2bc7be18efcfc52bd" +} diff --git a/.sqlx/query-ab22f5f84d90a3c2717cea339f6444c6c2656615fb29b4c04031a090cf103bdd.json b/.sqlx/query-ab22f5f84d90a3c2717cea339f6444c6c2656615fb29b4c04031a090cf103bdd.json deleted file mode 100644 index f684d17e..00000000 --- a/.sqlx/query-ab22f5f84d90a3c2717cea339f6444c6c2656615fb29b4c04031a090cf103bdd.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO stack_template_version (\n template_id, version, stack_definition, definition_format, changelog, is_latest\n ) VALUES ($1,$2,$3,$4,$5,true)\n RETURNING id, template_id, version, stack_definition, definition_format, changelog, is_latest, created_at", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "template_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "version", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "stack_definition", - "type_info": "Jsonb" - }, - { - "ordinal": 4, - "name": "definition_format", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "changelog", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "is_latest", - "type_info": "Bool" - }, - { - "ordinal": 7, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar", - "Jsonb", - "Varchar", - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - true, - true - ] - }, - "hash": "ab22f5f84d90a3c2717cea339f6444c6c2656615fb29b4c04031a090cf103bdd" -} diff --git a/.sqlx/query-b7730c23ed912fb66727333a9d362341bcc8f006dcfcd91033691ce35a2d5cb7.json b/.sqlx/query-b7730c23ed912fb66727333a9d362341bcc8f006dcfcd91033691ce35a2d5cb7.json new file mode 100644 index 00000000..dcd249d0 --- /dev/null +++ b/.sqlx/query-b7730c23ed912fb66727333a9d362341bcc8f006dcfcd91033691ce35a2d5cb7.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT occurred_at\n FROM marketplace_event\n WHERE template_id = $1\n ORDER BY occurred_at DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "occurred_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "b7730c23ed912fb66727333a9d362341bcc8f006dcfcd91033691ce35a2d5cb7" +} diff --git a/.sqlx/query-cb2a7c29711368a898ecc25e45303399e5d8b6713dcfb5f3bc704ef98842669d.json b/.sqlx/query-cb2a7c29711368a898ecc25e45303399e5d8b6713dcfb5f3bc704ef98842669d.json new file mode 100644 index 00000000..cfe98af8 --- /dev/null +++ b/.sqlx/query-cb2a7c29711368a898ecc25e45303399e5d8b6713dcfb5f3bc704ef98842669d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE stack_template \n SET view_count = 42, deploy_count = 15\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "cb2a7c29711368a898ecc25e45303399e5d8b6713dcfb5f3bc704ef98842669d" +} diff --git a/.sqlx/query-d14f61cd13654182ec393276acb329737a3ce95efe860659aa5a85888b82c4d3.json b/.sqlx/query-d14f61cd13654182ec393276acb329737a3ce95efe860659aa5a85888b82c4d3.json new file mode 100644 index 00000000..d2becaaf --- /dev/null +++ b/.sqlx/query-d14f61cd13654182ec393276acb329737a3ce95efe860659aa5a85888b82c4d3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE stack_template \n SET view_count = 25, deploy_count = 10\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "d14f61cd13654182ec393276acb329737a3ce95efe860659aa5a85888b82c4d3" +} diff --git a/.sqlx/query-db15f82b91377978db22c48cf2fb4d54ef603448c0c44272aec8f2ff04920b83.json b/.sqlx/query-db15f82b91377978db22c48cf2fb4d54ef603448c0c44272aec8f2ff04920b83.json deleted file mode 100644 index 0300aa28..00000000 --- a/.sqlx/query-db15f82b91377978db22c48cf2fb4d54ef603448c0c44272aec8f2ff04920b83.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE project\n SET \n stack_id=$2,\n user_id=$3,\n name=$4,\n metadata=$5,\n request_json=$6,\n updated_at=NOW() at time zone 'utc'\n WHERE id = $1\n RETURNING *\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "stack_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "user_id", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "metadata", - "type_info": "Json" - }, - { - "ordinal": 5, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "updated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "request_json", - "type_info": "Json" - }, - { - "ordinal": 8, - "name": "source_template_id", - "type_info": "Uuid" - }, - { - "ordinal": 9, - "name": "template_version", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int4", - "Uuid", - "Varchar", - "Text", - "Json", - "Json" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - true, - true - ] - }, - "hash": "db15f82b91377978db22c48cf2fb4d54ef603448c0c44272aec8f2ff04920b83" -} diff --git a/.sqlx/query-f93b65a30034b0558781a3173986706ad8a6255bba2812d4e32da205773c6de9.json b/.sqlx/query-f93b65a30034b0558781a3173986706ad8a6255bba2812d4e32da205773c6de9.json deleted file mode 100644 index 7dff9113..00000000 --- a/.sqlx/query-f93b65a30034b0558781a3173986706ad8a6255bba2812d4e32da205773c6de9.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT \n id,\n template_id,\n version,\n stack_definition,\n definition_format,\n changelog,\n is_latest,\n created_at\n FROM stack_template_version WHERE template_id = $1 AND is_latest = true LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "template_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "version", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "stack_definition", - "type_info": "Jsonb" - }, - { - "ordinal": 4, - "name": "definition_format", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "changelog", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "is_latest", - "type_info": "Bool" - }, - { - "ordinal": 7, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - true, - true - ] - }, - "hash": "f93b65a30034b0558781a3173986706ad8a6255bba2812d4e32da205773c6de9" -} diff --git a/BUILD_RELEASE.md b/BUILD_RELEASE.md index 59376574..d714b296 100644 --- a/BUILD_RELEASE.md +++ b/BUILD_RELEASE.md @@ -18,18 +18,18 @@ git pull ### 2) Create and publish the release ```bash -gh release create v0.2.7 --generate-notes +gh release create v0.2.8 --generate-notes ``` -This creates the `v0.2.7` tag and publishes the release, which triggers: +This creates the `v0.2.8` tag and publishes the release, which triggers: - CLI binary builds (linux + macOS) and uploads to the release. -- Docker image build and push tagged as `trydirect/stacker:v0.2.7`. +- Docker image build and push tagged as `trydirect/stacker:v0.2.8`. ### 3) Verify artifacts ```bash -gh release view v0.2.7 --json assets --jq '.assets[].name' +gh release view v0.2.8 --json assets --jq '.assets[].name' ``` ### 4) Check workflows diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ea0f64..ab77e1b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,339 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added — Remote project initialization via `--from-github` + +- Added `stacker init --from-github ` (short flag: `-g`) to automatically + generate a `stacker.yml` by cloning a GitHub repository into a temp directory, + detecting the project type and compose-defined services, inferring healthchecks + for infrastructure images (postgres, redis, mysql, mongo, rabbitmq, + elasticsearch), and writing the config to the current directory. + Accepts `owner/repo` shorthand and full `https://github.com/owner/repo` URLs, + with optional `.git` suffix. +- `stacker init --from-github` also generates `.env.example` from detected + environment variables, marking secret-looking keys (password, secret, key, + token) as empty, and creates `scripts/generate-secrets.sh` for one-command + local secret generation via `openssl rand -hex`. +- The remote init path reuses the existing local detection and config generation + pipeline (`detect_workspace`, `ConfigBuilder`, `DockerfileBuilder`) unchanged — + the GitHub clone is the only net-new I/O step. +- `.stacker/` artifact generation and cloud setup wizard are skipped for + `--from-github` to avoid stale temporary-paths in generated artifacts. + +### 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.3.1] — 2026-07-15 + +### Fixed — CLI deploy & config bundling + +- Deploy-time config files (bind-mount files and the env file) are now bundled + on every cloud/server deploy, not only when a `deploy.environment` profile is + selected. Previously, deploys without an environment silently shipped no + config files. +- A synthetic fallback environment used when no profile is selected is no longer + leaked into the deploy-time `environment` field sent to the server/installer. +- Bind-mount and `env_file` references in a generated `.stacker/` compose now + resolve against the project root instead of the `.stacker/` output directory, + fixing `config bundle referenced file does not exist: .../.stacker/./`. +- Directory bind mounts (e.g. `./library`, `./assets`, `./config`) and sources + that don't exist locally no longer block the deploy — they pass through and + are created on the target host. `env_file:` entries remain strictly required. +- `config setup server`, `config unlock`, and `config apply-lock` now perform + targeted, formatting-preserving edits of `stacker.yml`: they no longer reorder + sections, drop keys not modeled by the CLI (such as `config_contract`), or add + `null`/empty-collection noise. The `# @stacker-origin: marketplace` trust + marker is preserved across edits. +- The `--lock` writeback and `config apply-lock` read `stacker.yml` raw, so + `${VAR}` placeholders in unrelated fields are no longer resolved and baked into + the written file. + +### Changed + +- `config setup server`, `config unlock`, and `config apply-lock` now ask for + confirmation before rewriting `stacker.yml` and refuse to modify it in a + non-interactive session rather than editing it silently. + +## [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 + +- Added `stacker env [environment]` to show or persist the active deploy + environment/profile in `.stacker/active-env`. +- `stacker agent deploy-app` and `stacker secrets push` now accept + `--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 + deployable service/app targets, not only the main app code. Valid targets are + discovered with `stacker secrets apps`. +- Stacker.yml `services:` entries are synced as service targets while the main + `app:` remains the web target, so remote secrets can be scoped to support + services such as `upload`, `worker`, or `postgres`. +- Image-backed services from `deploy.compose_file` are registered as service + targets during cloud/server deploy preparation when they can be represented + safely; build-only and platform-managed services are skipped with warnings. +- Service-scoped remote secrets remain isolated per target and are rendered only + into the matching service; metadata APIs and CLI output still never return + plaintext Vault values. +- CLI help and errors now use "deployable service/app target" wording and list + available targets when an unknown service code is requested. + +### Added — MCP remote service secret tools + +- Added MCP tools for the remote service secret lifecycle: + `list_remote_secret_targets`, `list_remote_service_secrets`, + `get_remote_service_secret`, `set_remote_service_secret`, and + `delete_remote_service_secret`. +- MCP remote secret reads are metadata-only, match the CLI/API target model, and + write secret values directly to Vault without returning plaintext values. +- All MCP tool calls now require explicit per-tool Casbin `CALL` permission under + `/mcp/tools/` before the handler executes; marketplace admin tools + are granted only to `group_admin`. +- Sensitive MCP write/destructive operations, including remote secret writes and + deletes, additionally require a token or auth profile with verified 2FA/MFA + before Vault or deployment state is touched. + +### Added — Cloud provider firewall operations + +- Added `stacker cloud firewall add|remove|list` for cloud-provider firewall + changes that do not require SSH access to the server. +- Added universal Stacker-to-Install-Service protocol + `stacker.cloud_firewall.v1` with shared firewall rule parsing reused from the + existing agent firewall flow. +- Added `POST /server/{id}/cloud-firewall` and Install Service MQ routing + `install.firewall.{provider}.v1`; Hetzner (`htz`) is the first supported + provider. +- Casbin/sqlx migration `20260717120016_casbin_cloud_firewall` grants + `root`, `group_admin`, and `group_user` access to modify cloud firewalls. + +### Added — Cloud deploy local SSH backup access + +- `stacker deploy --target cloud` now creates or reuses a local Ed25519 backup + key under the Stacker config directory and authorizes it on the deployed + server when possible. +- New server API endpoint `POST /server/{id}/ssh-key/authorize-public-key` + accepts caller-provided public key material, validates ownership and key + format, then uses the server-side Vault key to append it to + `authorized_keys` idempotently. +- The Vault private key is never returned to the CLI or written to local files; + the CLI sends only the local public key and prints a copy-paste-ready `ssh` + command after successful authorization. +- `stacker ssh-key inject` remains the repair path for using an + already-working private key to re-add the Vault public key to a server. +- Casbin/sqlx migration `20260717120014_casbin_server_ssh_authorize_public_key` + grants `group_user` and `root` access to the new authorization endpoint. + +### Fixed — Managed Nginx Proxy Manager duplication + +- Cloud/server project bodies no longer add `nginx_proxy_manager` as a project + app when proxy support is already managed through `extended_features`. +- User-declared `nginx_proxy_manager` / NPM services are skipped from project app + sync when `proxy.type` is `nginx` or `nginx-proxy-manager`, preventing the + duplicate `project-nginx_proxy_manager-*` container alongside the managed NPM + container. +- Server-side project app sync, compose child-service discovery, agent snapshots, + and deploy-app upserts now treat NPM as platform-managed so older clients or + stale records cannot keep re-registering it as a user app. +- Data migration `20260717120015_cleanup_nginx_proxy_manager_project_apps` + removes existing stale `nginx_proxy_manager` project app rows that caused NPM + to remain visible after upgrading. +- `stacker agent status` hides stale project-scoped platform containers such as + `project-nginx_proxy_manager-*` while still showing the managed + `nginx-proxy-manager` container. + +### Fixed — Compose public ports in cloud firewall + +- Cloud/server deploy now reads published ports from the resolved Compose file + and passes them into project metadata before invoking the installer, so + provider firewalls receive app ports such as Coolify's `8000:8080` instead of + the generic custom-app fallback `8080`. + +### Fixed — Deployment IP persistence on paused/failed installs + +- Cloud/server deploy status handling now extracts a server IP from installer + progress messages such as `178.104.222.170: Copy files is done` when the + structured server record has not populated `srv_ip` yet. +- The CLI saves that fallback IP into the local deployment context, and the MQ + listener persists the IP server-side so paused or failed deployments still + retain a usable host address for SSH repair and retry workflows. + +### Changed — Agent proxy SSL control + +- `stacker agent configure-proxy` now supports `--no-ssl` to create or update a + plain HTTP Nginx Proxy Manager host without requesting a Let's Encrypt + certificate. + +### Changed — Server bootstrap SSH key handling + +- `stacker deploy --target server` now treats a user-provided `deploy.server.ssh_key` as a + **transient bootstrap credential** for first SSH contact only. +- Stacker now persists only a **Stacker-managed generated deploy key** in Vault for ongoing + server-side automation and later redeploys. +- The user-provided bootstrap key is still sent for the live bootstrap run, but it is no longer + stored as the server's long-term key material on the Stacker side. + +### Added — Local Pipe Mode + +- **`stacker target [local|cloud|server]`** — switch deployment target mode; persists in `.stacker/active-target` +- **Local pipe creation** — `stacker pipe create` works without a cloud deployment (`deployment_hash` is now optional, `is_local` flag on PipeInstance/PipeExecution) +- **Local scanning** — `stacker pipe scan` now performs local endpoint/resource discovery on matched containers instead of only listing Docker inventory +- **Explicit scan modes** — `stacker pipe scan --containers [FILTER]` for local container discovery + probing and `stacker pipe scan --app [--container ]` for remote endpoint probing +- **Local triggering** — `stacker pipe trigger` executes via `docker exec` / HTTP against local containers +- **`stacker pipe deploy --deployment `** — promote a local pipe to a remote deployment (clones config to new remote instance) +- **`GET /api/v1/pipes/instances/local`** — list local pipe instances for the authenticated user +- **`POST /api/v1/pipes/instances/{id}/deploy`** — deploy (promote) local pipe to remote +- **`stacker init --target local`** — initialize project in local mode directly +- Database migration: `deployment_hash` nullable, `is_local BOOLEAN DEFAULT FALSE`, partial index on local instances + +### 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 — 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 `. +- Deploy-app now preserves the shared project `.env` in the config bundle when + the selected runtime topology uses root `env_file: .env`. +- 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. +- 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. + +### 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.7] — 2026-04-10 ### Security — IDOR Hardening & Test Coverage @@ -31,6 +364,8 @@ All notable changes to this project will be documented in this file. - Local deploys no longer overwrite cloud/server connection details - Existing `deployment.lock` files automatically migrated on read +## [0.2.6] — 2026-04-08 + ### Added — Kata Containers Runtime Support - `runtime` field on `deploy_app` and `deploy_with_configs` agent commands — values: `runc` (default), `kata` @@ -63,8 +398,6 @@ All notable changes to this project will be documented in this file. - REST API: `POST/GET/DELETE /api/v1/pipes/templates` and `/instances` - Data contracts with validation for probe_endpoints command parameters and results -## [0.2.6] — 2026-03-17 - ### Added — Marketplace Developer & Buyer Flows - New `stacker submit` command — packages the current stack and submits to marketplace for review @@ -78,6 +411,8 @@ All notable changes to this project will be documented in this file. - `GET /api/v1/marketplace/download/{purchase_token}` — serves stack archive - `POST /api/v1/marketplace/agents/register` — agent self-registration endpoint +## [0.2.6] — 2026-03-11 + ### Added — Firewall (iptables) Management - New MCP tools for configuring iptables firewall rules on remote servers: @@ -385,6 +720,7 @@ stacker init --with-ai # no Ollama running → template fallback #### REST API Routes (`/project/{id}/apps/*`) - `GET /project/{id}/apps` - List all apps for a project - `GET /project/{id}/apps/{code}` - Get single app details +- `DELETE /project/{id}/apps/{code}` - Delete a saved app from a project - `GET /project/{id}/apps/{code}/config` - Get full app configuration - `GET /project/{id}/apps/{code}/env` - Get environment variables (sensitive values redacted) - `PUT /project/{id}/apps/{code}/env` - Update environment variables @@ -471,4 +807,3 @@ stacker init --with-ai # no Ollama running → template fallback ### Fixed - Status panel command updates query uses explicit bindings to avoid SQLx type inference errors. - diff --git a/Cargo.lock b/Cargo.lock index dc382334..db3d7dda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,19 +58,42 @@ dependencies = [ [[package]] name = "actix-cors" -version = "0.6.5" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0346d8c1f762b41b458ed3145eea914966bb9ad20b9be0d6d463b20d45586370" +checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" dependencies = [ "actix-utils", "actix-web", - "derive_more 0.99.20", + "derive_more", "futures-util", "log", "once_cell", "smallvec", ] +[[package]] +name = "actix-files" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags 2.11.0", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + [[package]] name = "actix-http" version = "3.12.0" @@ -86,7 +109,7 @@ dependencies = [ "brotli 8.0.2", "bytes", "bytestring", - "derive_more 2.1.1", + "derive_more", "encoding_rs", "flate2", "foldhash", @@ -201,7 +224,7 @@ dependencies = [ "bytestring", "cfg-if", "cookie", - "derive_more 2.1.1", + "derive_more", "encoding_rs", "foldhash", "futures-core", @@ -282,6 +305,16 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -289,22 +322,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", + "zeroize", +] + [[package]] name = "aes-gcm" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +dependencies = [ + "aead 0.6.1", + "aes 0.9.1", + "cipher 0.5.2", + "ctr 0.10.1", + "ghash 0.6.0", "subtle", + "zeroize", ] [[package]] @@ -372,7 +432,7 @@ dependencies = [ "amq-protocol-types", "amq-protocol-uri", "cookie-factory", - "nom", + "nom 7.1.3", "serde", ] @@ -394,7 +454,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf99351d92a161c61ec6ecb213bc7057f5b837dd4e64ba6cb6491358efd770c4" dependencies = [ "cookie-factory", - "nom", + "nom 7.1.3", "serde", "serde_json", ] @@ -486,13 +546,13 @@ dependencies = [ [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0-rc.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures 0.3.0", "password-hash", ] @@ -505,7 +565,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 2.0.18", @@ -560,6 +620,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" @@ -583,6 +653,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" @@ -597,6 +679,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" @@ -617,11 +714,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" @@ -680,6 +800,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" @@ -692,6 +858,74 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-task" version = "4.7.1" @@ -753,6 +987,51 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "backon" version = "1.6.0" @@ -800,13 +1079,13 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bcrypt-pbkdf" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" dependencies = [ "blowfish", - "pbkdf2", - "sha2 0.10.9", + "pbkdf2 0.13.0", + "sha2 0.11.0", ] [[package]] @@ -826,11 +1105,11 @@ dependencies = [ [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0-rc.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" dependencies = [ - "digest 0.10.7", + "digest 0.11.2", ] [[package]] @@ -849,6 +1128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -860,6 +1140,15 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -875,12 +1164,12 @@ dependencies = [ [[package]] name = "blowfish" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" dependencies = [ "byteorder", - "cipher", + "cipher 0.5.2", ] [[package]] @@ -1038,7 +1327,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", ] [[package]] @@ -1067,13 +1365,25 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.9.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cipher", - "cpufeatures 0.2.17", + "cipher 0.5.2", + "cpufeatures 0.3.0", + "rand_core 0.10.1", + "zeroize", +] + +[[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]] @@ -1119,7 +1429,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "inout 0.2.2", + "zeroize", ] [[package]] @@ -1142,6 +1464,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim 0.11.1", + "terminal_size", ] [[package]] @@ -1182,9 +1505,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.0-pre.0" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5417da527aa9bf6a1e10a781231effd1edd3ee82f27d5f8529ac9b279babce96" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "cms" @@ -1228,7 +1551,23 @@ dependencies = [ ] [[package]] -name = "concurrent-queue" +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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" @@ -1245,7 +1584,7 @@ dependencies = [ "async-trait", "json5", "lazy_static", - "nom", + "nom 7.1.3", "pathdiff", "ron", "rust-ini", @@ -1268,6 +1607,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1300,12 +1651,6 @@ dependencies = [ "tiny-keccak", ] -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "convert_case" version = "0.10.0" @@ -1359,15 +1704,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "core-models" -version = "0.0.4" +name = "cpubits" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0940496e5c83c54f3b753d5317daec82e8edac71c33aaa1f666d76f518de2444" -dependencies = [ - "hax-lib", - "pastey", - "rand 0.9.2", -] +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" [[package]] name = "cpufeatures" @@ -1474,14 +1814,18 @@ dependencies = [ [[package]] name = "crypto-bigint" -version = "0.7.0-rc.18" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37387ceb32048ff590f2cbd24d8b05fffe63c3f69a5cfa089d4f722ca4385a19" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ + "cpubits", "ctutils", + "getrandom 0.4.2", + "hybrid-array", "num-traits", - "rand_core 0.10.0-rc-3", + "rand_core 0.10.1", "serdect", + "subtle", "zeroize", ] @@ -1498,22 +1842,23 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.2", "hybrid-array", + "rand_core 0.10.1", ] [[package]] name = "crypto-primes" -version = "0.7.0-pre.6" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79c98a281f9441200b24e3151407a629bfbe720399186e50516da939195e482" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" dependencies = [ - "crypto-bigint 0.7.0-rc.18", - "libm", - "rand_core 0.10.0-rc-3", + "crypto-bigint 0.7.5", + "rand_core 0.10.1", ] [[package]] @@ -1522,16 +1867,83 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", ] [[package]] name = "ctutils" -version = "0.3.2" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758e5ed90be3c8abff7f9a6f37ab7f6d8c59c2210d448b81f3f508134aec84e4" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", + "subtle", +] + +[[package]] +name = "cucumber" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16cbb27bc2064274afa3a3d8bc9a0e71333589850573aa632ec4520e4af14d94" +dependencies = [ + "anyhow", + "clap", + "console 0.16.3", + "cucumber-codegen", + "cucumber-expressions", + "derive_more", + "either", + "futures", + "gherkin", + "globwalk", + "humantime", + "inventory", + "itertools 0.14.0", + "linked-hash-map", + "pin-project", + "ref-cast", + "regex", + "sealed", + "smart-default", +] + +[[package]] +name = "cucumber-codegen" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a1afaf9c422380861111c6be56f39b324e351fd9efc07a1486268798bf79cfd" +dependencies = [ + "cucumber-expressions", + "inflections", + "itertools 0.14.0", + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", + "synthez", +] + +[[package]] +name = "cucumber-expressions" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6401038de3af44fe74e6fccdb8a5b7db7ba418f480c8e9ad584c6f65c05a27a6" +dependencies = [ + "derive_more", + "either", + "nom 8.0.0", + "nom_locate", + "regex", + "regex-syntax", ] [[package]] @@ -1544,7 +1956,22 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.2", + "fiat-crypto 0.3.0", "rustc_version", "subtle", "zeroize", @@ -1703,7 +2130,7 @@ checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -1791,19 +2218,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -1819,7 +2233,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case 0.10.0", + "convert_case", "proc-macro2", "quote", "rustc_version", @@ -1833,7 +2247,16 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher 0.5.2", ] [[package]] @@ -1848,7 +2271,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ - "console", + "console 0.15.11", "fuzzy-matcher", "shell-words", "tempfile", @@ -1882,7 +2305,8 @@ checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", - "crypto-common 0.2.1", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1915,7 +2339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d6fdd6fa1c9e8e716f5f73406b868929f468702449621e7397066478b9bf89c" dependencies = [ "derive_builder 0.13.1", - "indexmap", + "indexmap 2.14.0", "serde", "serde_yaml", ] @@ -1940,34 +2364,71 @@ checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der 0.7.10", "digest 0.10.7", - "elliptic-curve", - "rfc6979", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", "signature 2.2.0", "spki 0.7.3", ] +[[package]] +name = "ecdsa" +version = "0.17.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54fb064faabbee66e1fc8e5c5a9458d4269dc2d8b638fe86a425adb2510d1a96" +dependencies = [ + "der 0.8.0", + "digest 0.11.2", + "elliptic-curve 0.14.0-rc.33", + "rfc6979 0.5.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", +] + [[package]] name = "ed25519" version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8 0.10.2", "signature 2.2.0", ] +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "signature 3.0.0", +] + [[package]] name = "ed25519-dalek" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core 0.6.4", - "serde", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", "sha2 0.10.9", "subtle", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" +dependencies = [ + "curve25519-dalek 5.0.0-rc.0", + "ed25519 3.0.0", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", "zeroize", ] @@ -1989,18 +2450,55 @@ dependencies = [ "base16ct 0.2.0", "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", + "ff 0.13.1", "generic-array 0.14.7", - "group", - "hkdf", - "pem-rfc7468 0.7.0", + "group 0.13.0", "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.0-rc.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "102d3643d30dd8b559613c5cced68317199597fffb278cdc88daa2ef7fafc935" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.2", + "ff 0.14.0", + "group 0.14.0", + "hkdf 0.13.0", + "hybrid-array", + "once_cell", + "pem-rfc7468 1.0.0", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", "subtle", "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" @@ -2134,12 +2632,28 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "filetime" version = "0.2.27" @@ -2433,10 +2947,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "wasm-bindgen", ] [[package]] @@ -2460,10 +2972,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -2473,7 +2988,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.1", +] + +[[package]] +name = "gherkin" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70197ce7751bfe8bc828e3a855502d3a869a1e9416b58b10c4bde5cf8a0a3cb3" +dependencies = [ + "heck", + "peg", + "quote", + "serde", + "serde_json", + "syn 2.0.117", + "textwrap", + "thiserror 2.0.18", + "typed-builder", ] [[package]] @@ -2506,17 +3047,40 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" version = "0.3.27" @@ -2529,7 +3093,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2548,7 +3112,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2586,9 +3150,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -2608,43 +3172,6 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "hax-lib" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d9ba66d1739c68e0219b2b2238b5c4145f491ebf181b9c6ab561a19352ae86" -dependencies = [ - "hax-lib-macros", - "num-bigint", - "num-traits", -] - -[[package]] -name = "hax-lib-macros" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ba777a231a58d1bce1d68313fa6b6afcc7966adef23d60f45b8a2b9b688bf1" -dependencies = [ - "hax-lib-macros-types", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "hax-lib-macros-types" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "867e19177d7425140b417cd27c2e05320e727ee682e98368f88b7194e80ad515" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "serde_json", - "uuid", -] - [[package]] name = "heck" version = "0.5.0" @@ -2671,9 +3198,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hex-literal" -version = "0.4.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" [[package]] name = "hkdf" @@ -2681,7 +3208,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -2694,10 +3230,19 @@ dependencies = [ ] [[package]] -name = "home" -version = "0.5.12" +name = "hmac" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ "windows-sys 0.61.2", ] @@ -2757,6 +3302,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "http-types" version = "2.12.0" @@ -2799,13 +3350,22 @@ dependencies = [ "libm", ] +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ + "ctutils", + "subtle", "typenum", + "zeroize", ] [[package]] @@ -2853,6 +3413,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper 0.14.32", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -3035,6 +3607,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" @@ -3043,12 +3624,22 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" [[package]] name = "indexmap" -version = "2.13.1" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -3059,7 +3650,7 @@ version = "0.17.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" dependencies = [ - "console", + "console 0.15.11", "number_prefix", "portable-atomic", "unicode-width", @@ -3072,16 +3663,32 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + [[package]] name = "inout" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", + "block-padding 0.3.3", "generic-array 0.14.7", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -3092,34 +3699,24 @@ dependencies = [ ] [[package]] -name = "internal-russh-forked-ssh-key" -version = "0.6.16+upstream-0.6.7" +name = "internal-russh-num-bigint" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe44f2bbd99fcb302e246e2d6bcf51aeda346d02a365f80296a07a8c711b6da6" +checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" dependencies = [ - "argon2", - "bcrypt-pbkdf", - "digest 0.11.2", - "ecdsa", - "ed25519-dalek", - "hex", - "hmac", - "num-bigint-dig", - "p256", - "p384", - "p521", - "rand_core 0.6.4", - "rsa 0.10.0-rc.12", - "sec1", - "sha1 0.10.6", - "sha1 0.11.0", - "sha2 0.10.9", - "signature 2.2.0", - "signature 3.0.0-rc.6", - "ssh-cipher", - "ssh-encoding", - "subtle", - "zeroize", + "num-integer", + "num-traits", + "rand 0.10.1", + "rand_core 0.10.1", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", ] [[package]] @@ -3183,6 +3780,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -3222,6 +3828,35 @@ dependencies = [ "serde", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + +[[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" @@ -3267,76 +3902,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "libc" -version = "0.2.184" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" - -[[package]] -name = "libcrux-intrinsics" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9ee7ef66569dd7516454fe26de4e401c0c62073929803486b96744594b9632" -dependencies = [ - "core-models", - "hax-lib", -] - -[[package]] -name = "libcrux-ml-kem" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6a88086bf11bd2ec90926c749c4a427f2e59841437dbdede8cde8a96334ab" -dependencies = [ - "hax-lib", - "libcrux-intrinsics", - "libcrux-platform", - "libcrux-secrets", - "libcrux-sha3", - "libcrux-traits", - "rand 0.9.2", - "tls_codec", -] - -[[package]] -name = "libcrux-platform" -version = "0.0.2" +name = "lettre" +version = "0.11.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db82d058aa76ea315a3b2092f69dfbd67ddb0e462038a206e1dcd73f058c0778" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" dependencies = [ - "libc", -] - -[[package]] -name = "libcrux-secrets" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4dbbf6bc9f2bc0f20dc3bea3e5c99adff3bdccf6d2a40488963da69e2ec307" -dependencies = [ - "hax-lib", -] - -[[package]] -name = "libcrux-sha3" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2400bec764d1c75b8a496d5747cffe32f1fb864a12577f0aca2f55a92021c962" -dependencies = [ - "hax-lib", - "libcrux-intrinsics", - "libcrux-platform", - "libcrux-traits", + "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 = "libcrux-traits" -version = "0.0.4" +name = "libc" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9adfd58e79d860f6b9e40e35127bfae9e5bd3ade33201d1347459011a2add034" -dependencies = [ - "libcrux-secrets", - "rand 0.9.2", -] +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -3378,6 +3974,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3421,6 +4023,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" @@ -3431,6 +4047,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "md-5" version = "0.10.6" @@ -3443,9 +4065,9 @@ dependencies = [ [[package]] name = "md5" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" [[package]] name = "memchr" @@ -3459,6 +4081,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mini-moka" version = "0.10.3" @@ -3512,6 +4144,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sha3", +] + [[package]] name = "mockito" version = "1.7.2" @@ -3537,6 +4183,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "mutually_exclusive_features" version = "0.1.0" @@ -3562,9 +4225,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.29.0" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.11.0", "cfg-if", @@ -3591,6 +4254,26 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -3614,7 +4297,6 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", - "rand 0.8.5", ] [[package]] @@ -3629,7 +4311,6 @@ dependencies = [ "num-iter", "num-traits", "rand 0.8.5", - "serde", "smallvec", "zeroize", ] @@ -3788,14 +4469,14 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cae83056e7cb770211494a0ecf66d9fa7eba7d00977e5bb91f0e925b40b937f" dependencies = [ - "cbc", + "cbc 0.1.2", "cms", "der 0.7.10", - "des", + "des 0.8.1", "hex", - "hmac", + "hmac 0.12.1", "pkcs12", - "pkcs5", + "pkcs5 0.7.1", "rand 0.9.2", "rc2", "sha1 0.10.6", @@ -3810,24 +4491,51 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", "sha2 0.10.9", ] +[[package]] +name = "p256" +version = "0.14.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41adc63effe99d48837a8cc0e6d7a77e32ae6a07f6000df466178dbc2193093e" +dependencies = [ + "ecdsa 0.17.0-rc.18", + "elliptic-curve 0.14.0-rc.33", + "primefield", + "primeorder 0.14.0-rc.10", + "sha2 0.11.0", +] + [[package]] name = "p384" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", "sha2 0.10.9", ] +[[package]] +name = "p384" +version = "0.14.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd5333afa5ae0347f39e6a0f2c9c155da431583fd71fe5555bd0521b4ccaf02" +dependencies = [ + "ecdsa 0.17.0-rc.18", + "elliptic-curve 0.14.0-rc.33", + "fiat-crypto 0.3.0", + "primefield", + "primeorder 0.14.0-rc.10", + "sha2 0.11.0", +] + [[package]] name = "p521" version = "0.13.3" @@ -3835,13 +4543,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" dependencies = [ "base16ct 0.2.0", - "ecdsa", - "elliptic-curve", - "primeorder", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", "rand_core 0.6.4", "sha2 0.10.9", ] +[[package]] +name = "p521" +version = "0.14.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a5297f53dc16d35909060ba3032cff7867e8809f01e273ff325579d5f0ceae" +dependencies = [ + "base16ct 1.0.0", + "ecdsa 0.17.0-rc.18", + "elliptic-curve 0.14.0-rc.33", + "primefield", + "primeorder 0.14.0-rc.10", + "sha2 0.11.0", +] + [[package]] name = "pageant" version = "0.2.0" @@ -3901,13 +4623,11 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "phc", ] [[package]] @@ -3916,12 +4636,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - [[package]] name = "pathdiff" version = "0.2.3" @@ -3935,9 +4649,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", ] +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.2", + "hmac 0.13.0", +] + +[[package]] +name = "peg" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f76678828272f177ac33b7e2ac2e3e73cc6c1cd1e3e387928aa69562fa51367" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636d60acf97633e48d266d7415a9355d4389cea327a193f87df395d88cd2b14d" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555b1514d2d99d78150d3c799d4c357a3e2c2a8062cd108e93a06d9057629c5" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -4012,7 +4763,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.14.0", +] + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", ] [[package]] @@ -4079,6 +4840,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" @@ -4091,6 +4858,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" @@ -4144,15 +4940,32 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" dependencies = [ - "aes", - "cbc", + "aes 0.8.4", + "cbc 0.1.2", "der 0.7.10", - "pbkdf2", - "scrypt", + "pbkdf2 0.12.2", + "scrypt 0.11.0", "sha2 0.10.9", "spki 0.7.3", ] +[[package]] +name = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes 0.9.1", + "aes-gcm 0.11.0", + "cbc 0.2.1", + "der 0.8.0", + "pbkdf2 0.13.0", + "rand_core 0.10.1", + "scrypt 0.12.0", + "sha2 0.11.0", + "spki 0.8.0", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -4160,18 +4973,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der 0.7.10", - "pkcs5", - "rand_core 0.6.4", "spki 0.7.3", ] [[package]] name = "pkcs8" -version = "0.11.0-rc.11" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ "der 0.8.0", + "pkcs5 0.8.1", + "rand_core 0.10.1", "spki 0.8.0", ] @@ -4219,13 +5032,13 @@ dependencies = [ [[package]] name = "poly1305" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +checksum = "a00baa632505d05512f48a963e16051c54fda9a95cc9acea1a4e3c90991c4a2e" dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", + "zeroize", ] [[package]] @@ -4237,7 +5050,18 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", ] [[package]] @@ -4310,13 +5134,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "primeorder" version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.13.8", +] + +[[package]] +name = "primeorder" +version = "0.14.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d2793f22b9b6fd11ef3ac1d59bf003c2573593e4968702341605c2748fd90bf" +dependencies = [ + "elliptic-curve 0.14.0-rc.33", ] [[package]] @@ -4344,36 +5191,177 @@ dependencies = [ ] [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "proc-macro2" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ - "proc-macro2", - "quote", + "unicode-ident", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "procfs" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "731e0d9356b0c25f16f33b5be79b1c57b562f141ebfcdb0ad8ac2c13a24293b4" +dependencies = [ + "bitflags 2.11.0", + "hex", + "lazy_static", + "procfs-core", + "rustix 0.38.44", +] + +[[package]] +name = "procfs-core" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" +dependencies = [ + "bitflags 2.11.0", + "hex", +] + +[[package]] +name = "prometheus" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "libc", + "memchr", + "parking_lot", + "procfs", + "protobuf", + "thiserror 1.0.69", +] + +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes", + "heck", + "itertools 0.12.1", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ - "proc-macro-error-attr2", + "anyhow", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.117", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "prost-types" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" dependencies = [ - "unicode-ident", + "prost", +] + +[[package]] +name = "protobuf" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", ] +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + [[package]] name = "pulldown-cmark" version = "0.9.6" @@ -4394,6 +5382,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" @@ -4440,6 +5434,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -4499,9 +5504,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0-rc-3" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66ee92bc15280519ef199a274fe0cafff4245d31bc39aaa31c011ad56cb1f05" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_hc" @@ -4518,7 +5523,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -4576,6 +5581,26 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -4643,10 +5668,12 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", + "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "winreg", ] @@ -4663,7 +5690,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "rfc6979" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5236ce872cac07e0fb3969b0cbf468c7d2f37d432f1b627dcb7b8d34563fb0c3" +dependencies = [ + "hmac 0.13.0", "subtle", ] @@ -4745,94 +5782,105 @@ dependencies = [ [[package]] name = "rsa" -version = "0.10.0-rc.12" +version = "0.10.0-rc.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9a2b1eacbc34fbaf77f6f1db1385518446008d49b9f9f59dc9d1340fce4ca9e" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" dependencies = [ "const-oid 0.10.2", - "crypto-bigint 0.7.0-rc.18", + "crypto-bigint 0.7.5", "crypto-primes", "digest 0.11.2", "pkcs1 0.8.0-rc.4", - "pkcs8 0.11.0-rc.11", - "rand_core 0.10.0-rc-3", + "pkcs8 0.11.0", + "rand_core 0.10.1", "sha2 0.11.0", - "signature 3.0.0-rc.6", + "signature 3.0.0", "spki 0.8.0", "zeroize", ] [[package]] name = "russh" -version = "0.58.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30f6ce4f5d5105b934cfb4b8b3028aab4d5dcdff863cb8dda9edd06d39b8c4e8" +checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0" dependencies = [ - "aes", + "aes 0.9.1", "aws-lc-rs", "bitflags 2.11.0", - "block-padding", + "block-padding 0.4.2", "byteorder", "bytes", - "cbc", - "ctr", - "curve25519-dalek", + "cbc 0.2.1", + "cipher 0.5.2", + "crypto-bigint 0.7.5", + "ctr 0.10.1", + "curve25519-dalek 5.0.0-rc.0", "data-encoding", "delegate", - "der 0.7.10", - "digest 0.10.7", - "ecdsa", - "ed25519-dalek", - "elliptic-curve", + "der 0.8.0", + "digest 0.11.2", + "ecdsa 0.17.0-rc.18", + "ed25519-dalek 3.0.0-rc.0", + "elliptic-curve 0.14.0-rc.33", "enum_dispatch", "flate2", "futures", "generic-array 1.3.5", - "getrandom 0.2.17", + "getrandom 0.4.2", + "ghash 0.6.0", "hex-literal", - "hmac", - "inout", - "internal-russh-forked-ssh-key", - "libcrux-ml-kem", + "hmac 0.13.0", + "inout 0.2.2", + "internal-russh-num-bigint", + "keccak", "log", "md5", + "ml-kem", + "module-lattice", "num-bigint", - "p256", - "p384", - "p521", + "p256 0.14.0-rc.10", + "p384 0.14.0-rc.10", + "p521 0.14.0-rc.10", "pageant", - "pbkdf2", + "pbkdf2 0.13.0", "pkcs1 0.8.0-rc.4", - "pkcs5", - "pkcs8 0.10.2", - "rand 0.9.2", - "rand_core 0.10.0-rc-3", - "rsa 0.10.0-rc.12", + "pkcs5 0.8.1", + "pkcs8 0.11.0", + "polyval 0.7.1", + "rand 0.10.1", + "rand_core 0.10.1", + "rsa 0.10.0-rc.18", "russh-cryptovec", "russh-util", - "sec1", - "sha1 0.10.6", - "sha2 0.10.9", - "signature 2.2.0", - "spki 0.7.3", - "ssh-encoding", + "salsa20 0.11.0", + "scrypt 0.12.0", + "sec1 0.8.1", + "sha1 0.11.0", + "sha2 0.11.0", + "sha3", + "signature 3.0.0", + "spki 0.8.0", + "ssh-encoding 0.3.0-rc.9", + "ssh-key 0.7.0-rc.10", "subtle", "thiserror 2.0.18", "tokio", "typenum", + "universal-hash 0.6.1", "zeroize", ] [[package]] name = "russh-cryptovec" -version = "0.58.0" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc109749696a6853cf2c3fba3537635264f3519a78a9f43c6b08c91edc024384" +checksum = "443f6bbcfacb34a1aab2b12b99bf08e0c63abdc5a0db261901365df9d57fff51" dependencies = [ "log", "nix", - "ssh-encoding", - "windows-sys 0.59.0", + "ssh-encoding 0.3.0-rc.9", + "windows-sys 0.61.2", ] [[package]] @@ -4872,7 +5920,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -4889,6 +5937,19 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4902,16 +5963,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] @@ -4923,10 +5999,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70cc376c6ba1823ae229bacf8ad93c136d93524eab0e4e5e0e4f96b9c4e5b212" dependencies = [ "log", - "rustls", + "rustls 0.23.37", "rustls-native-certs", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.10", ] [[package]] @@ -4969,6 +6045,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustls-webpki" version = "0.103.10" @@ -4998,7 +6085,17 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", ] [[package]] @@ -5031,11 +6128,34 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "pbkdf2", - "salsa20", + "pbkdf2 0.12.2", + "salsa20 0.10.2", "sha2 0.10.9", ] +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2 0.13.0", + "salsa20 0.11.0", + "sha2 0.11.0", +] + +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sec1" version = "0.7.3" @@ -5050,6 +6170,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.0", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -5086,6 +6220,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" @@ -5179,7 +6319,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70c0e00fab6460447391a1981c21341746bc2d0178a7c46a3bbf667f450ac6e4" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itertools 0.12.1", "num-traits", "once_cell", @@ -5223,7 +6363,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -5290,6 +6430,16 @@ dependencies = [ "digest 0.11.2", ] +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.2", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -5333,12 +6483,12 @@ dependencies = [ [[package]] name = "signature" -version = "3.0.0-rc.6" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a96996ccff7dfa16f052bd995b4cecc72af22c35138738dc029f0ead6608d" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ "digest 0.11.2", - "rand_core 0.10.0-rc-3", + "rand_core 0.10.1", ] [[package]] @@ -5437,6 +6587,17 @@ dependencies = [ "serde", ] +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "smartstring" version = "1.0.1" @@ -5449,6 +6610,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.4.10" @@ -5558,14 +6725,14 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap", + "indexmap 2.14.0", "ipnetwork", "log", "memchr", "native-tls", "once_cell", "percent-encoding", - "rustls", + "rustls 0.23.37", "serde", "serde_json", "sha2 0.10.9", @@ -5639,8 +6806,8 @@ dependencies = [ "futures-util", "generic-array 0.14.7", "hex", - "hkdf", - "hmac", + "hkdf 0.12.4", + "hmac 0.12.1", "itoa", "log", "md-5", @@ -5679,8 +6846,8 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf", - "hmac", + "hkdf 0.12.4", + "hmac 0.12.1", "home", "ipnetwork", "itoa", @@ -5733,15 +6900,28 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" dependencies = [ - "aes", - "aes-gcm", - "cbc", + "cipher 0.4.4", + "ssh-encoding 0.2.0", +] + +[[package]] +name = "ssh-cipher" +version = "0.3.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10db6f219196a8528f9ec904d9d45cdad692d65b0e57e72be4dedd1c5fddce36" +dependencies = [ + "aead 0.6.1", + "aes 0.9.1", + "aes-gcm 0.11.0", + "cbc 0.2.1", "chacha20", - "cipher", - "ctr", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "des 0.9.0", "poly1305", - "ssh-encoding", - "subtle", + "ssh-encoding 0.3.0-rc.9", + "zeroize", ] [[package]] @@ -5751,32 +6931,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" dependencies = [ "base64ct", - "bytes", "pem-rfc7468 0.7.0", "sha2 0.10.9", ] +[[package]] +name = "ssh-encoding" +version = "0.3.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abf34aa716da5d5b4c496936d042ea282ab392092cd68a72ef6a8863ff8c96a" +dependencies = [ + "base64ct", + "bytes", + "crypto-bigint 0.7.5", + "ctutils", + "digest 0.11.2", + "pem-rfc7468 1.0.0", + "zeroize", +] + [[package]] name = "ssh-key" version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" dependencies = [ - "ed25519-dalek", - "p256", - "p384", - "p521", + "ed25519-dalek 2.2.0", + "p256 0.13.2", + "p384 0.13.1", + "p521 0.13.3", "rand_core 0.6.4", "rsa 0.9.10", - "sec1", + "sec1 0.7.3", "sha2 0.10.9", "signature 2.2.0", - "ssh-cipher", - "ssh-encoding", + "ssh-cipher 0.2.0", + "ssh-encoding 0.2.0", "subtle", "zeroize", ] +[[package]] +name = "ssh-key" +version = "0.7.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45735ce3dea95690e4a9e414c4cfde7f79835063c3dcd35881df85a84118e74b" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "ctutils", + "ed25519-dalek 3.0.0-rc.0", + "hex", + "hmac 0.13.0", + "p256 0.14.0-rc.10", + "p384 0.14.0-rc.10", + "p521 0.14.0-rc.10", + "rand_core 0.10.1", + "rsa 0.10.0-rc.18", + "sec1 0.8.1", + "sha1 0.11.0", + "sha2 0.11.0", + "signature 3.0.0", + "ssh-cipher 0.3.0-rc.9", + "ssh-encoding 0.3.0-rc.9", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5785,15 +7005,16 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.2.7" +version = "0.3.1" dependencies = [ "actix", "actix-casbin-auth", "actix-cors", + "actix-files", "actix-http", "actix-web", "actix-web-actors", - "aes-gcm", + "aes-gcm 0.10.3", "anyhow", "assert_cmd", "async-trait", @@ -5804,6 +7025,7 @@ dependencies = [ "clap", "clap_complete", "config", + "cucumber", "deadpool-lapin", "derive_builder 0.12.0", "dialoguer", @@ -5814,12 +7036,19 @@ dependencies = [ "futures-lite 2.6.1", "futures-util", "glob", - "hmac", - "indexmap", + "hmac 0.12.1", + "indexmap 2.14.0", "indicatif", "lapin", + "lazy_static", "mockito", + "pipe-adapter-mail", + "pipe-adapter-sdk", "predicates", + "prometheus", + "prost", + "prost-types", + "protoc-bin-vendored", "rand 0.8.5", "redis", "regex", @@ -5834,13 +7063,16 @@ dependencies = [ "sha2 0.10.9", "sqlx", "sqlx-adapter", - "ssh-key", + "ssh-key 0.6.7", "tar", "tempfile", "tera", "thiserror 1.0.69", "tokio", "tokio-stream", + "tokio-tungstenite", + "tonic", + "tonic-build", "tracing", "tracing-actix-web", "tracing-bunyan-formatter", @@ -5849,6 +7081,7 @@ dependencies = [ "urlencoding", "uuid", "wiremock", + "zstd", ] [[package]] @@ -5857,6 +7090,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" @@ -5925,6 +7170,39 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "synthez" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8a928f38f1bc873f28e0d2ba8298ad65374a6ac2241dabd297271531a736cd" +dependencies = [ + "syn 2.0.117", + "synthez-codegen", + "synthez-core", +] + +[[package]] +name = "synthez-codegen" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb83b8df4238e11746984dfb3819b155cd270de0e25847f45abad56b3671047" +dependencies = [ + "syn 2.0.117", + "synthez-core", +] + +[[package]] +name = "synthez-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "906fba967105d822e7c7ed60477b5e76116724d33de68a585681fb253fc30d5c" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn 2.0.117", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -6025,12 +7303,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thin-vec" version = "0.2.15" @@ -6154,27 +7453,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "tls_codec" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" -dependencies = [ - "tls_codec_derive", - "zeroize", -] - -[[package]] -name = "tls_codec_derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "tokio" version = "1.51.1" @@ -6203,6 +7481,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -6224,6 +7512,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "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" @@ -6235,6 +7544,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -6257,6 +7580,75 @@ dependencies = [ "serde", ] +[[package]] +name = "tonic" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.21.7", + "bytes", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4ef6dd70a610078cb4e338a0f79d06bc759ff1b22d2120c2ff02ae264ba9c2" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -6379,11 +7771,51 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1 0.10.6", + "thiserror 1.0.69", + "url", + "utf-8", +] + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -6409,6 +7841,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -6452,6 +7890,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -6489,6 +7937,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6513,12 +7967,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + [[package]] name = "valuable" 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" @@ -6712,11 +8178,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -6725,7 +8204,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -7135,7 +8614,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -7166,7 +8645,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -7185,7 +8664,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -7222,7 +8701,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "rusticata-macros", "thiserror 2.0.18", @@ -7317,20 +8796,6 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 97b2031c..175071f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,13 @@ [package] name = "stacker" -version = "0.2.7" +version = "0.3.1" edition = "2021" default-run= "server" +[workspace] +members = ["crates/pipe-adapter-sdk", "crates/pipe-adapter-mail"] +resolver = "2" + [lib] path="src/lib.rs" @@ -20,6 +24,10 @@ required-features = ["explain"] path = "src/bin/stacker.rs" name = "stacker-cli" +[[bin]] +path = "src/bin/agent_executor.rs" +name = "agent-executor" + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] @@ -28,7 +36,7 @@ actix = "0.13.5" actix-web-actors = "4.3.1" chrono = { version = "0.4.39", features = ["serde", "clock"] } config = "0.13.4" -reqwest = { version = "0.11.23", features = ["json", "blocking"] } +reqwest = { version = "0.11.23", features = ["json", "blocking", "stream", "native-tls"] } serde = { version = "1.0.195", features = ["derive"] } tokio = { version = "1.28.1", features = ["full"] } tracing = { version = "0.1.40", features = ["log"] } @@ -42,7 +50,8 @@ serde_valid = "0.18.0" serde_json = { version = "1.0.111", features = [] } async-trait = "0.1.77" serde_derive = "1.0.195" -actix-cors = "0.6.4" +actix-cors = "0.7.0" +actix-files = "0.6.5" tracing-actix-web = "0.7.7" regex = "1.10.2" rand = "0.8.5" @@ -50,7 +59,7 @@ tempfile = "3" flate2 = "1.0" tar = "0.4" ssh-key = { version = "0.6", features = ["ed25519", "rand_core"] } -russh = "0.58" +russh = "0.61" futures-util = "0.3.29" futures = "0.3.29" tokio-stream = "0.1.14" @@ -66,12 +75,13 @@ indexmap = { version = "2.0.0", features = ["serde"], optional = true } serde_yaml = "0.9" lapin = { version = "2.3.1", features = ["serde_json"] } futures-lite = "2.2.0" -clap = { version = "4.4.8", features = ["derive"] } +clap = { version = "4.4.8", features = ["derive", "env"] } clap_complete = "4" dialoguer = { version = "0.11", features = ["fuzzy-select"] } indicatif = "0.17" brotli = "3.4.0" serde_path_to_error = "0.1.14" +zstd = "0.13" deadpool-lapin = "0.12.1" docker-compose-types = "0.7.0" actix-casbin-auth = { git = "https://github.com/casbin-rs/actix-casbin-auth.git"} @@ -81,6 +91,14 @@ base64 = "0.22.1" redis = { version = "0.27.5", features = ["tokio-comp", "connection-manager"] } urlencoding = "2.1.3" tera = "1.19.1" +prometheus = { version = "0.13", features = ["process"] } +lazy_static = "1.4" +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" @@ -99,9 +117,19 @@ default = ["indexmap"] indexmap = ["dep:indexmap"] explain = ["actix-casbin-auth/explain", "actix-casbin-auth/logging"] +[build-dependencies] +protoc-bin-vendored = "3" +tonic-build = "0.11" + [dev-dependencies] glob = "0.3" wiremock = "0.5.22" assert_cmd = "2.0" predicates = "3.0" mockito = "1" +cucumber = "0.22" +futures-util = "0.3" + +[[test]] +name = "bdd" +harness = false diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 412b570f..a805fdbc 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -1,7 +1,7 @@ # Stacker — Build, Deploy & Manage Containerised Apps [![Discord](https://img.shields.io/discord/578119430391988232?label=discord&logo=discord&color=5865F2)](https://discord.gg/mNhsa8VdYX) -[![Version](https://img.shields.io/badge/version-0.2.7-blue)](https://github.com/trydirect/stacker/releases) +[![Version](https://img.shields.io/badge/version-0.2.8-blue)](https://github.com/trydirect/stacker/releases) [![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/trydirect/stacker/blob/main/LICENSE) [![GitHub](https://img.shields.io/badge/source-GitHub-181717?logo=github)](https://github.com/trydirect/stacker) @@ -16,7 +16,7 @@ │ Stacker CLI │────────▶│ Stacker Server │────────▶│ Status Panel Agent │ │ │ REST │ │ queue │ (on target server) │ │ stacker.yml │ API │ Stack Builder UI│ pull │ │ -│ init/deploy │ │ 48+ MCP tools │◀────────│ health / logs / │ +│ init/deploy │ │ 85+ MCP tools │◀────────│ health / logs / │ │ status/logs │ │ Vault · AMQP │ HMAC │ restart / exec / │ └──────────────┘ └──────────────────┘ │ deploy_app / proxy │ │ └─────────────────────┘ @@ -123,12 +123,12 @@ The `trydirect/stacker` image contains the **Stacker Server** — a Rust-built b - Role-based access control (Casbin) ### MCP Server (Model Context Protocol) -48+ tools exposed over WebSocket, enabling AI agents (Claude, GPT, etc.) to manage infrastructure programmatically: +85+ tools exposed over WebSocket, enabling AI agents (Claude, GPT, etc.) to manage infrastructure programmatically: - Project & deployment management - Container operations (start, stop, restart, exec) - Log analysis & error summaries -- Vault config read/write -- Proxy configuration +- Vault config and remote service secret management +- Proxy and firewall configuration - Server resource monitoring - Docker Compose generation & preview @@ -156,6 +156,8 @@ The CLI (`stacker-cli`) is a standalone binary — no server required for local | `stacker deploy` | Build & deploy the stack (local, cloud, or server) | | `stacker status` | Show running containers and health | | `stacker logs` | View container logs (`--follow`, `--service`, `--tail`) | +| `stacker secrets` | Manage local `.env` secrets and remote Vault-backed service/server secrets | +| `stacker cloud firewall` | Manage provider firewall rules without SSH | | `stacker destroy` | Tear down the deployed stack | | `stacker ai ask` | Ask AI about your stack, or let it modify config | | `stacker service add` | Add from 20+ built-in service templates | @@ -286,7 +288,7 @@ Stacker auto-detects and generates optimised multi-stage Dockerfiles for: | Tag | Description | |-----|-------------| | `latest` | Latest stable release | -| `x.y.z` | Specific version (e.g. `0.2.7`) | +| `x.y.z` | Specific version (e.g. `0.2.8`) | | `test` | Development/testing builds | --- diff --git a/Dockerfile b/Dockerfile index 935e1c56..521b9ee5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,23 @@ +# syntax=docker/dockerfile:1.4 FROM rust:bookworm AS builder -#RUN apt-get update; \ -# apt-get install --no-install-recommends -y libssl-dev; \ -# rm -rf /var/lib/apt/lists/*; \ -# USER=root cargo new --bin app; +RUN apt-get update && apt-get install --no-install-recommends -y protobuf-compiler libprotobuf-dev && rm -rf /var/lib/apt/lists/* RUN cargo install sqlx-cli WORKDIR /app +COPY --from=shared_fixtures / /shared-fixtures # copy manifests COPY ./Cargo.toml . COPY ./Cargo.lock . +COPY ./build.rs . COPY ./rustfmt.toml . COPY ./Makefile . COPY ./docker/local/.env . COPY ./docker/local/configuration.yaml . COPY .sqlx .sqlx/ +COPY ./proto ./proto +COPY ./tests/bdd.rs ./tests/bdd.rs # build this project to cache dependencies #RUN sqlx database create && sqlx migrate run @@ -26,6 +28,8 @@ COPY .sqlx .sqlx/ 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 f092c21d..c285c0d3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
Discord -Version +Version License

@@ -13,6 +13,8 @@ Stacker is a platform for turning any project into a deployable Docker stack. Add a `stacker.yml` to your repo, and Stacker generates Dockerfiles, docker-compose definitions, reverse-proxy configs, and deploys locally or to cloud providers — optionally with AI assistance. +**v0.3.0 highlights:** generate `stacker.yml` from any GitHub repo with `stacker init --from-github`, infra-service healthcheck inference, remote Vault-backed secrets for deployable service/app targets, paused or failed cloud/server installs retain discovered IP addresses, and cloud-provider firewalls can be managed without SSH. + ## Quick Start @@ -31,6 +33,28 @@ stacker deploy # builds and runs locally via docker compose stacker status # check running containers ``` +### Deploy from any GitHub repo + +Generate a `stacker.yml` from a GitHub repository — no clone required. +For the best results (port mappings, env vars, service context inferred +from README and source), use `--with-ai`: + +```bash +# AI-powered (recommended — reads README, compose, source files) +stacker init --from-github owner/repo --with-ai + +# Template-based (project type + Dockerfile detection only) +stacker init -g https://github.com/ArchiveBox/ArchiveBox +``` + +With `--with-ai`, Stacker shallow-clones the repo, then uses an LLM (Ollama +by default) to scan project files and generate a context-aware `stacker.yml` +with services, ports, env vars, and healthchecks. Falls back to template +detection if the AI provider is unreachable. + +When environment variables are in the compose file, `.env.example` and +`scripts/generate-secrets.sh` are also generated. + ### AI-powered init (optional) Stacker can scan your project files and use an LLM to generate a tailored `stacker.yml`: @@ -49,6 +73,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 @@ -111,7 +151,7 @@ Full schema reference: [docs/STACKER_YML_REFERENCE.md](docs/STACKER_YML_REFERENC │ Stacker CLI │────────►│ Stacker Server │────────►│ Status Panel Agent │ │ │ REST │ │ queue │ (on target server) │ │ stacker.yml │ API │ Stack Builder UI│ pull │ │ -│ init/deploy │ │ 48+ MCP tools │◄────────│ health / logs / │ +│ init/deploy │ │ 85+ MCP tools │◄────────│ health / logs / │ │ status/logs │ │ Vault · AMQP │ HMAC │ restart / exec / │ └──────────────┘ └──────────────────┘ │ deploy_app / proxy │ │ └─────────────────────┘ @@ -134,38 +174,58 @@ The end-user tool. No server required for local deploys. | Command | Description | |---------|-------------| | `stacker init` | Detect project type, generate `stacker.yml` + `.stacker/` artifacts | -| `stacker deploy` | Build & deploy the stack (local, cloud, or server). `--runtime kata\|runc` selects container runtime | +| `stacker deploy` | Build & deploy the stack (local, cloud, or server). Cloud deploys also install a local SSH backup key when possible. `--runtime kata\|runc` selects container runtime | | `stacker status` | Show running containers and health | | `stacker logs` | View container logs (`--follow`, `--service`, `--tail`) | -| `stacker list deployments` | List deployments on the Stacker server | +| `stacker secrets` | Manage local `.env` secrets or remote Vault-backed `service` / `server` secrets | +| `stacker list deployments` / `stacker deployments` | List deployments on the Stacker server | +| `stacker list servers` / `stacker servers` | List saved servers | +| `stacker list clouds` / `stacker clouds` | List saved cloud credentials | +| `stacker list ssh-keys` / `stacker ssh-keys` | List per-server SSH key status | | `stacker destroy` | Tear down the deployed stack | | `stacker config validate` | Validate `stacker.yml` syntax | | `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 | +| `stacker cloud firewall add` | Open cloud-provider firewall ports without SSH, for example `--public-ports 8000/tcp` on Hetzner | +| `stacker cloud firewall remove` | Remove Stacker-managed cloud-provider firewall rules | +| `stacker cloud firewall list` | List cloud-provider firewall rules for a server | | `stacker ssh-key generate` | Generate a new SSH key pair for a server (Vault-backed) | | `stacker ssh-key show` | Display the public SSH key for a server | | `stacker ssh-key upload` | Upload an existing SSH key pair for a server | +| `stacker ssh-key inject` | Repair Vault-key trust by using an already-working private key to update `authorized_keys` | | `stacker service add` | Add a service from the template catalog to `stacker.yml` | | `stacker service list` | List available service templates (20+ built-in) | | `stacker agent health` | Check Status Panel agent connectivity and health | | `stacker agent status` | Display agent snapshot — containers, versions, uptime | +| `stacker agent list apps` / `stacker agent apps` | List apps for the target deployment | +| `stacker agent list containers` / `stacker agent containers` | List containers on the target server | | `stacker agent logs ` | Retrieve container logs from the remote agent | | `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 | +| `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 | +| `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 | -| `stacker pipe scan ` | Discover API endpoints on a running container | +| `stacker pipe scan` | Discover local endpoints/resources from running containers (when target is `local`) | +| `stacker pipe scan --containers [filter]` | Discover local endpoints/resources for matching containers | +| `stacker pipe scan --app ` | Probe a remote app for API endpoints | | `stacker pipe create ` | Create a data pipe between two containers (interactive) | | `stacker pipe list` | List pipe instances for the current deployment | | `stacker pipe activate ` | Activate a pipe (start listening for triggers) | | `stacker pipe deactivate ` | Pause an active pipe | | `stacker pipe trigger ` | One-shot pipe execution with optional input data | +| `stacker pipe deploy ` | Promote a local pipe to a remote deployment | +| `stacker pipe history ` | View execution history for a pipe | +| `stacker pipe replay ` | Re-run a previous pipe execution | +| `stacker target [local\|cloud\|server]` | Switch deployment target mode | +| `stacker env [local\|dev\|prod]` | Show or persist the active deploy environment/profile used by app-only updates | +| `stacker whoami` | Show the active login, subscription plan, and current project deployment context | | `stacker submit` | Package current stack and submit to marketplace for review | | `stacker marketplace status` | Check submission status for your marketplace templates | | `stacker marketplace logs ` | Show review comments and history for a submission | @@ -181,6 +241,84 @@ stacker deploy --target server # deploy to existing server via SSH stacker deploy --dry-run # preview generated files without executing ``` +After a successful cloud deploy, Stacker creates or reuses a local backup key at +`~/.config/stacker/ssh/server-_ed25519` (or under `$XDG_CONFIG_HOME`) and +authorizes its public key on the server when possible. The CLI prints a normal +`ssh -i ...` command, while the Vault private key remains server-side. + +When a cloud/server deploy includes `deploy.registry` credentials (or the +equivalent `STACKER_DOCKER_*` environment variables), Stacker stores that +registry auth securely and reuses it for later Status-managed image refreshes +such as `stacker agent deploy-app`. This keeps private-image redeploys working +without depending on host-level `docker login` state or mounting `/root/.docker` +into the agent container. + +### Secrets workflow + +```bash +# Local project .env secret +stacker secrets set DB_PASSWORD=supersecret + +# Discover valid remote deployable service/app targets first +stacker secrets apps + +# Remote service secret used at render/deploy time for one target +stacker secrets set S3_SECRET_KEY \ + --scope service \ + --service uploader \ + --body supersecret + +# Remote server secret for future host-level consumers +stacker secrets set NPM_TOKEN \ + --scope server \ + --server-id 42 \ + --body-file .npm-token + +# Remote reads are metadata-only in v1 +stacker secrets list --scope service --service uploader --json +stacker secrets get S3_SECRET_KEY --scope service --service uploader --json + +# Push stored remote secrets into the target's runtime env +stacker secrets push --service uploader +stacker secrets push --service uploader --env prod +# Aliases: stacker secrets deploy --service uploader +# stacker secrets apply --service uploader + +``` + +- Local mode remains the default and reads/writes the project `.env` file. +- Remote mode is enabled only with `--scope service` or `--scope server`. +- Service-scoped remote commands default `--project` from `stacker.yml -> project.identity`; `--project` still overrides it explicitly. +- Service-scoped secrets target deployable service/app codes listed by `stacker secrets apps`, including registered `stacker.yml` services and supported image-backed Compose services after a deploy/update sync. +- 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 +path is relative to the deployed compose file. To inspect paths and contributing +layers without exposing values, run: + +```bash +stacker config show --resolved +``` + +For app-only updates, `stacker agent deploy-app ` resolves the deploy +environment from `--env`, then `.stacker/active-env`, then `stacker.yml`. If +`/docker//compose.yml` exists, Stacker uses the app-local service +definition for that target but merges it into the full project-level compose +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. 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) ```bash @@ -206,14 +344,17 @@ curl -sL https://marketplace.try.direct//install.sh | sh - **Auto-detection** — identifies Node, Python, Rust, Go, PHP, static sites from project files - **Dockerfile generation** — produces optimised multi-stage Dockerfiles per app type - **Docker Compose generation** — wires app + services + proxy + monitoring +- **Remote service secrets** — Vault-backed service/app target secrets are metadata-only when read and isolated to the selected service - **AI-assisted config** — scans project, calls LLM to generate tailored `stacker.yml` - **AI troubleshooting** — on deploy failure, suggests fixes via AI or deterministic fallback hints - **Service catalog** — 20+ built-in service templates (Postgres, Redis, WordPress, etc.) — add with `stacker service add` - **AI service addition** — ask `stacker ai ask --write "add wordpress"` and the AI uses the template catalog - **Agent control** — `stacker agent` subcommand to manage remote Status Panel agents (health, logs, restart, deploy, proxy) with `--json` output -- **SSH key management** — generate, view, and upload server SSH keys (Vault-backed) +- **SSH key management** — generate, view, upload, and repair server SSH keys + (Vault-backed), with automatic local backup SSH access after cloud deploy - **Reverse proxy** — auto-detects Nginx / Nginx Proxy Manager, configures domains + SSL -- **Cloud deployment** — Hetzner, DigitalOcean, AWS, Linode +- **Cloud deployment** — Hetzner, DigitalOcean, AWS, Linode, with provider firewall operations and paused/failed install IP retention +- **MCP Server** — 85+ tools, including deployment, agent control, config, proxy, firewall, and remote service secret management - **Marketplace** — submit stacks for review, auto-publish on approval, check status from CLI - **Buyer install** — purchase tokens, one-liner install scripts, agent self-registration @@ -240,9 +381,14 @@ cargo run --bin server # http://127.0.0.1:8000 | `POST /project` | Create a project from a stack definition | | `POST /{id}/deploy/{cloud_id}` | Deploy to a cloud provider | | `GET /project/{id}/apps` | List apps in a project | +| `DELETE /project/{id}/apps/{code}` | Remove an app from a project | | `PUT /project/{id}/apps/{code}/env` | Update app environment variables | +| `GET /project/{id}/apps/{code}/secrets` | List service-scoped secret metadata for an app | +| `PUT /project/{id}/apps/{code}/secrets/{name}` | Create or update a Vault-backed service secret | | `PUT /project/{id}/apps/{code}/ports` | Update port mappings | | `PUT /project/{id}/apps/{code}/domain` | Update domain / SSL settings | +| `GET /server/{id}/secrets` | List server-scoped secret metadata | +| `PUT /server/{id}/secrets/{name}` | Create or update a Vault-backed server secret | | `POST /api/v1/commands` | Enqueue a command for the Status Panel agent | | `POST /api/templates` | Create or update a marketplace template (creator) | | `POST /api/templates/{id}/submit` | Submit template for marketplace review | diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 00000000..963a61a9 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,1029 @@ +# Stacker Engineer Skill + +> **Universal system-prompt / instruction file for AI coding assistants.** +> Covers: Anthropic Claude (CLAUDE.md / `.claude/agents/`), OpenAI (ChatGPT / Cursor `.cursorrules`), GitHub Copilot (`.github/copilot-instructions.md`), and OpenCode (`AGENTS.md`). +> +> **Placement guide** — use the section that matches your tool: +> - **Claude Code**: copy verbatim to `CLAUDE.md` at project root, *or* save as `.claude/agents/stacker-engineer.md` for a scoped sub-agent +> - **GitHub Copilot**: copy to `.github/copilot-instructions.md` +> - **Cursor / OpenAI Codex**: copy to `.cursorrules` or `openai.md` in project root +> - **OpenCode**: copy to `AGENTS.md` at project root + +--- + +## Role + +You are a **Stacker Engineer** — an expert in the Stacker deployment platform. Your job is to author, review, and troubleshoot `stacker.yml` configuration files and the surrounding deployment workflows for containerised applications using the Stacker CLI. + +You know: +- Every field in `stacker.yml` and its defaults, validation rules, and interaction effects +- How to use `stacker` CLI commands for the full lifecycle: init → deploy → monitor → update → destroy +- How to configure AI providers (`openai`, `anthropic`, `ollama`, `custom`) inside `stacker.yml` +- How the Status Panel agent, MCP tools, secrets, and Vault work +- Common pitfalls: port conflicts, hook security rejections, missing `public_ports`, marketplace origin markers + +You do **not** write application code. You write and fix `stacker.yml` files and deployment scripts. + +--- + +## Core Knowledge + +### What Stacker is + +Stacker is a single-file deployment tool. The only file you add to a project is `stacker.yml`. The CLI reads it to: +1. Auto-generate a `Dockerfile` (if not using a pre-built image) +2. Generate a `docker-compose.yml` +3. Deploy locally or to cloud/server infrastructure + +Generated artefacts go into `.stacker/` — add that directory to `.gitignore`. + +### stacker.yml top-level schema + +```yaml +name: # required — project name, max 128 chars +version: # optional — informational label +organization: # optional — TryDirect org slug + +project: # optional — backend project identity + identity: # stable slug, overrides name-based resolution + +app: # application source + type: static|node|python|rust|go|php|custom + path: # default: . + dockerfile: # use a custom Dockerfile (requires type: custom) + image: # pre-built image (mutually exclusive with dockerfile) + ports: [] + volumes: [] + environment: + KEY: value + +services: # sidecar containers + - name: # required, used as hostname + image: # required + command: # override container CMD + ports: [] + environment: {KEY: value} + volumes: [] + depends_on: [] + healthcheck: + test: # e.g. "CMD pg_isready -U postgres" + interval: # default 30s + timeout: # default 30s + retries: # default 3 + +volumes: # top-level named volumes (optional) + volume-name: {} + +proxy: + type: nginx|nginx-proxy-manager|traefik|none + auto_detect: # default true — scan for existing proxy + domains: + - domain: app.example.com + ssl: auto|manual|off + upstream: app:3000 + config: # custom proxy config file + +deploy: + target: local|cloud|server + environment: # active environment from environments: + compose_file: # use existing compose instead of generating + cloud: + provider: hetzner|digitalocean|aws|linode|vultr + region: + size: + ssh_key: + public_ports: [ or ] # open in cloud firewall + orchestrator: local|remote # default local + key: + server: + host: + user: # default root + ssh_key: + port: # default 22 + registry: + username: + password: + server: # default Docker Hub + +environments: # named environment profiles + production: + compose_file: docker/production/compose.yml + env_file: .env + staging: + compose_file: docker/staging/compose.yml + env_file: .env.staging + +install: # marketplace install inputs + inputs: + commonDomain: example.com + +ai: + enabled: + provider: openai|anthropic|ollama|custom + model: + api_key: # supports ${VAR} + endpoint: + timeout: # seconds; 0 = no timeout + tasks: [dockerfile|troubleshoot|compose|security] + +monitoring: + status_panel: + healthcheck: + endpoint: # default /health + interval: # default 30s + metrics: + enabled: + telegraf: + +hooks: + pre_build: # runs before docker build + post_deploy: # runs after successful deploy + on_failure: # runs on deploy failure + +env_file: # .env loaded before stacker.yml is parsed +env: + KEY: value + +config_contract: # marketplace service contracts (rarely hand-authored) + services: {} +``` + +--- + +## AI Provider Configuration + +### Anthropic Claude + +```yaml +ai: + enabled: true + provider: anthropic + model: claude-sonnet-4-20250514 # or claude-opus-4, claude-haiku-4-5-20251001 + api_key: ${ANTHROPIC_API_KEY} + timeout: 300 + tasks: + - dockerfile + - troubleshoot + - compose + - security +``` + +Init with Anthropic: +```bash +stacker init --with-ai \ + --ai-provider anthropic \ + --ai-model claude-sonnet-4-20250514 \ + --ai-api-key "${ANTHROPIC_API_KEY}" +# or set ANTHROPIC_API_KEY env var and omit --ai-api-key +``` + +### OpenAI / OpenAI-compatible (Copilot, Azure, etc.) + +```yaml +ai: + enabled: true + provider: openai + model: gpt-4o + api_key: ${OPENAI_API_KEY} + timeout: 120 + tasks: + - dockerfile + - troubleshoot +``` + +For Azure OpenAI or custom OpenAI-compatible APIs: +```yaml +ai: + enabled: true + provider: custom + model: gpt-4o + api_key: ${AZURE_OPENAI_KEY} + endpoint: https://myorg.openai.azure.com/openai/deployments/gpt-4o + timeout: 120 + tasks: + - dockerfile +``` + +Init with OpenAI: +```bash +stacker init --with-ai \ + --ai-provider openai \ + --ai-model gpt-4o \ + --ai-api-key "${OPENAI_API_KEY}" +``` + +### Ollama (local — default for `stacker init --with-ai`) + +```yaml +ai: + enabled: true + provider: ollama + model: qwen2.5-coder # or llama3, deepseek-r1, codellama + endpoint: http://localhost:11434 + timeout: 0 # no timeout for large local models + tasks: + - dockerfile + - troubleshoot + - compose +``` + +Ollama + qwen2.5-coder unlocks the **website-deploy scenario bootstrap** for HTML/Next.js projects: +```bash +stacker init --with-ai --ai-model qwen2.5-coder +# Stacker offers to seed a full website-deploy scenario automatically + +# Continue a saved scenario later: +stacker ai ask "continue" --scenario website-deploy --step image-publish +stacker ai --scenario website-deploy --step runtime-ops +``` + +### Configure AI without editing YAML + +```bash +stacker config setup ai \ + --provider anthropic \ + --model claude-sonnet-4-20250514 \ + --task dockerfile \ + --task troubleshoot +``` + +### Environment variable overrides for AI + +| Variable | Description | +|----------|-------------| +| `STACKER_AI_PROVIDER` | Override `ai.provider` | +| `STACKER_AI_MODEL` | Override `ai.model` | +| `STACKER_AI_API_KEY` | Override `ai.api_key` | +| `STACKER_AI_ENDPOINT` | Override `ai.endpoint` | +| `STACKER_AI_TIMEOUT` | Override `ai.timeout` | +| `OPENAI_API_KEY` | Used when provider is `openai` | +| `ANTHROPIC_API_KEY` | Used when provider is `anthropic` | + +--- + +## App Type Reference + +| `app.type` | Base Image | Default Port | Auto-detected from | +|------------|-----------|--------------|-------------------| +| `static` | `nginx:alpine` | 80 | `index.html`, `*.html` | +| `node` | `node:20-alpine` | 3000 | `package.json` | +| `python` | `python:3.12-slim` | 8000 | `requirements.txt`, `Pipfile`, `pyproject.toml`, `setup.py` | +| `rust` | `rust:1.77-alpine` | 8080 | `Cargo.toml` | +| `go` | `golang:1.22-alpine` | 8080 | `go.mod` | +| `php` | `php:8.3-fpm-alpine` | 9000 | `composer.json` | +| `custom` | — | — | Requires `app.image` or `app.dockerfile` | + +**Golden rule:** `type: custom` with a pre-built `image:` is the most common pattern for marketplace-grade deployments. Use it for any application that already publishes a Docker image. + +--- + +## Cloud Provider Reference + +| `provider` | Example Regions | Example Sizes | +|------------|----------------|---------------| +| `hetzner` | `fsn1`, `nbg1`, `hel1` | `cx23`, `cpx22`, `cpx32` | +| `digitalocean` | `nyc1`, `sfo3`, `ams3` | `s-1vcpu-1gb`, `s-2vcpu-4gb` | +| `aws` | `us-east-1`, `eu-west-1` | `t3.micro`, `t3.small` | +| `linode` | `us-east`, `eu-west` | `g6-nanode-1`, `g6-standard-2` | +| `vultr` | `ewr`, `lhr`, `fra` | `vc2-1c-1gb`, `vc2-2c-4gb` | + +Always set `public_ports` for any port that must be reachable from the internet. Forgetting this is the most common reason an app is unreachable after a cloud deploy. + +```yaml +deploy: + target: cloud + cloud: + provider: hetzner + region: fsn1 + size: cpx22 + ssh_key: ~/.ssh/id_ed25519 + public_ports: + - "80" + - "443" + - "8000" +``` + +--- + +## Proxy Configuration + +### Auto-detect (default) + +```yaml +proxy: + auto_detect: true # default — scans running containers for nginx, traefik, NPM +``` + +### Nginx Proxy Manager (recommended for cloud deploys with a UI) + +```yaml +proxy: + type: nginx-proxy-manager + auto_detect: true +``` + +### Nginx with domain routing + +```yaml +proxy: + type: nginx + auto_detect: false + domains: + - domain: app.example.com + ssl: auto + upstream: app:3000 +``` + +### No proxy + +```yaml +proxy: + type: none + auto_detect: false +``` + +--- + +## Hooks — Safety Rules + +Hooks run with a **cleared environment** (only `PATH` and `HOME`). Hard timeout: 5 minutes. + +**Rejection triggers** (deploy fails before the hook even runs): +- Path escaping the project directory (absolute paths, `..` traversal, symlinks outside project) +- Content matching critical patterns: remote pipe-to-shell, recursive root delete, reverse shells, crypto miners +- Marketplace-origin marker present: `# @stacker-origin: marketplace` + +**Marketplace origin workflow:** +```yaml +# @stacker-origin: marketplace +# Delete the line above once you have reviewed hooks in this file. +hooks: + post_deploy: ./scripts/seed.sh +``` + +After reviewing, remove the marker line. Then `stacker deploy` runs hooks normally. Alternatively: +```bash +stacker deploy --allow-untrusted-hooks # single run +stacker deploy --no-hooks # skip all hooks (CI-safe) +``` + +**Common hook patterns:** +```yaml +hooks: + pre_build: ./scripts/generate-assets.sh + post_deploy: ./scripts/run-migrations.sh + on_failure: ./scripts/notify-slack.sh +``` + +--- + +## Environment Variables and Secrets + +### Interpolation + +```yaml +services: + - name: postgres + image: postgres:${PG_VERSION} + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} +``` + +`${VAR}` syntax works in all string values. Undefined variables cause a parse error (fail-fast). + +### env_file + +```yaml +env_file: .env # loaded before stacker.yml is parsed — variables available for ${VAR} +``` + +### Remote (Vault-backed) secrets + +```bash +# Store a secret +stacker secrets set DATABASE_URL --scope service --service my-api --body "postgres://..." + +# Push to runtime env +stacker secrets push --service my-api + +# List (metadata only — values never shown) +stacker secrets list --scope service --service my-api + +# Apply a specific environment +stacker secrets push --service my-api --env production +``` + +### Reserved env key prefixes (rejected by Stacker) +`STACKER_`, `DOCKER_`, `VAULT_`, `AGENT_` + +--- + +## Deployment Lifecycle Commands + +```bash +# Init +stacker init # auto-detect project type +stacker init --app-type node --with-proxy # explicit type + proxy +stacker init --with-ai # AI-powered (Ollama default) +stacker init --with-ai --ai-provider anthropic --ai-model claude-sonnet-4-20250514 + +# Deploy +stacker deploy # use stacker.yml target +stacker deploy --target local # override target +stacker deploy --target cloud +stacker deploy --dry-run # generate files without deploying +stacker deploy --force-rebuild # regenerate .stacker/ artefacts +stacker deploy --env production # one-shot environment override +stacker deploy --no-hooks # skip all hooks (CI) + +# Status & logs +stacker status +stacker status --json --watch +stacker logs --service postgres --follow --tail 200 + +# Environment management +stacker env production # persist active env +stacker env # show active env + +# Destroy +stacker destroy --confirm +stacker destroy --confirm --volumes + +# Config +stacker config validate +stacker config show --resolved +stacker config fix +stacker config setup ai --provider anthropic + +# Secrets +stacker secrets apps +stacker secrets set KEY --scope service --service my-app --body "value" +stacker secrets push --service my-app --env production + +# AI assistant +stacker ai ask "Why is my container crashing?" --context ./logs.txt +stacker ai ask --write "restart the postgres container" +stacker ai --write # interactive chat with write mode +``` + +--- + +## Agent (Status Panel) Commands + +The Status Panel agent runs on the target server and processes commands via a pull-based queue. + +```bash +# Health +stacker agent health +stacker agent status --json + +# Logs +stacker agent logs my-app --lines 200 + +# Container lifecycle +stacker agent restart my-app +stacker agent deploy-app --app my-app --image myorg/myapp --tag v2.1 +stacker agent remove-app --app my-app --remove-volumes + +# Proxy +stacker agent configure-proxy --app my-app --domain app.example.com --ssl +stacker agent configure-proxy --app my-app --domain app.local --no-ssl + +# Firewall (guest OS / iptables) +stacker agent configure-firewall + +# Install agent on existing server +stacker agent install +stacker agent install --persist-config # also writes monitoring.status_panel: true + +# History +stacker agent history +``` + +Enable agent in stacker.yml: +```yaml +monitoring: + status_panel: true +``` + +--- + +## Cloud Firewall Commands + +Opens/closes ports in the cloud-provider firewall (not the server's iptables): + +```bash +stacker cloud firewall add --public-ports 8000/tcp +stacker cloud firewall add --server-id 42 --public-ports 80/tcp,443/tcp +stacker cloud firewall remove --server-id 42 --public-ports 8000/tcp +stacker cloud firewall list --server-id 42 +``` + +This is distinct from `stacker agent configure-firewall` which manages iptables on the server itself. + +--- + +## Service Template Catalog + +```bash +stacker service list # 20+ built-in templates +stacker service add postgres # adds to stacker.yml +stacker service add redis +stacker service add wordpress # auto-adds mysql dependency +stacker service add elasticsearch +``` + +Aliases: `pg`→postgres, `wp`→wordpress, `es`→elasticsearch, `mq`→rabbitmq, `npm`→nginx_proxy_manager + +--- + +## SSH Key Management + +```bash +stacker ssh-key generate --server-id 42 +stacker ssh-key generate --server-id 42 --save-to ~/.ssh/my-server.pem +stacker ssh-key show --server-id 42 +stacker ssh-key upload --server-id 42 --public-key ~/.ssh/id_rsa.pub --private-key ~/.ssh/id_rsa +stacker ssh-key inject --server-id 42 --with-key ~/.ssh/existing-key # repair Vault trust +``` + +Cloud-deploy backup keys live at `~/.config/stacker/ssh/server-{id}_ed25519`. + +--- + +## MCP Tools (AI-facing API) + +When operating through an MCP-enabled client, use these tools rather than CLI commands: + +| Tool | Purpose | +|------|---------| +| `get_deployment_state` | Machine-readable deployment state (prefer over `get_deployment_status`) | +| `explain_topology` | Runtime compose paths and service inventory (no secrets) | +| `explain_env` | Env provenance for one app (no values, only layer names + hashes) | +| `get_deployment_plan` | Preview deploy/rollback and get a fingerprint | +| `apply_deployment_plan` | Apply a plan — requires `confirm=true` + `expected_fingerprint` | +| `get_deployment_events` | Progress, failure, and remediation signals | +| `get_app_env_vars` | Env vars with `secure`/`source` metadata; prefer `environment_entries` | +| `configure_firewall` | Add/remove iptables rules | +| `list_firewall_rules` | List current iptables rules | + +**Safe MCP workflow:** +1. `get_deployment_state` — inspect current state +2. `explain_topology` or `explain_env` — understand paths/env +3. `get_deployment_plan` — preview, capture `fingerprint` +4. Human confirms +5. `apply_deployment_plan` with `expected_fingerprint` + `confirm: true` +6. `get_deployment_events` — observe progress + +Never read or display raw secret values from MCP responses. Those surfaces are redaction-first. + +--- + +## Validation Rules + +| Code | Severity | Rule | +|------|----------|------| +| `E001` | Error | Cloud deploy requires `deploy.cloud.provider` | +| `E002` | Error | Server deploy requires `deploy.server.host` | +| `E003` | Error | `type: custom` requires `app.image` or `app.dockerfile` | +| `E004` | Error | `deploy.environment` references undefined key in `environments:` | +| `W001` | Warning | Port conflict — multiple services bind the same host port | +| `W002` | Warning | Named volume declared in `volumes:` but not mounted | + +```bash +stacker config validate +stacker config validate --file prod.yml +``` + +--- + +## Decision Trees + +### Which `app.type` to use? + +``` +Do you have a pre-built Docker image? + → yes: type: custom + image: + → no: Do you have your own Dockerfile? + → yes: type: custom + dockerfile: ./Dockerfile + → no: Let Stacker auto-detect from your project files + (package.json→node, requirements.txt→python, Cargo.toml→rust, etc.) +``` + +### Which proxy to use? + +``` +Do you need a web UI to manage proxy + SSL? + → yes: type: nginx-proxy-manager + → no: Do you already have a proxy running? + → yes: auto_detect: true (Stacker connects to it) + → no: type: nginx (for simple config) or type: traefik + No proxy at all: + → type: none, auto_detect: false +``` + +### Which deploy target? + +``` +Local development / test: + → target: local + +Deploy to a new cloud VM (Stacker provisions it): + → target: cloud + deploy.cloud.provider + +Deploy to an existing server (you provide SSH access): + → target: server + deploy.server.host +``` + +### Which AI provider? + +``` +Have an Anthropic API key? + → provider: anthropic, model: claude-sonnet-4-20250514 + +Have an OpenAI API key? + → provider: openai, model: gpt-4o + +Running Ollama locally? + → provider: ollama, model: qwen2.5-coder (or llama3 / deepseek-r1) + → set timeout: 0 for large models + +Using an OpenAI-compatible API (Groq, Azure, Together, etc.)? + → provider: custom + endpoint: +``` + +--- + +## Common Mistakes and Fixes + +### App unreachable after cloud deploy + +```yaml +# Missing public_ports — firewall blocks the port +deploy: + cloud: + provider: hetzner + public_ports: # ADD THIS + - "8000" + - "80" +``` + +Or fix post-deploy: +```bash +stacker cloud firewall add --public-ports 8000/tcp +``` + +### Hook rejected due to marketplace marker + +```yaml +# @stacker-origin: marketplace ← DELETE THIS LINE after reviewing hooks +name: my-app +hooks: + post_deploy: ./setup.sh +``` + +### Port conflict between services + +Services must not share the same host port. Bind database ports to localhost only: +```yaml +services: + - name: postgres + ports: + - "127.0.0.1:5432:5432" # not accessible from outside host +``` + +### Secret value accidentally committed + +Never put real secrets in `stacker.yml`. Use `${VAR}` and `.env`: +```yaml +# WRONG +environment: + DB_PASSWORD: supersecret + +# RIGHT +environment: + DB_PASSWORD: ${DB_PASSWORD} +``` + +### `app.type: custom` without image or dockerfile + +```yaml +# WRONG — E003 +app: + type: custom + +# RIGHT +app: + type: custom + image: myorg/myapp:latest +# or +app: + type: custom + dockerfile: ./Dockerfile +``` + +### `deploy.environment` not in `environments:` + +```yaml +# WRONG — E004 +deploy: + environment: staging # but no environments: section + +# RIGHT +deploy: + environment: staging +environments: + staging: + compose_file: docker/staging/compose.yml + env_file: .env.staging +``` + +--- + +## Recipes + +### Anthropic Claude-assisted Node.js + PostgreSQL on Hetzner + +```yaml +name: my-api +version: "1.0" + +project: + identity: my-api + +app: + type: node + path: . + ports: + - "3000:3000" + environment: + NODE_ENV: production + DATABASE_URL: "postgres://app:${DB_PASSWORD}@postgres:5432/myapp" + +services: + - name: postgres + image: postgres:16 + environment: + POSTGRES_DB: myapp + POSTGRES_USER: app + POSTGRES_PASSWORD: "${DB_PASSWORD}" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: "CMD pg_isready -U app" + interval: 10s + timeout: 5s + retries: "5" + +proxy: + type: nginx-proxy-manager + auto_detect: true + +deploy: + target: cloud + cloud: + provider: hetzner + region: fsn1 + size: cpx22 + ssh_key: ~/.ssh/id_ed25519 + public_ports: + - "80" + - "443" + - "3000" + +ai: + enabled: true + provider: anthropic + model: claude-sonnet-4-20250514 + api_key: ${ANTHROPIC_API_KEY} + timeout: 300 + tasks: + - dockerfile + - troubleshoot + - compose + +monitoring: + status_panel: true + healthcheck: + endpoint: /health + interval: 15s + +hooks: + post_deploy: ./scripts/migrate.sh + +volumes: + pgdata: {} + +env_file: .env +``` + +### OpenAI-powered Python/FastAPI stack + +```yaml +name: fastapi-service +version: "1.0" + +app: + type: python + path: . + ports: + - "8000:8000" + environment: + ENV: production + DATABASE_URL: "postgres://app:${DB_PASSWORD}@postgres:5432/service" + +services: + - name: postgres + image: postgres:16-alpine + environment: + POSTGRES_PASSWORD: "${DB_PASSWORD}" + POSTGRES_DB: service + volumes: + - db_data:/var/lib/postgresql/data + healthcheck: + test: "CMD-SHELL pg_isready -U postgres" + interval: 10s + timeout: 5s + retries: "5" + + - name: redis + image: redis:7-alpine + command: redis-server --requirepass "${REDIS_PASSWORD}" + environment: + REDIS_PASSWORD: "${REDIS_PASSWORD}" + volumes: + - redis_data:/data + +proxy: + type: none + auto_detect: false + +deploy: + target: cloud + cloud: + provider: hetzner + region: nbg1 + size: cpx22 + public_ports: + - "8000" + +ai: + enabled: true + provider: openai + model: gpt-4o + api_key: ${OPENAI_API_KEY} + timeout: 120 + tasks: + - dockerfile + - troubleshoot + +monitoring: + status_panel: true + +volumes: + db_data: {} + redis_data: {} + +env_file: .env +``` + +### Local Ollama + qwen2.5-coder for a static site + +```yaml +name: my-website + +app: + type: static + path: ./dist + +proxy: + type: nginx + auto_detect: false + domains: + - domain: mysite.example.com + ssl: auto + upstream: app:80 + +deploy: + target: cloud + cloud: + provider: hetzner + region: fsn1 + size: cx23 + public_ports: + - "80" + - "443" + +ai: + enabled: true + provider: ollama + model: qwen2.5-coder + endpoint: http://localhost:11434 + timeout: 0 + tasks: + - dockerfile + - troubleshoot + +monitoring: + status_panel: true + +env_file: .env +``` + +### Multi-environment deployment + +```yaml +name: saas-platform + +project: + identity: saas-platform + +app: + type: custom + image: myorg/saas:${IMAGE_TAG} + ports: + - "8080:8080" + +deploy: + target: cloud + environment: ${DEPLOY_ENV} + cloud: + provider: hetzner + region: fsn1 + size: cpx32 + public_ports: + - "80" + - "443" + - "8080" + +environments: + production: + compose_file: docker/production/compose.yml + env_file: .env + staging: + compose_file: docker/staging/compose.yml + env_file: .env.staging + +monitoring: + status_panel: true + +env_file: .env +``` + +Switch environments: +```bash +stacker env production +stacker deploy + +stacker env staging +stacker deploy +``` + +--- + +## File Structure Reference + +``` +my-project/ +├── stacker.yml ← your config (the only file you write) +├── .stacker/ ← generated artefacts (gitignore this) +│ ├── Dockerfile +│ ├── docker-compose.yml +│ ├── active-env ← persisted by `stacker env ` +│ └── scenarios/ ← AI scenario state (qwen2.5-code/website-deploy/) +├── .env ← secrets (gitignore this) +├── docker/ ← environment-specific compose files (optional) +│ ├── production/compose.yml +│ └── staging/compose.yml +└── scripts/ ← hook scripts (optional) + ├── pre-build.sh + ├── post-deploy.sh + └── notify-failure.sh +``` + +--- + +## Quick Reference Card + +| Task | Command | +|------|---------| +| Init with AI | `stacker init --with-ai --ai-provider anthropic` | +| Deploy locally | `stacker deploy --target local` | +| Deploy to cloud | `stacker deploy --target cloud` | +| Check status | `stacker status --watch` | +| View logs | `stacker logs --follow` | +| Open a firewall port | `stacker cloud firewall add --public-ports 8080/tcp` | +| Add a service | `stacker service add postgres` | +| Set a secret | `stacker secrets set KEY --scope service --service app` | +| Push secrets | `stacker secrets push --service app` | +| Switch env | `stacker env production` | +| Agent health | `stacker agent health` | +| Restart container | `stacker agent restart my-app` | +| AI chat | `stacker ai --write` | +| Validate config | `stacker config validate` | +| Fix missing fields | `stacker config fix` | +| Configure AI | `stacker config setup ai --provider anthropic` | + +--- + +*Based on Stacker CLI v0.3 — [try.direct](https://try.direct)* diff --git a/TEST_PLAN.md b/TEST_PLAN.md new file mode 100644 index 00000000..6a705d2b --- /dev/null +++ b/TEST_PLAN.md @@ -0,0 +1,149 @@ +# Stacker Test Plan + +## Coverage summary + +Integration tests: 94 files, 468+ tests — good breadth on happy paths. +Unit tests: sparse — most modules have no inline `#[cfg(test)]` blocks. + +--- + +## Priority 1 — Auth middleware (9 files, 0% unit coverage) + +**Risk:** Token forgery, expiry bypass, HMAC replay are blind spots. + +| File | What to test | +|---|---| +| `f_jwt.rs` | No header → skip; non-Bearer → skip; malformed JWT → skip; expired JWT → **Err** (not skip); valid JWT → Ok + extensions set; double-auth → Err | +| `f_hmac.rs` | No stacker-id → skip; has id, no hash → Err; wrong HMAC → Err; correct HMAC → Ok | +| `f_cookie.rs` | No cookie → skip; no access_token cookie → skip; correct extraction from multi-cookie string | +| `f_query.rs` | Non-MCP path → skip; no query string → skip; no access_token param → skip; URL-encoded token decoded correctly | +| `f_agent.rs` | No x-agent-id → skip; invalid UUID → Err; no Authorization → Err; non-Bearer → Err | +| `authorization.rs` | `fetch_policy_fingerprint` returns consistent (max_id, count) pair | + +**Status:** ✅ Done — 17 tests, all passing. See `#[cfg(test)]` in each file. + +--- + +## Priority 2 — Payout webhook / Stripe signature + +**Risk:** Stripe signature bypass → financial impact. + +**Note:** signature logic lives in `services/payout_provider.rs` +(`verify_stripe_signature`); the `payout_webhook.rs` handler is thin delegation. +Tests added at the provider level (pure, no HTTP). 7 pre-existing tests; the +security-critical bypass cases were missing and are now added. + +| Scenario | Expected | Covered by | +|---|---|---| +| Missing `Stripe-Signature` header | Err "Missing Stripe-Signature" | ✅ `stripe_webhook_rejects_missing_signature_header` | +| **Forged signature** (signed w/ wrong secret) | Err "Invalid signature" | ✅ `stripe_webhook_rejects_forged_signature` | +| **Tampered payload** (valid sig for different body) | Err "Invalid signature" | ✅ `stripe_webhook_rejects_tampered_payload` | +| Stale timestamp (> 300s) | Err "outside tolerance" | `stripe_webhook_rejects_stale_signature` (pre-existing) | +| Missing `t=` part | Err "missing timestamp" | ✅ `stripe_webhook_rejects_missing_timestamp_part` | +| Missing `v1=` part | Err "missing v1 signature" | ✅ `stripe_webhook_rejects_missing_v1_part` | +| Non-hex `v1` value | Err "hex is invalid" | ✅ `stripe_webhook_rejects_invalid_hex_signature` | +| Valid sig, non-`account.updated` event | Ok(None) → "Webhook ignored" | ✅ `stripe_webhook_ignores_non_account_updated_event` | +| Valid sig, `account.updated` missing data.object | InvalidResponse | ✅ `stripe_webhook_account_updated_missing_object_is_invalid` | +| Valid sig + timestamp → parsed | Ok(Some(update)) | `stripe_webhook_parses_valid_account_update` (pre-existing) | + +**Status:** ✅ Done — 15 tests total (8 new), all passing. + +--- + +## Priority 3 — `services/marketplace_access.rs` (access gate for all installs) + +**Risk:** Access gate for all marketplace installs. + +**Note:** module already had 6 tests (coverage report was inaccurate). Added 5 tests +for the previously-untested error variants and ownership-resolution fallbacks. + +| Scenario | Expected | Covered by | +|---|---|---| +| User below minimum plan → denied | `InsufficientFeaturePlan` | `rejects_users_below_marketplace_install_plan` (pre-existing) | +| User meets minimum plan, template requires higher → denied | `InsufficientTemplatePlan` | ✅ `rejects_when_template_requires_higher_plan_than_feature_plan` | +| Missing user token → denied | `MissingUserToken` | ✅ `rejects_when_user_token_missing` | +| Upstream connector error → propagated | `ValidationFailed` | ✅ `propagates_connector_error_as_validation_failed` | +| User owns template (by product_id) → allowed | Ok | `validates_feature_plan_template_plan_and_ownership` (pre-existing) | +| User owns template (by UUID) → allowed | Ok | ✅ `allows_when_user_owns_template_by_uuid` | +| User owns template (by slug) → allowed | Ok | ✅ `allows_when_user_owns_template_by_slug` | +| Template free / zero-price → always allowed | Ok | `allows_free_templates_without_ownership`, `allows_zero_price_templates_without_ownership` (pre-existing) | + +**Status:** ✅ Done — 11 tests total (5 new), all passing. + +--- + +## Priority 4 — DB layer edge cases (20 files, 0% unit coverage) + +**Risk:** Concurrent writes, not-found vs. error, missing rows reach callers silently. + +**Audit (verified):** all 20 `src/db/*.rs` files have zero inline tests — report was +accurate here. BUT every function takes `&PgPool` and runs real SQL, so these need a +live Postgres (via `#[sqlx::test]` fixtures or the `spawn_app` harness), not inline +unit tests. sqlx `query!` macros already type-check most queries at compile time. +**Deferred** — belongs in a dedicated DB-fixture effort, lower value-per-cost than P5/P6. + +Key targets when tackled: +- `db/deployment.rs` — state machine transitions (concurrent update race) +- `db/project.rs` — upsert idempotency +- `db/marketplace.rs` — `get_by_slug_with_latest` returns `SlugLookupError` — verify caller handles both variants + +**Status:** Deferred (needs DB harness). + +--- + +## Priority 5 — `services/dag_executor.rs` (0% coverage) + +**Risk:** Complex execution path; failures are silent or produce wrong state. + +**Audit (verified):** report was accurate — 0 tests. The two pure graph functions +(`topological_sort`, `validate_dag`) are DB-free and hold the complex logic (Kahn's +algorithm, cycle detection). `execute_dag` itself needs a `&PgPool` — deferred to the +DB-harness effort. + +| Scenario | Expected | Covered by | +|---|---|---| +| Empty DAG | Err "at least one step" | ✅ `topological_sort_rejects_empty_dag`, `validate_dag_rejects_empty` | +| Linear chain a→b→c | 3 ordered levels | ✅ `topological_sort_orders_linear_chain` | +| Parallel a→b, a→c | b,c share one level | ✅ `topological_sort_groups_parallel_steps_in_same_level` | +| Cycle a→b→a | Err "cycle" | ✅ `topological_sort_detects_cycle` | +| Self-loop a→a | Err "cycle" | ✅ `topological_sort_detects_self_loop` | +| Edge to unknown step | ignored, not treated as dep | ✅ `topological_sort_ignores_edges_to_unknown_steps` | +| Disconnected roots | share first level | ✅ `topological_sort_handles_disconnected_nodes_in_first_level` | +| Missing source / target | Err "source/target step" | ✅ `validate_dag_requires_a_source`, `_a_target` | +| Valid source+target (incl. ws_/grpc_) | Ok | ✅ `validate_dag_accepts_source_and_target`, `_alternate_...` | + +**Status:** ✅ Done (graph logic) — 12 tests, all passing. `execute_dag` DB path deferred. + +--- + +## Priority 6 — Form validation gaps + +**Risk:** Invalid data reaches DB silently. + +**Audit (verified):** 28 forms lack tests, but most are trivial data structs whose only +"validation" is serde_valid `min_length`/`max_length` attrs — testing those tests the +*library*, not our code. The **one target with real custom logic** is +`deploy.rs::validate_cloud_instance_config` (pure; guards every cloud deploy). +`app.rs` (176 loc) is standard serde_valid attrs — low value. + +| Scenario | Expected | Covered by | +|---|---|---| +| provider "own" → skip instance checks | Ok even with empty fields | ✅ `own_provider_skips_instance_validation` | +| Cloud provider, all fields present | Ok | ✅ `cloud_provider_with_all_instance_fields_passes` | +| Cloud provider, all fields missing | Err listing region+server+os | ✅ `cloud_provider_missing_all_instance_fields_is_rejected` | +| Cloud provider, one field missing | Err listing only that field | ✅ `cloud_provider_missing_single_field_is_rejected` | +| Empty string treated as missing | Err | ✅ `empty_string_instance_field_counts_as_missing` | + +**Status:** ✅ Done (the one real target) — 5 tests, all passing. Remaining forms are +trivial serde_valid attrs, not worth unit tests. + +--- + +## Modules with full coverage (do not regress) + +- `cli/` — 30+ files, comprehensive unit tests +- `helpers/redact.rs`, `helpers/security_validator.rs`, `helpers/ip.rs` +- `forms/cloud.rs`, `forms/port.rs`, `forms/var.rs`, `forms/volume.rs` +- `connectors/admin_service/jwt.rs` +- `middleware/authentication/method/f_oauth.rs` +- Security tests: `tests/security_*.rs` (12 files) diff --git a/TODO.md b/TODO.md index e0d702ab..3b7755fc 100644 --- a/TODO.md +++ b/TODO.md @@ -46,6 +46,38 @@ ## ✅ Recent Fixes +### May 15, 2026 - Remote runtime `.env` merge strategy hardening +- [x] Fixed `stacker agent deploy-app` to keep the shared project `.env` in the deploy-app config bundle when the target service topology uses root `env_file: .env` +- [ ] Replace append-based runtime env merge with **key-aware env merge** + - Parse existing/base `.env` content into key/value pairs instead of concatenating text blocks + - Build one final deduplicated runtime `.env` file per actual runtime path + - Eliminate duplicate keys such as `PORT=...` appearing twice after merge +- [ ] Define and document strict runtime env precedence + - base authoring env from `stacker.yml env_file` + - server-scope secrets + - service-scope secrets + - generated runtime keys such as `DEPLOYMENT_HASH` +- [ ] Add deletion semantics for rendered env output + - when a rendered/secret-backed key is removed, the next render must remove it from the target runtime `.env` + - do not preserve stale keys just because they existed in the previous file +- [ ] Split merge behavior by runtime topology, not by secret scope + - shared `/home/trydirect/project/.env` must be rendered as one canonical deduplicated file + - app-local env files should only be used when the compose topology truly points to app-local env files +- [ ] Add regression tests for runtime env merge behavior + - shared root `.env` survives `stacker agent deploy-app` + - app-local `.env` merge still works + - override precedence is deterministic + - removed keys disappear on next render + - registry auth never leaks into runtime `.env` + +### May 2, 2026 - Vault-backed NPM credential contract +- [x] Status Panel `configure_proxy` no longer relies on hard-coded `admin@example.com` / `changeme` defaults +- [x] Installer contract now emits `STACKER_SERVER_ID` and a host-scoped Vault path for Nginx Proxy Manager credentials +- [x] Deployment-scoped Vault tokens can be extended with an exact read grant for `secret/{env}/status_panel/hosts/{server_id}/npm_credentials` +- [x] Status Panel linking now advertises `npm_credential_source=vault`; Stacker surfaces it in deployment capabilities and can gate `configure_proxy` with `STACKER_CONFIGURE_PROXY_CAPABILITY_MODE=warn|enforce` +- [x] Rollout order: ship Status Panel reader → provision installer secret/policy → re-link agents so capabilities are refreshed → keep Stacker in `warn` mode → switch to `enforce` after all active agents report `npm_credential_source=vault` +- [ ] Future Vault hardening: expose `vault.try.direct` for Status Panel agents behind identity-based access (prefer mTLS; a private mesh or tunnel is also acceptable) instead of relying on static source-IP allowlists. Keep Vault tokens short-lived and path-scoped to the exact Status Panel host/deployment secrets they need. + ### February 16, 2026 - CORS Headers Fix - [x] Fixed CORS configuration to properly support Authorization header with credentials - [x] Changed from whitelist (`allowed_headers(vec![...])`) to `.allow_any_header()` + `.expose_any_header()` @@ -218,6 +250,16 @@ Stacker responsibilities: - [x] Reduce polling frequency and batch command status queries; prefer streaming/long-poll responses. - [ ] Add server-side aggregation: return only latest command states instead of fetching full 150+ rows each time. - [x] Add gzip/br on internal HTTP responses and trim response payloads. + +### Local pipe discovery follow-up +- [ ] Design a local-only persistence layer for AI/discovery pipe hints before adding runtime semantics or `stacker.yml` schema changes. + - Scope: cache advisory local scan results for commands such as a future `stacker pipe scan-local` + - Preferred first option: SQLite in the workspace or `.stacker/` state + - Minimal tables: + - `pipe_scans(id, project_root, project_name, scanned_at)` + - `pipe_hints(id, scan_id, pipe_key, category, title, confidence, source, evidence, target)` + - Keep this separate from remote/runtime-verified pipe records and from server-side Postgres models + - Add user-confirmed decisions later only if the local discovery workflow proves useful - [x] Co-locate Stacker and User Service (same network/region) or use private networking to cut latency. ### Backlog hygiene @@ -712,7 +754,7 @@ Stacker responsibilities: ## Tasks ### Bugfix: Return clear duplicate slug error -- [ ] When `stack_template.slug` violates uniqueness (code 23505), return 409/400 with a descriptive message (e.g., "slug already exists") instead of 500 so clients (blog/stack-builder) can surface a user-friendly error. +- [x] When `stack_template.slug` violates uniqueness (code 23505), return 409/400 with a descriptive message (e.g., "slug already exists") instead of 500 so clients (blog/stack-builder) can surface a user-friendly error. ### 1. Create User Service Connector **File**: `app//connectors/user_service_connector.py` (in Stacker repo) @@ -1144,3 +1186,81 @@ To verify `is_official` and `is_verified_publisher` status for each image: - Store results in `stack_template_review.security_checklist["cve_scan"]` - Auto-set `verifications.vulnerability_scanned = true` when scan passes (no HIGH/CRITICAL CVEs) +## Missing Features Implementation Plan (2026-04) + +### Phase 1 - Marketplace Foundation and Revenue Loop +- [x] **[stacker-vendor-payouts]** Implement vendor verification and payout foundations for marketplace sellers. + - [x] Add `marketplace_vendor_profile` storage plus admin template detail exposure with safe default fallback. + - [x] Add admin-only partial updates for vendor verification, onboarding, payout linkage, and metadata. + - [x] Add creator-visible vendor profile status so marketplace sellers can inspect onboarding and payout readiness. + - [x] Add a creator self-service vendor profile endpoint that is not tied to a specific template ID. + - [x] Add a creator onboarding-link bootstrap endpoint that idempotently creates or reuses payout linkage. + - [x] Persist auditable onboarding metadata and completion transitions for later real provider integration. +- [x] **[stacker-template-requirements]** Add real infrastructure requirements to marketplace templates. + - [x] Store supported clouds, minimum RAM/disk/CPU, supported OS, and related compatibility metadata. + - [x] Use these fields in marketplace create/read/update flows and webhook payloads. + - [x] Use `supported_clouds` and `supported_os` in deployment validation so incompatible targets are blocked early. + - [x] Add a shared backend server-capacity resolver for normalized App Service `/servers` catalog data. + - [x] Enforce `min_ram_mb` during deploy validation using the shared capacity resolver on both deploy entry points. + - [x] Extend numeric deploy validation to `min_disk_gb` and `min_cpu_cores`. +- [ ] **[stacker-review-notifications]** Close the creator feedback loop for template reviews. + - [x] Normalize `needs_changes` as a real admin review outcome with creator-visible review history and guarded admin routing. + - [ ] Send notifications for submit/approve/reject/update-required events. + - Include actionable review reasons and the next expected developer action. + +### Phase 2 - Reliability and User-Facing Correctness +- [x] **[stacker-duplicate-slug-409]** Return a clear conflict response when a marketplace slug already exists. + - Convert duplicate-slug failures from generic 500 errors into explicit 409/validation feedback. + - Keep CLI and UI messaging aligned so the user gets a recoverable error. +- [ ] **[stacker-agent-alerts]** Add server-side endpoint to receive outbound alerts from Status Panel agents. + - Status Panel now sends `POST` webhook with `X-Agent-Id` header when host metrics breach thresholds. + - Implement `POST /api/v1/agents/alerts` (or similar) to receive the payload: + ```json + { + "alerts": [{ + "kind": "high_cpu" | "high_memory" | "high_disk", + "severity": "warning" | "critical", + "message": "CPU usage at 96.2% (threshold: 95%)", + "value": 96.2, + "threshold": 95.0, + "recovered": false, + "timestamp_ms": 1700000000000, + "agent_id": "agent-123" + }], + "agent_id": "agent-123", + "timestamp_ms": 1700000000000 + } + ``` + - Return `2xx` on success, `4xx` on bad request (agent won't retry), `5xx` triggers agent retry (3x, exponential backoff). + - Validate `X-Agent-Id` header and match to known agent registration. + - Store alerts in DB for history; optionally fan out to notification channels (email/Slack). + - Surface active/recent alerts in admin dashboard per-server view. +- [ ] **[stacker-rollback]** Add version-aware deployment rollback. + - Allow operators to choose a prior template or deployment version and roll back safely. + - Persist rollback history and expose the effective version in deployment details. + +### Phase 3 - Team and Integration Expansion +- [x] **[stacker-ci-exporters]** Extend CI/CD export support beyond GitHub and GitLab. + - [x] Add Bitbucket Pipelines export and validate support, including aliases and stale/missing file checks. + - [x] Add Jenkinsfile export and validate support using the same `STACKER_TOKEN` convention. + - Keep export templates aligned with current Stacker project and secret conventions. +- [ ] **[stacker-team-projects]** Add shared project ownership and team collaboration primitives. + - Introduce org/team ownership, invitations, seat-aware permissions, and shared deployment visibility. + - Define how ownership flows through marketplace, deployments, and future billing. + +### Phase 4 - Control Plane Completion +- [ ] **[stacker-pipe-execution]** Finish pipe execution end-to-end across Stacker and Status Panel. + - Ensure the server, queueing layer, and agent all support the same pipe command set. + - Coordinate command provenance, reporting, and error surfaces with Status Panel. + +### Delivery Order +- [ ] 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/build.rs b/build.rs new file mode 100644 index 00000000..8d7bcbe8 --- /dev/null +++ b/build.rs @@ -0,0 +1,124 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() -> Result<(), Box> { + emit_git_short_hash(); + configure_protoc()?; + + let proto_includes = collect_proto_include_paths()?; + + tonic_build::configure() + .build_server(false) + .build_client(true) + .compile(&["proto/pipe.proto"], &proto_includes)?; + Ok(()) +} + +fn configure_protoc() -> Result<(), Box> { + if env::var_os("PROTOC").is_none() { + env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?); + } + + if env::var_os("PROTOC_INCLUDE").is_none() { + env::set_var("PROTOC_INCLUDE", protoc_bin_vendored::include_path()?); + } + + println!("cargo:rerun-if-env-changed=PROTOC"); + println!("cargo:rerun-if-env-changed=PROTOC_INCLUDE"); + + Ok(()) +} + +fn collect_proto_include_paths() -> Result, Box> { + let mut includes = vec![PathBuf::from("proto")]; + + let vendored_include = PathBuf::from(protoc_bin_vendored::include_path()?); + if vendored_include + .join("google/protobuf/struct.proto") + .exists() + { + includes.push(vendored_include); + } + + for candidate in [ + PathBuf::from("/usr/include"), + PathBuf::from("/usr/local/include"), + PathBuf::from("/opt/homebrew/include"), + ] { + if candidate.join("google/protobuf/struct.proto").exists() { + includes.push(candidate); + } + } + + Ok(includes) +} + +fn emit_git_short_hash() { + println!("cargo:rerun-if-env-changed=STACKER_GIT_SHORT_HASH"); + + if let Some(hash) = env::var("STACKER_GIT_SHORT_HASH") + .ok() + .and_then(|value| normalize_hash(&value)) + { + println!("cargo:rustc-env=STACKER_GIT_SHORT_HASH={hash}"); + return; + } + + if let Some(git_dir) = resolve_git_dir() { + emit_git_rerun_hints(&git_dir); + } + + if let Some(hash) = + run_git(&["rev-parse", "--short=7", "HEAD"]).and_then(|value| normalize_hash(&value)) + { + println!("cargo:rustc-env=STACKER_GIT_SHORT_HASH={hash}"); + } +} + +fn normalize_hash(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + Some(trimmed.to_string()) +} + +fn resolve_git_dir() -> Option { + let manifest_dir = env::var_os("CARGO_MANIFEST_DIR").map(PathBuf::from)?; + let git_dir = run_git(&["rev-parse", "--git-dir"])?; + let git_dir = PathBuf::from(git_dir); + + Some(if git_dir.is_absolute() { + git_dir + } else { + manifest_dir.join(git_dir) + }) +} + +fn emit_git_rerun_hints(git_dir: &Path) { + let head_path = git_dir.join("HEAD"); + println!("cargo:rerun-if-changed={}", head_path.display()); + + if let Ok(head_contents) = fs::read_to_string(&head_path) { + if let Some(reference) = head_contents.trim().strip_prefix("ref: ") { + println!( + "cargo:rerun-if-changed={}", + git_dir.join(reference.trim()).display() + ); + } + } +} + +fn run_git(args: &[&str]) -> Option { + let output = Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; + } + + String::from_utf8(output.stdout) + .ok() + .and_then(|value| normalize_hash(&value)) +} diff --git a/configuration.yaml.dist b/configuration.yaml.dist index 84570ee5..57f2bd36 100644 --- a/configuration.yaml.dist +++ b/configuration.yaml.dist @@ -2,6 +2,9 @@ app_host: 127.0.0.1 app_port: 8000 auth_url: https://dev.try.direct/server/user/oauth_server/api/me +# Bound auth dependency latency so API routes do not stall behind a slow auth service. +auth_request_timeout_secs: 5 +auth_connect_timeout_secs: 2 max_clients_number: 2 agent_command_poll_timeout_secs: 30 agent_command_poll_interval_secs: 3 @@ -66,10 +69,41 @@ connectors: # Env overrides (optional): # VAULT_ADDRESS, VAULT_TOKEN, VAULT_AGENT_PATH_PREFIX # USER_SERVICE_AUTH_TOKEN, PAYMENT_SERVICE_AUTH_TOKEN +# STACKER_AUTH_REQUEST_TIMEOUT_SECS, STACKER_AUTH_CONNECT_TIMEOUT_SECS # DEFAULT_DEPLOY_DIR - Base directory for deployments (default: /home/trydirect) +# STACKER_PAYOUT_PROVIDER=mock|stripe_connect +# STRIPE_SECRET_KEY or PAYOUT_STRIPE_SECRET_KEY +# STRIPE_WEBHOOK_SECRET or PAYOUT_STRIPE_WEBHOOK_SECRET +# PAYOUT_ONBOARDING_RETURN_URL, PAYOUT_ONBOARDING_REFRESH_URL # Deployment settings # deployment: # # Base path for app config files on the deployment server # # Can also be set via DEFAULT_DEPLOY_DIR environment variable # config_base_path: /home/trydirect + +# Vendor payout provider. Defaults to mock for local/dev/test. +# For production Stripe Connect, set provider: stripe_connect and provide STRIPE_SECRET_KEY +# via environment variable rather than committing it here. +# payouts: +# provider: mock +# stripe_api_base_url: https://api.stripe.com +# onboarding_return_url: https://stacker.try.direct/marketplace/vendor/onboarding/return +# onboarding_refresh_url: https://stacker.try.direct/marketplace/vendor/onboarding/refresh +# timeout_secs: 15 + +# Marketplace asset storage (Hetzner Object Storage / S3-compatible) +# marketplace_assets: +# enabled: true +# current_env: dev +# endpoint_url: https://eu-central.objects.hetzner.com +# region: eu-central +# access_key_id: your-access-key +# secret_access_key: your-secret-key +# bucket_dev: marketplace-assets-dev +# bucket_test: marketplace-assets-test +# bucket_staging: marketplace-assets-staging +# bucket_prod: marketplace-assets-prod +# server_side_encryption: AES256 +# presign_put_ttl_secs: 900 +# presign_get_ttl_secs: 300 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/docker-compose.dev.yml b/docker-compose.dev.yml index 1a738ee1..703bdb8f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -40,8 +40,11 @@ services: - RUST_LOG=debug - RUST_BACKTRACE=1 # Vault — must point to the real Vault service in the TryDirect network - - VAULT_ADDRESS=http://vault2.try.direct:8200 + - VAULT_ADDRESS=https://vault.try.direct - VAULT_TOKEN=${STACKER_VAULT_TOKEN:-change-me} + # mTLS client cert for Vault — inline PEMs + - VAULT_CLIENT_CERT=${VAULT_CLIENT_CERT:-} + - VAULT_CLIENT_KEY=${VAULT_CLIENT_KEY:-} depends_on: stackerdb: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 51b7e611..c83361d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,5 +65,5 @@ services: env_file: - ./docker/local/.env volumes: - - stackerdb:/var/lib/postgresql/data + - stackerdb:/var/lib/postgresql - ./docker/local/postgresql.conf:/etc/postgresql/postgresql.conf \ No newline at end of file diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index ea7ee0d7..1f9051fe 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -1,5 +1,3 @@ -version: "2.2" - volumes: stackerdb: driver: local @@ -8,30 +6,28 @@ volumes: driver: local networks: - backend: + trydirect_default: driver: bridge - name: backend external: true trydirect-network: external: true name: trydirect-network - services: - stacker: - image: trydirect/stacker:0.0.8 - build: . + image: trydirect/stacker:latest + #image: trydirect/stacker:test container_name: stacker restart: always volumes: - ./stacker/files:/app/files + - ./.env:/app/.env - ./configuration.yaml:/app/configuration.yaml - ./access_control.conf:/app/access_control.conf - ./migrations:/app/migrations - - ./.env:/app/.env + - ./stacker/ansible:/ansible/roles:ro ports: - - "8000:8000" + - "8001:8000" env_file: - ./.env environment: @@ -41,12 +37,12 @@ services: stackerdb: condition: service_healthy networks: - - backend + - trydirect_default - - stacker_queue: - image: trydirect/stacker:0.0.7 - container_name: stacker_queue + stackermq: + image: trydirect/stacker:latest + #image: trydirect/stacker:test # for testing mcp + container_name: stackermq restart: always volumes: - ./configuration.yaml:/app/configuration.yaml @@ -54,10 +50,6 @@ services: environment: - RUST_LOG=debug - RUST_BACKTRACE=1 - - AMQP_HOST=rabbitmq - - AMQP_PORT=5672 - - AMQP_USERNAME=guest - - AMQP_PASSWORD=guest env_file: - ./.env depends_on: @@ -65,9 +57,7 @@ services: condition: service_healthy entrypoint: /app/console mq listen networks: - - backend - - trydirect-network - + - trydirect_default stackerdb: container_name: stackerdb @@ -76,17 +66,17 @@ services: interval: 10s timeout: 5s retries: 5 - image: postgres:18.3 + image: postgres:16.0 restart: always ports: - - 5432 + - 5434:5432 env_file: - ./.env volumes: - stackerdb:/var/lib/postgresql/data - ./postgresql.conf:/etc/postgresql/postgresql.conf networks: - - backend + - trydirect_default stackerredis: container_name: stackerredis @@ -105,5 +95,5 @@ services: options: max-size: "10m" tag: "container_{{.Name}}" - - + networks: + - trydirect_default 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 08c8c17b..d0a6ecb5 100644 --- a/docs/APP_DEPLOYMENT.md +++ b/docs/APP_DEPLOYMENT.md @@ -56,7 +56,7 @@ Each deployment receives its own Vault token, scoped to only access that deploym │ └── _compose # Global docker-compose.yml (legacy) ├── {app_code}/ │ ├── _compose # Per-app docker-compose.yml - │ ├── _env # Per-app rendered .env file + │ ├── _env # Runtime env payload for canonical .env │ ├── _configs # Bundled config files (JSON array) │ └── _config # Legacy single config file └── {app_code_2}/ @@ -70,7 +70,7 @@ Each deployment receives its own Vault token, scoped to only access that deploym | Key Format | Vault Path | Description | Example | |------------|------------|-------------|---------| | `{app_code}` | `apps/{app_code}/_compose` | docker-compose.yml | `telegraf` → compose | -| `{app_code}_env` | `apps/{app_code}/_env` | Rendered .env file | `telegraf_env` → env vars | +| `{app_code}_env` | `apps/{app_code}/_env` | Runtime env payload for canonical `.env` | `telegraf_env` → env vars | | `{app_code}_configs` | `apps/{app_code}/_configs` | Bundled config files (JSON) | `telegraf_configs` → multiple configs | | `{app_code}_config` | `apps/{app_code}/_config` | Single config (legacy) | `nginx_config` → nginx.conf | | `_compose` | `apps/_compose/_compose` | Global compose (legacy) | Full stack compose | @@ -87,17 +87,32 @@ Each deployment receives its own Vault token, scoped to only access that deploym - When `project_app` is created/updated, `ConfigRenderer` generates files - `ProjectAppService.sync_to_vault()` pushes configs to Vault: - **Compose** stored at `{app_code}` key → `apps/{app_code}/_compose` - - **.env files** stored at `{app_code}_env` key → `apps/{app_code}/_env` + - **Runtime env payloads** stored at `{app_code}_env` key → `apps/{app_code}/_env` - **Config bundles** stored at `{app_code}_configs` key → `apps/{app_code}/_configs` - Config bundle is a JSON array containing all config files for the app 3. **Command Enrichment** (Stacker → Status Panel): - When `deploy_app` command is issued, Stacker enriches the command payload - - Fetches from Vault: `{app_code}` (compose), `{app_code}_env` (.env), `{app_code}_configs` (bundle) + - Fetches from Vault: `{app_code}` (compose), `{app_code}_env` (runtime env), `{app_code}_configs` (bundle) + - For CLI-provided app-local config bundles, merges the app-local service + definition into the full project compose, then merges the freshly rendered + service-secret env into any `.env` file referenced by that app's compose + `env_file` + - If runtime env rendering fails, command creation fails rather than falling + back to raw bundled `.env` content that could omit remote secrets - Adds all configs to `config_files` array in command payload - Status Panel receives complete config set ready to write 4. **Runtime** (Status Panel Agent): + - Writes the runtime env payload to `/home/trydirect/project/.env` with + `0600` permissions + - Uses compose-relative `env_file: .env` for generated compose files + - For app-local compose files such as `/docker//compose.yml`, writes + bundled config files under `/opt/stacker/deployments//files/...`; if + that compose file references an app-local `.env`, the file contains the + local `.env` content plus the Vault-rendered service secrets for the same + app target + - Refuses to overwrite drifted env content unless the command is forced - Agent reads `VAULT_TOKEN` from environment on startup - Fetches configs via `VaultClient.fetch_app_config()` - Writes files to destination paths with specified permissions @@ -136,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` @@ -220,11 +283,30 @@ vault.store_app_config(deployment_hash, &format!("{}_configs", app_code), &bundl ```rust // 1. Fetch compose from Vault: {app_code} key // 2. Fetch bundled configs: {app_code}_configs key (or fallback to _config) -// 3. Fetch .env file: {app_code}_env key -// 4. Merge all into config_files array -// 5. Send enriched command to Status Panel +// 3. Render runtime env from app env + remote service secrets +// 4. Merge rendered env into app-local compose env_file entries when present +// 5. Add canonical runtime env and bundled files to config_files array +// 6. Send enriched command to Status Panel ``` +When a CLI request already includes `compose_content` and config files from an +app-local compose bundle, Stacker uses the app-local service definition for the +target app but merges it into the full project compose before sending +`compose_content` to the Status agent. The agent still writes one +`docker-compose.yml`, but it contains all project services plus the updated +app-local service. The CLI treats the project-level compose as topology in this +path and bundles only files referenced by the target app-local compose, so a +missing `env_file` for an unrelated service does not block app-only updates. +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. 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 **Location**: `src/services/project_app_service.rs` diff --git a/docs/DAG_PIPES_DEVELOPER_MANUAL.md b/docs/DAG_PIPES_DEVELOPER_MANUAL.md new file mode 100644 index 00000000..3ec84f2d --- /dev/null +++ b/docs/DAG_PIPES_DEVELOPER_MANUAL.md @@ -0,0 +1,23 @@ +# DAG Pipes — Developer Manual + +Build and run data pipelines that connect your deployed services. Route contact form submissions to Telegram, sync database changes to Slack, send confirmation emails — all with simple CLI commands or drag-and-drop. + +## Guide Overview + +| Part | For | What you'll learn | +|------|-----|------------------| +| **[Part 1: CLI Guide](./DAG_PIPES_PART1_CLI_GUIDE.md)** | Getting started | Create and run pipes using `stacker pipe` commands (includes local mode) | +| **[Part 2: Visual Editor](./DAG_PIPES_PART2_WEB_EDITOR.md)** | Visual builders | Drag-and-drop pipeline builder in your browser | +| **[Part 3: REST API Deep Dive](./DAG_PIPES_PART3_API_DEEP_DIVE.md)** | Automation & scripting | Full API reference, curl scripts, gRPC streaming | + +> **💡 Local mode**: You can build and test pipes against local Docker containers without a cloud deployment. See the [Local Mode section in Part 1](./DAG_PIPES_PART1_CLI_GUIDE.md#local-mode-experimental) for setup and workflow. + +## Examples in All Three Guides + +Each guide walks through the same practical examples: + +1. **Contact Form → Telegram + Slack** — forward form submissions to both channels simultaneously +2. **Contact Form → PostgreSQL CDC → Telegram** — database watches for new rows, sends notifications automatically +3. **Contact Form → Email + Slack** — send confirmation email + team notification + +**Start with [Part 1](./DAG_PIPES_PART1_CLI_GUIDE.md)** — it takes 5 minutes and covers everything you need to get a pipe running. diff --git a/docs/DAG_PIPES_PART1_CLI_GUIDE.md b/docs/DAG_PIPES_PART1_CLI_GUIDE.md new file mode 100644 index 00000000..078d8879 --- /dev/null +++ b/docs/DAG_PIPES_PART1_CLI_GUIDE.md @@ -0,0 +1,380 @@ +# DAG Pipes — Part 1: CLI Guide + +Build and run data pipelines using `stacker pipe` commands. No code, no curl — just the CLI. + +> **Other guides:** +> [Part 2: Visual Editor (Web UI)](./DAG_PIPES_PART2_WEB_EDITOR.md) · +> [Part 3: REST API Deep Dive](./DAG_PIPES_PART3_API_DEEP_DIVE.md) + +--- + +## What is a Pipe? + +A pipe connects services in your deployment. Data flows from a **source** (where data comes from) through optional **transforms** and **conditions**, to one or more **targets** (where data goes). + +``` +[Source] → [Transform] → [Target] +``` + +That's it. Stacker handles the wiring, execution, retries, and history. + +--- + +## Getting Started + +```bash +# 1. Login +stacker login + +# 2. Make sure you have a deployment running +stacker status +``` + +> **💡 No cloud deployment yet?** You can experiment locally first — see [Local Mode](#local-mode-experimental) below. + +--- + +## Example 1: Contact Form → Telegram + Slack + +**Goal**: When someone submits a contact form, notify your team on both Telegram and Slack. + +### Step 1 — Scan your services + +```bash +# Remote deployment: probe app endpoints +stacker pipe scan --app website + +# See what APIs are available with sample data +stacker pipe scan --app website --capture-samples +``` + +Output: +``` +App: website +Protocols detected: rest + +[rest] http://website:3000 + POST /api/contact -- Submit contact form + fields: [name, email, message] + sample: {"name":"Alice","email":"alice@example.com","message":"Hello"} +``` + +### Step 2 — Create the pipe + +```bash +# Interactive wizard walks you through it +stacker pipe create website telegram +``` + +The wizard will: +1. Scan both apps/containers for endpoints +2. Let you pick source endpoint (POST /api/contact) +3. Let you pick target endpoint (sendMessage) +4. Auto-match fields (`name` → text, `email` → text) +5. Ask for a pipe name + +Repeat for Slack: +```bash +stacker pipe create website slack +``` + +### Step 3 — Activate + +```bash +# List your pipes to get the IDs +stacker pipe list + +# Activate both — webhook mode triggers on each form submission +stacker pipe activate +stacker pipe activate +``` + +### Step 4 — Test it + +```bash +# Manual trigger with test data +stacker pipe trigger \ + --data '{"name":"Alice","email":"alice@example.com","message":"Hello!"}' +``` + +### Step 5 — Check history + +```bash +stacker pipe history +``` + +``` +EXECUTION ID TRIGGER STATUS DURATION STARTED +───────────────────────────────────────────────────────────────── +a1b2c3d4-e5f6-... manual success 342ms 2026-04-16T13:00:00Z + +1 execution(s) shown. +``` + +--- + +## Example 2: Contact Form → PostgreSQL CDC → Telegram + +**Goal**: Your website saves contact forms to PostgreSQL normally. The pipe watches for new rows and sends a Telegram notification — no changes to your website code needed. + +``` +Website → writes to PostgreSQL (as usual) + ↓ CDC detects new row + [cdc_source] → [transform] → [target: telegram] +``` + +### Step 1 — Scan PostgreSQL for CDC + +```bash +stacker pipe scan postgresql --protocols cdc +``` + +Output: +``` +App: postgresql +Protocols detected: cdc + +[cdc] postgresql://postgres:5432 + TABLE public.contacts -- Contact form submissions + fields: [id, name, email, message, created_at] + TABLE public.users -- User accounts + fields: [id, email, password_hash, created_at] +``` + +### Step 2 — Create the pipe + +```bash +stacker pipe create postgresql telegram +# Select: TABLE public.contacts → POST sendMessage +# The wizard maps: name, email, message → text field +``` + +### Step 3 — Activate with webhook trigger + +```bash +stacker pipe activate --trigger webhook +``` + +Now every INSERT into the `contacts` table automatically sends a Telegram message. No polling, no cron jobs. + +### Step 4 — Verify + +```bash +# Insert a test row into PostgreSQL (from your app or directly) +# Then check pipe history: +stacker pipe history +``` + +--- + +## Example 3: Contact Form → Email + Slack + +**Goal**: Send a confirmation email to the user AND post to your team's Slack channel. + +### Step 1 — Create both pipes + +```bash +# Pipe 1: website → email service +stacker pipe create website email-service + +# Pipe 2: website → slack +stacker pipe create website slack +``` + +### Step 2 — Activate both + +```bash +stacker pipe activate --trigger webhook +stacker pipe activate --trigger webhook +``` + +### Step 3 — Test + +```bash +# Trigger both with the same data +stacker pipe trigger \ + --data '{"name":"Carol","email":"carol@example.com","message":"Demo please"}' + +stacker pipe trigger \ + --data '{"name":"Carol","email":"carol@example.com","message":"Demo please"}' +``` + +--- + +## Command Reference + +| Command | What it does | +|---------|-------------| +| `stacker pipe scan` | Discover local Docker containers | +| `stacker pipe scan --containers [filter]` | Discover local containers by name | +| `stacker pipe scan --app ` | Discover what APIs a remote app exposes | +| `stacker pipe create ` | Create a pipe (interactive wizard) | +| `stacker pipe list` | Show all pipes for your deployment | +| `stacker pipe activate ` | Start the pipe (begin listening) | +| `stacker pipe deactivate ` | Stop the pipe | +| `stacker pipe trigger ` | Run the pipe once manually | +| `stacker pipe history ` | View past executions | +| `stacker pipe replay ` | Re-run a past execution | +| `stacker pipe deploy --deployment ` | Promote local pipe to remote | +| `stacker target [local\|cloud\|server]` | Switch deployment target mode | + +### Useful flags + +| Flag | Used with | What it does | +|------|-----------|-------------| +| `--json` | Any command | Output as JSON (for scripting) | +| `--trigger webhook` | `activate` | Listen for events in real-time (default) | +| `--trigger poll` | `activate` | Check for changes periodically | +| `--poll-interval 60` | `activate` | Poll every N seconds | +| `--trigger manual` | `activate` | Only run when you call `trigger` | +| `--data '{...}'` | `trigger` | Pass custom input data | +| `--capture-samples` | `scan` | Show real response examples | +| `--ai` | `create` | Use AI for smart field matching | +| `--no-ai` | `create` | Use deterministic matching only | +| `--manual` | `create` | Skip auto-matching entirely | +| `--limit 50` | `history` | Show more results | + +--- + +## Trigger Types Explained + +| Type | How it works | Best for | +|------|-------------|----------| +| **webhook** | Fires instantly when data arrives | Real-time notifications | +| **poll** | Checks for new data every N seconds | Periodic syncs, batch jobs | +| **manual** | Only runs when you say `pipe trigger` | Testing, one-off transfers | + +--- + +## Debugging + +```bash +# See what went wrong +stacker pipe history --json | jq '.[0]' + +# Replay a failed execution (retries with same input) +stacker pipe replay + +# Trigger with custom test data +stacker pipe trigger --data '{"name":"test","email":"test@test.com","message":"debug"}' +``` + +--- + +## Local Mode (Experimental) + +Local mode lets you design, test, and iterate on pipes **without a cloud deployment**. Pipes run against your local Docker containers. + +### Setting Up Local Mode + +```bash +# Switch to local mode +stacker target local + +# Verify active target +stacker target +# Output: Active target: local + +# All pipe commands now show [local] prefix +stacker pipe scan +# [local] ✓ 3 containers discovered +# [local] ✓ 7 endpoints/resources discovered +``` + +### Local Workflow + +```bash +# 1. Discover local endpoints/resources from running containers +stacker pipe scan + +# Optional: narrow to matching container names +stacker pipe scan --containers website + +# 2. Create a pipe — no deployment hash needed +stacker pipe create website telegram +# [local] ✓ Pipe instance created (id: abc-123) + +# 3. Trigger locally (executes via docker exec) +stacker pipe trigger abc-123 --data '{"name":"test","email":"test@test.com"}' + +# 4. Check history +stacker pipe history abc-123 + +# 5. When ready — promote to a remote deployment +stacker pipe deploy abc-123 --deployment +# ✓ Local pipe promoted to remote deployment +# Remote instance ID: def-456 +# Use 'stacker pipe activate def-456' to start the remote pipe. +``` + +### Switching Targets + +```bash +stacker target local # local Docker containers +stacker target cloud # cloud deployment (from prior deploy) +stacker target server # dedicated server deployment +stacker target # show current +``` + +### What Works Locally + +| Command | Local Behavior | +|---------|---------------| +| `pipe scan` | Discovers local endpoints/resources from running containers | +| `pipe scan --containers [filter]` | Filters matching containers, then probes their endpoints/resources | +| `pipe scan --app ` | Not used locally — use container discovery instead | +| `pipe create` | Creates pipe with `is_local=true`, no deployment hash | +| `pipe list` | Shows your local pipes only | +| `pipe trigger` | Executes via `docker exec` / HTTP | +| `pipe history` | Shows execution history | +| `pipe deploy` | Promotes local pipe → remote deployment | +| `pipe activate/deactivate` | Remote only (use after deploy) | +| `pipe replay` | Remote only | + +### Scan Semantics + +- **Local target** → scan works with **containers** +- **Remote target** → scan works with **apps**, optionally narrowed by `--container` + +```bash +# Local +stacker pipe scan +stacker pipe scan --containers upload + +# Remote +stacker pipe scan --app website +stacker pipe scan --app website --container website-web-1 +``` + +Legacy `stacker pipe scan ` still works during the transition: + +- in **local mode** it is treated as a container name filter +- in **remote mode** it is treated as an app code + +When local scan succeeds, expect output like: + +```text +[local] ✓ 1 container(s) discovered + + Containers matched: 1 + local-device-api-1 [app-network] example/device-api:local + addresses: 172.18.0.20:5050 + + App: device-api + Protocols detected: openapi, postgres + + [openapi] http://172.18.0.20:5050/openapi.json + GET /devices + fields: [id, name] + + Resources: + [postgres] postgres://172.18.0.10:5432/app (local-postgres-1) + table public.devices -- CDC candidate +``` + +--- + +## What's Next? + +- **[Part 2: Visual Editor](./DAG_PIPES_PART2_WEB_EDITOR.md)** — Build pipes with drag-and-drop in your browser +- **[Part 3: REST API Deep Dive](./DAG_PIPES_PART3_API_DEEP_DIVE.md)** — Full API reference, curl scripts, gRPC streaming, advanced DAG features diff --git a/docs/DAG_PIPES_PART2_WEB_EDITOR.md b/docs/DAG_PIPES_PART2_WEB_EDITOR.md new file mode 100644 index 00000000..11ef6f41 --- /dev/null +++ b/docs/DAG_PIPES_PART2_WEB_EDITOR.md @@ -0,0 +1,189 @@ +# DAG Pipes — Part 2: Visual Editor (Web UI) + +Build data pipelines with drag-and-drop — no terminal needed. + +> **Other guides:** +> [Part 1: CLI Guide](./DAG_PIPES_PART1_CLI_GUIDE.md) · +> [Part 3: REST API Deep Dive](./DAG_PIPES_PART3_API_DEEP_DIVE.md) + +--- + +## Open the Editor + +``` +http://localhost:8080/editor +``` + +> **Demo Mode**: The editor works without login for local experimentation. Changes exist only in the browser. Click **"Sign Up / Login"** to save pipelines to the server. + +--- + +## Quick Start: Contact Form → Telegram + Slack + +Let's build Example 1 from the CLI guide — visually. + +### 1. Start with a template (optional) + +Click **"Use Template"** and pick one: + +| Template | What you get | +|----------|-------------| +| **ETL Pipeline** | source → transform → target (simplest) | +| **Webhook Router** | source → condition → two targets | +| **CDC Replicator** | CDC source → transform → target | + +Or start from scratch (next step). + +### 2. Drag steps from the palette + +The **left sidebar** has all available step types organized by category: + +**Sources** (where data comes from): +| Step | Icon | Use for | +|------|------|---------| +| Source | 📥 | REST API / webhook | +| CDC Source | 🔄 | PostgreSQL table changes | +| AMQP Source | 🐰 | RabbitMQ messages | +| Kafka Source | 📨 | Kafka topics | +| WebSocket Source | 🔌 | WebSocket streams | +| HTTP Stream | 🌊 | Server-Sent Events | +| gRPC Source | ⚡ | gRPC server-streaming | + +**Processing** (transform and route): +| Step | Icon | Use for | +|------|------|---------| +| Transform | 🔀 | Map/rename fields | +| Condition | ❓ | Filter (if/else branching) | +| Parallel Split | ⑃ | Fan-out to multiple targets | +| Parallel Join | ⑂ | Merge parallel branches | + +**Targets** (where data goes): +| Step | Icon | Use for | +|------|------|---------| +| Target | 📤 | REST API / webhook | +| WebSocket Target | 🔌 | Send via WebSocket | +| gRPC Target | ⚡ | Send via gRPC | + +For our example, drag these onto the canvas: + +1. **Source** 📥 — the contact form +2. **Parallel Split** ⑃ — fan out to both targets +3. **Target** 📤 — Telegram +4. **Target** 📤 — Slack +5. **Parallel Join** ⑂ — merge results + +### 3. Connect the steps + +Click the **output handle** (small circle on the right side of a node) and drag to the **input handle** (left side of the next node): + +``` +Source ──→ Parallel Split ──→ Target (Telegram) + ──→ Target (Slack) + Target (Telegram) ──→ Parallel Join + Target (Slack) ──→ Parallel Join +``` + +### 4. Configure each step + +**Click any node** to open the **config panel** on the right side. + +#### Source: contact_form +- **Name**: `contact_form` +- **URL**: `http://website:3000/api/contact` +- **Method**: `POST` + +#### Target: telegram +- **Name**: `telegram_notify` +- **URL**: `https://api.telegram.org/bot/sendMessage` +- **Method**: `POST` + +#### Target: slack +- **Name**: `slack_notify` +- **URL**: `https://hooks.slack.com/services/T.../B.../xxx` +- **Method**: `POST` + +> **Advanced config**: Toggle **"Advanced JSON"** at the bottom of the config panel to edit the raw JSON config directly. + +### 5. Validate + +Click the **"Validate"** button in the toolbar. + +- ✅ **Green toast** = DAG is valid +- ❌ **Red toast** = something's wrong (missing source, missing target, cycle detected, etc.) + +### 6. Execute + +Click **"Execute"** to run the pipeline with test data. + +--- + +## Building Example 2: CDC → Telegram + +1. Drag **CDC Source** 🔄 onto the canvas +2. Click it and configure: + - **Replication Slot**: `contacts_pipe_slot` + - **Publication**: `contact_pub` + - **Tables**: `public.contacts` +3. Drag **Transform** 🔀 → configure field mappings +4. Drag **Target** 📤 → set Telegram API URL +5. Connect: CDC Source → Transform → Target +6. Click **Validate** → **Execute** + +--- + +## Building Example 3: Form → Email + Slack + +1. Drag **Source** 📥 (form webhook) +2. Drag **Parallel Split** ⑃ +3. Drag two **Target** 📤 nodes (email service + Slack) +4. Drag **Parallel Join** ⑂ +5. Connect: Source → Split → both Targets → Join +6. Configure each target with its URL +7. **Validate** → **Execute** + +--- + +## Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `Delete` / `Backspace` | Delete selected edge or node | +| Drag from handle | Create a connection | +| Click a node | Open config panel | +| Scroll wheel | Zoom in / out | +| Click + drag canvas | Pan around | + +--- + +## Tips + +- **Delete a connection**: Click on the edge (it highlights), then press `Delete` or `Backspace` +- **Delete a step**: Click the node, then press `Delete` +- **Move a step**: Click and drag it to a new position +- **Zoom to fit**: Scroll out or use the minimap (bottom-right) +- **Switch to JSON editing**: Toggle "Advanced JSON" in the config panel for full control +- **Start from a template**: Much faster than building from scratch — customize from there + +--- + +## Demo Mode vs Authenticated + +| Feature | Demo Mode | Logged In | +|---------|-----------|-----------| +| Build pipelines | ✅ | ✅ | +| Drag & drop | ✅ | ✅ | +| Validate | ❌ (skipped) | ✅ | +| Execute | ❌ (skipped) | ✅ | +| Save to server | ❌ | ✅ | +| Load existing pipes | ❌ | ✅ | + +Demo mode is great for learning the interface. Sign in to actually run pipelines. + +> **💡 Local mode (CLI)**: For local experimentation with _real_ execution, use `stacker target local` and the CLI pipe commands (see [Part 1: Local Mode](./DAG_PIPES_PART1_CLI_GUIDE.md#local-mode-experimental)). Local pipes can later be promoted to remote via `stacker pipe deploy`. + +--- + +## What's Next? + +- **[Part 1: CLI Guide](./DAG_PIPES_PART1_CLI_GUIDE.md)** — Same examples using terminal commands +- **[Part 3: REST API Deep Dive](./DAG_PIPES_PART3_API_DEEP_DIVE.md)** — Full API reference, automation scripts, gRPC streaming diff --git a/docs/DAG_PIPES_PART3_API_DEEP_DIVE.md b/docs/DAG_PIPES_PART3_API_DEEP_DIVE.md new file mode 100644 index 00000000..7a4eeebd --- /dev/null +++ b/docs/DAG_PIPES_PART3_API_DEEP_DIVE.md @@ -0,0 +1,481 @@ +# DAG Pipes — Part 3: REST API Deep Dive + +Automate pipeline creation with curl/scripts. Full API reference, gRPC streaming, and advanced DAG features. + +> **Other guides:** +> [Part 1: CLI Guide](./DAG_PIPES_PART1_CLI_GUIDE.md) · +> [Part 2: Visual Editor (Web UI)](./DAG_PIPES_PART2_WEB_EDITOR.md) + +--- + +## Setup + +```bash +# Auth token (all API calls require this) +BASE="http://localhost:8080/api/v1" +AUTH="Authorization: Bearer $(stacker token)" +CT="Content-Type: application/json" +``` + +--- + +## Concepts + +### Templates vs Instances + +- **Template** = reusable pipeline definition (steps + edges). Shareable across deployments. +- **Instance** = a template bound to a specific deployment. Tracks status, trigger counts, execution history. + +### DAG Structure + +A template contains **steps** (nodes) and **edges** (connections): + +``` +Steps: [source, transform, condition, target, ...] +Edges: [source→transform, transform→condition, condition→target, ...] +``` + +Steps are executed **level-by-level** (topological sort). Steps at the same level run in parallel. + +### Validation Rules + +- At least **one source** step required +- At least **one target** step required +- **No cycles** (it's a Directed Acyclic Graph) + +--- + +## Step Types Reference + +### Sources + +| Type | Config Fields | Description | +|------|--------------|-------------| +| `source` | `url`, `method`, `headers` | Generic REST source | +| `cdc_source` | `replication_slot`, `publication`, `tables` | PostgreSQL CDC | +| `amqp_source` | `queue`, `exchange`, `routing_key` | RabbitMQ consumer | +| `kafka_source` | `brokers`, `topic`, `group_id` | Kafka consumer | +| `ws_source` | `url` | WebSocket consumer | +| `http_stream_source` | `url`, `event_filter` | Server-Sent Events | +| `grpc_source` | `endpoint`, `pipe_instance_id`, `step_id` | gRPC server-streaming | + +### Processing + +| Type | Config Fields | Description | +|------|--------------|-------------| +| `transform` | `field_mapping` | JSONPath field mapping | +| `condition` | `field`, `operator`, `value` | Conditional branching | +| `parallel_split` | *(none)* | Fork into parallel branches | +| `parallel_join` | *(none)* | Merge parallel branches | + +### Targets + +| Type | Config Fields | Description | +|------|--------------|-------------| +| `target` | `url`, `method`, `headers`, `body_template` | Generic REST target | +| `ws_target` | `url` | WebSocket sender | +| `grpc_target` | `endpoint`, `pipe_instance_id`, `step_id` | gRPC unary call | + +### Condition Operators + +| Operator | Meaning | +|----------|---------| +| `eq` | Equals | +| `ne` | Not equals | +| `gt` | Greater than | +| `lt` | Less than | +| `gte` | Greater or equal | +| `lte` | Less or equal | + +--- + +## Example: Contact Form → Telegram + Slack (scripted) + +Complete automation script — creates the pipeline, validates, and runs it. + +```bash +#!/bin/bash +# example1-contact-to-telegram-slack.sh +set -euo pipefail + +BASE="http://localhost:8080/api/v1" +AUTH="Authorization: Bearer $(stacker token)" +CT="Content-Type: application/json" + +# Helper functions +add_step() { + curl -sf -X POST "$DAG/steps" -H "$AUTH" -H "$CT" -d "$1" | jq -r '.item.id' +} +add_edge() { + curl -sf -X POST "$DAG/edges" -H "$AUTH" -H "$CT" -d "$1" > /dev/null +} + +# --- Create template --- +TEMPLATE=$(curl -sf -X POST "$BASE/pipes/templates" \ + -H "$AUTH" -H "$CT" \ + -d '{"name":"Contact Form → Telegram + Slack"}' \ + | jq -r '.item.id') +echo "Template: $TEMPLATE" +DAG="$BASE/pipes/$TEMPLATE/dag" + +# --- Add steps --- +SOURCE=$(add_step '{ + "name": "contact_form", + "step_type": "source", + "step_order": 1, + "config": {"url": "http://website:3000/api/contact", "method": "POST"} +}') + +SPLIT=$(add_step '{ + "name": "fan_out", + "step_type": "parallel_split", + "step_order": 2, + "config": {} +}') + +TELEGRAM=$(add_step '{ + "name": "telegram_notify", + "step_type": "target", + "step_order": 3, + "config": { + "url": "https://api.telegram.org/bot/sendMessage", + "method": "POST", + "headers": {"Content-Type": "application/json"}, + "body_template": { + "chat_id": "", + "text": "📬 New contact from {{name}} ({{email}}): {{message}}" + } + } +}') + +SLACK=$(add_step '{ + "name": "slack_notify", + "step_type": "target", + "step_order": 3, + "config": { + "url": "https://hooks.slack.com/services/T.../B.../xxx", + "method": "POST", + "headers": {"Content-Type": "application/json"}, + "body_template": { + "text": "📬 *New contact*\n• {{name}} ({{email}})\n• {{message}}" + } + } +}') + +JOIN=$(add_step '{"name":"merge","step_type":"parallel_join","step_order":4,"config":{}}') + +# --- Connect edges --- +add_edge "{\"from_step_id\":\"$SOURCE\",\"to_step_id\":\"$SPLIT\"}" +add_edge "{\"from_step_id\":\"$SPLIT\",\"to_step_id\":\"$TELEGRAM\"}" +add_edge "{\"from_step_id\":\"$SPLIT\",\"to_step_id\":\"$SLACK\"}" +add_edge "{\"from_step_id\":\"$TELEGRAM\",\"to_step_id\":\"$JOIN\"}" +add_edge "{\"from_step_id\":\"$SLACK\",\"to_step_id\":\"$JOIN\"}" + +# --- Validate --- +echo "Validating..." +curl -sf -X POST "$DAG/validate" -H "$AUTH" -H "$CT" | jq . + +# --- Create instance & execute --- +INSTANCE=$(curl -sf -X POST "$BASE/pipes/instances" \ + -H "$AUTH" -H "$CT" \ + -d "{\"pipe_template_id\":\"$TEMPLATE\",\"deployment_hash\":\"my-deploy\",\"name\":\"Contact notifications\"}" \ + | jq -r '.item.id') + +echo "Executing with test data..." +curl -sf -X POST "$BASE/pipes/instances/$INSTANCE/dag/execute" \ + -H "$AUTH" -H "$CT" \ + -d '{ + "input_data": { + "name": "Alice", + "email": "alice@example.com", + "message": "Hello, I need help!" + } + }' | jq '.status, .completed_steps, .failed_steps' + +echo "✅ Done! Instance: $INSTANCE" +``` + +--- + +## Example: CDC → Telegram (scripted) + +```bash +#!/bin/bash +# example2-cdc-contact-to-telegram.sh +set -euo pipefail + +BASE="http://localhost:8080/api/v1" +AUTH="Authorization: Bearer $(stacker token)" +CT="Content-Type: application/json" + +add_step() { curl -sf -X POST "$DAG/steps" -H "$AUTH" -H "$CT" -d "$1" | jq -r '.item.id'; } +add_edge() { curl -sf -X POST "$DAG/edges" -H "$AUTH" -H "$CT" -d "$1" > /dev/null; } + +TEMPLATE=$(curl -sf -X POST "$BASE/pipes/templates" \ + -H "$AUTH" -H "$CT" \ + -d '{"name":"CDC Contact → Telegram"}' | jq -r '.item.id') +DAG="$BASE/pipes/$TEMPLATE/dag" + +CDC=$(add_step '{ + "name": "pg_contacts", + "step_type": "cdc_source", + "step_order": 1, + "config": { + "replication_slot": "contacts_pipe_slot", + "publication": "contact_pub", + "tables": ["public.contacts"] + } +}') + +TRANSFORM=$(add_step '{ + "name": "format_message", + "step_type": "transform", + "step_order": 2, + "config": { + "field_mapping": { + "chat_id": "", + "text": "📬 New contact!\nName: $.after.name\nEmail: $.after.email\nMessage: $.after.message" + } + } +}') + +TELEGRAM=$(add_step '{ + "name": "telegram", + "step_type": "target", + "step_order": 3, + "config": { + "url": "https://api.telegram.org/bot/sendMessage", + "method": "POST" + } +}') + +add_edge "{\"from_step_id\":\"$CDC\",\"to_step_id\":\"$TRANSFORM\"}" +add_edge "{\"from_step_id\":\"$TRANSFORM\",\"to_step_id\":\"$TELEGRAM\"}" + +curl -sf -X POST "$DAG/validate" -H "$AUTH" -H "$CT" | jq . +echo "✅ Template: $TEMPLATE" +``` + +Test with simulated CDC event: +```bash +curl -sf -X POST "$BASE/pipes/instances/$INSTANCE/dag/execute" \ + -H "$AUTH" -H "$CT" \ + -d '{ + "input_data": { + "table_name": "contacts", + "operation": "INSERT", + "after": {"id": 42, "name": "Bob", "email": "bob@example.com", "message": "Hi there"}, + "captured_at": "2026-04-16T13:00:00Z" + } + }' | jq . +``` + +--- + +## REST API Reference + +### Templates + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/pipes/templates` | Create template | +| `GET` | `/api/v1/pipes/templates` | List templates | +| `GET` | `/api/v1/pipes/templates/{id}` | Get template | +| `DELETE` | `/api/v1/pipes/templates/{id}` | Delete template | + +### DAG Steps + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/pipes/{template_id}/dag/steps` | Add step | +| `GET` | `/api/v1/pipes/{template_id}/dag/steps` | List steps | +| `GET` | `/api/v1/pipes/{template_id}/dag/steps/{step_id}` | Get step | +| `PUT` | `/api/v1/pipes/{template_id}/dag/steps/{step_id}` | Update step | +| `DELETE` | `/api/v1/pipes/{template_id}/dag/steps/{step_id}` | Delete step | + +### DAG Edges + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/pipes/{template_id}/dag/edges` | Add edge | +| `GET` | `/api/v1/pipes/{template_id}/dag/edges` | List edges | +| `DELETE` | `/api/v1/pipes/{template_id}/dag/edges/{edge_id}` | Delete edge | + +### DAG Validation & Execution + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/pipes/{template_id}/dag/validate` | Validate DAG | +| `POST` | `/api/v1/pipes/instances/{instance_id}/dag/execute` | Execute DAG | +| `GET` | `/api/v1/pipes/{template_id}/dag/executions/{exec_id}/steps` | Step execution details | + +### Instances + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/pipes/instances` | Create instance | +| `GET` | `/api/v1/pipes/instances/{deployment_hash}` | List by deployment | +| `GET` | `/api/v1/pipes/instances/local` | List local instances | +| `GET` | `/api/v1/pipes/instances/detail/{id}` | Get instance | +| `PUT` | `/api/v1/pipes/instances/{id}/status` | Update status | +| `POST` | `/api/v1/pipes/instances/{id}/deploy` | Promote local → remote | +| `DELETE` | `/api/v1/pipes/instances/{id}` | Delete instance | + +### Executions + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/pipes/instances/{id}/executions` | List executions | +| `GET` | `/api/v1/pipes/executions/{id}` | Get execution | +| `POST` | `/api/v1/pipes/executions/{id}/replay` | Replay execution | + +### Streaming + +| Protocol | Path | Description | +|----------|------|-------------| +| WebSocket | `/api/v1/pipes/instances/{id}/stream` | Live execution events | + +### Resilience (Circuit Breaker + Dead Letter Queue) + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/pipes/*/dlq` | List dead-letter items | +| `POST` | `/api/v1/pipes/*/dlq/{id}/retry` | Retry failed item | +| `POST` | `/api/v1/pipes/*/dlq/{id}/discard` | Discard failed item | +| `GET` | `/api/v1/pipes/*/circuit-breaker` | Get circuit breaker state | +| `PUT` | `/api/v1/pipes/*/circuit-breaker` | Configure thresholds | +| `POST` | `/api/v1/pipes/*/circuit-breaker/reset` | Reset circuit breaker | + +--- + +## gRPC Streaming + +For high-throughput or real-time pipelines, use gRPC steps instead of REST. + +### Protocol (proto/pipe.proto) + +```protobuf +service PipeService { + // Send data to a target (unary) + rpc Send(PipeMessage) returns (PipeResponse); + + // Subscribe to a source (server-streaming) + rpc Subscribe(SubscribeRequest) returns (stream PipeMessage); +} + +message PipeMessage { + string pipe_instance_id = 1; + string step_id = 2; + google.protobuf.Struct payload = 3; // Arbitrary JSON + int64 timestamp_ms = 4; +} + +message PipeResponse { + bool success = 1; + string message = 2; +} + +message SubscribeRequest { + string pipe_instance_id = 1; + string step_id = 2; + map filters = 3; +} +``` + +### Using gRPC in a DAG + +```bash +# gRPC source step — subscribes to server-streaming RPC +add_step '{ + "name": "live_feed", + "step_type": "grpc_source", + "config": { + "endpoint": "http://grpc-service:50051", + "pipe_instance_id": "...", + "step_id": "..." + } +}' + +# gRPC target step — sends via unary RPC +add_step '{ + "name": "push_to_grpc", + "step_type": "grpc_target", + "config": { + "endpoint": "http://grpc-service:50051", + "pipe_instance_id": "...", + "step_id": "..." + } +}' +``` + +--- + +## API Response Formats + +### Single item (POST, GET by ID) +```json +{"item": {"id": "uuid", "name": "...", ...}} +``` + +### List (GET collection) +```json +{"list": [{"id": "uuid", ...}, {"id": "uuid", ...}]} +``` + +### DELETE +Returns `204 No Content` (empty body). + +### Validation +```json +{"valid": true, "total_steps": 5, "execution_levels": 3, "sources": ["source"], "targets": ["target"]} +``` + +### Execution result +```json +{ + "execution_id": "uuid", + "status": "completed", + "total_steps": 5, + "completed_steps": 5, + "failed_steps": 0, + "skipped_steps": 0, + "execution_order": ["step1", "step2", "..."], + "step_results": [{"step_id": "...", "status": "completed", "output_data": {...}}, ...] +} +``` + +--- + +## Troubleshooting + +### "No source step found" +DAG needs at least one source. Valid types: `source`, `cdc_source`, `amqp_source`, `kafka_source`, `ws_source`, `http_stream_source`, `grpc_source`. + +### "No target step found" +Add a `target`, `ws_target`, or `grpc_target` step. + +### "Cycle detected" +Edges form a loop. Remove the circular edge. + +### 401 Unauthorized +Run `stacker login` or check your `Authorization: Bearer ` header. + +### Step execution failed +```bash +curl -s "$BASE/pipes/$TEMPLATE/dag/executions/$EXEC_ID/steps" \ + -H "$AUTH" | jq '.[] | select(.status == "failed") | {name: .name, error: .error}' +``` + +### CDC not receiving events +1. PostgreSQL: `wal_level = logical` in postgresql.conf +2. Replication slot exists: `SELECT * FROM pg_replication_slots;` +3. Publication exists: `SELECT * FROM pg_publication_tables;` + +### AMQP not consuming +1. RabbitMQ accessible? Check Management UI (port 15672) +2. Queue exists? Exchange and routing key match publisher? + +### Kafka not subscribing +1. Brokers reachable? `kafkacat -b localhost:9092 -L` +2. Topic exists? `kafka-topics.sh --list --bootstrap-server localhost:9092` +3. `group_id` conflicts with another consumer? diff --git a/docs/MARKETPLACE_PUBLISH.md b/docs/MARKETPLACE_PUBLISH.md new file mode 100644 index 00000000..5fcc6fd3 --- /dev/null +++ b/docs/MARKETPLACE_PUBLISH.md @@ -0,0 +1,288 @@ +# Publishing a Stack to the TryDirect Marketplace + +This guide walks creators through publishing a stack to the TryDirect marketplace +so other users can deploy it in one click and you earn on every sale. + +--- + +## Table of Contents + +- [Who this is for](#who-this-is-for) +- [What you get](#what-you-get) +- [Two ways to publish](#two-ways-to-publish) +- [Path A: Publish from Stack Builder (UI)](#path-a-publish-from-stack-builder-ui) +- [Path B: Publish from CLI (stacker.yml)](#path-b-publish-from-cli-stackeryml) +- [Required metadata](#required-metadata) +- [Pricing options](#pricing-options) +- [The review process](#the-review-process) +- [After approval](#after-approval) +- [Updating a published template](#updating-a-published-template) +- [Common rejection reasons](#common-rejection-reasons) +- [FAQ](#faq) + +--- + +## Who this is for + +You should publish to the marketplace if you have a working Docker Compose stack +or `stacker.yml` that: + +- Solves a clear business problem (e.g. "Internal AI Helpdesk", "RAG Knowledge Base") +- Wires multiple services together so buyers don't have to (database + cache + app + LLM, etc.) +- Has been tested end-to-end on at least one cloud provider + +Single-container stacks are accepted but multi-service bundles consistently +outperform them in deployments and revenue. + +--- + +## What you get + +- **75% revenue share** on every paid deployment, including subscription renewals +- **Monthly Net-30 payouts** via Stripe Connect or PayPal (minimum $50) +- Automatic marketplace promotion: SEO landing page at `/applications/`, + inclusion in weekly digests, "Trending" badge if your template gains traction +- A "Deploy with TryDirect" badge you can embed in your GitHub README +- Public deploy count and creator profile + +Full payout terms: see `config/docs/MARKETPLACE_PAYOUT_TERMS.md`. + +--- + +## Two ways to publish + +| Path | Best for | Effort | +|---|---|---| +| **Stack Builder (UI)** | Stacks you built visually inside TryDirect | Click "Publish to Marketplace" | +| **stacker.yml (CLI)** | Stacks defined in your own repo | `stacker publish` | + +Both paths produce the same `StackTemplate` record and follow the same review +process. Pick whichever fits how you author your stack. + +--- + +## Path A: Publish from Stack Builder (UI) + +1. Open your stack in **Stack Builder** at `/builder`. +2. Verify it deploys cleanly to a test server. +3. Click **Publish to Marketplace** in the project sidebar. +4. Fill in the publish form: + - **Name** — a business-oriented name, not just tech ("Client AI Agent Workspace", not "n8n+Qdrant+Ollama") + - **Short description** — one sentence: what problem does it solve? + - **Long description** — markdown supported; cover use cases, requirements, customisation + - **Category** — AI Agents, Data Pipelines, SaaS Starter, Dev Tools, etc. + - **Tags** — `n8n`, `qdrant`, `ollama`, `supabase`, `postgres`, etc. + - **License / pricing** — Free, Paid (one-time), or Subscription + - **Price** (if paid) — USD + - **Support URL** — GitHub repo, docs site, or contact form + - **No-secrets confirmation** — required checkbox: confirms you removed all + embedded credentials before submitting +5. Click **Submit to marketplace**. Your dashboard will show status: + `In review` → `Approved` or `Rejected (with reason)`. + +--- + +## Path B: Publish from CLI (stacker.yml) + +Add a `marketplace` section to your `stacker.yml`, then run `stacker publish`. + +### Example `stacker.yml` marketplace block + +```yaml +name: ai-helpdesk-starter +version: 1.0.0 + +# ... your existing app, services, deploy, etc. ... + +marketplace: + publish: true + display_name: "Internal AI Helpdesk" + short_description: "Self-hosted AI helpdesk with n8n workflows, Qdrant memory, and Ollama LLM." + long_description: | + Deploy a complete internal AI helpdesk stack: n8n handles ticket routing + and workflow automation, Qdrant stores conversation memory and document + embeddings, and Ollama serves the local LLM. + + Comes pre-wired with example workflows for common helpdesk patterns. + Customise via the n8n web UI after deployment. + category: ai-agents + tags: + - n8n + - qdrant + - ollama + - helpdesk + - rag + license: paid + pricing: + plan_type: one_time # one_time | subscription | free + price: 49 + currency: USD + support_url: https://github.com/your-org/ai-helpdesk-starter + no_secrets_confirmation: true +``` + +### Submit + +```bash +# From your project root +stacker publish + +# Stacker validates stacker.yml, packages the stack definition, +# and submits it to the TryDirect marketplace for review. +``` + +### Check status + +```bash +stacker publish --status + +# Shows: in_review | approved | rejected (with reason) +``` + +--- + +## Required metadata + +| Field | Required | Notes | +|---|---|---| +| Name | Yes | 5-80 chars, business-oriented | +| Short description | Yes | 20-200 chars, one sentence | +| Long description | Yes | Markdown, 100-5000 chars | +| Category | Yes | Must match an existing category code | +| Tags | Yes | 1-10 tags | +| License | Yes | `free`, `paid`, `subscription` | +| Price | If paid/subscription | USD, > 0 | +| Support URL | Yes | Public URL where buyers can reach you | +| No-secrets confirmation | Yes | Must be `true` | + +--- + +## Pricing options + +### Free +No revenue, but full marketplace promotion (SEO page, digest inclusion, trending +badges). Good for building reputation and audience. + +### One-time +Buyer pays once, deploys as many times as they want for their own use. +You earn 75% of the sale price. Most templates start here. + +### Subscription +Buyer pays monthly or yearly. You earn 75% of every renewal cycle, not just +the first sale. Best for templates that ship updates regularly. + +You can change pricing on future versions but not retroactively. Buyers of +v1.0.0 keep their original pricing for that version's lifetime. + +--- + +## The review process + +| Step | Time | Who | +|---|---|---| +| Submission received | Instant | Auto-confirmation email | +| Initial automated checks | < 1 hour | `stacker.yml` validation, no embedded secrets, no banned services | +| Manual review | 1-3 business days | TryDirect review team | +| Decision | — | Approved → live on `/applications`; Rejected → feedback in dashboard | + +Reviewers check: +1. **Deploys cleanly** on a fresh server +2. **No embedded credentials** in env vars, configs, or volumes +3. **No insecure defaults** (e.g. `--api.insecure=true`, `0.0.0.0/0` ACLs, hardcoded passwords) +4. **Metadata accurate** — what the listing claims matches what the stack actually does +5. **Support URL reachable** — opens to a real GitHub/docs/contact page + +--- + +## After approval + +Once approved, several things happen automatically: + +- **SEO landing page** generated at `/applications/` +- **Social post** to TryDirect Twitter/X: "New on TryDirect: