diff --git a/.github/PULL_REQUEST_TEMPLATE/agents.md b/.github/PULL_REQUEST_TEMPLATE/agents.md index 84541e60a9..e1b12cf000 100644 --- a/.github/PULL_REQUEST_TEMPLATE/agents.md +++ b/.github/PULL_REQUEST_TEMPLATE/agents.md @@ -5,8 +5,8 @@ Briefly describe the change to the agent instruction files and the expected effe Examples: * Add a new repository rule to `AGENTS.md` -* Fix contradictory guidance between `AGENTS.md` and `CLAUDE.md` -* Improve instruction clarity for `GEMINI.md` +* Fix contradictory guidance between `AGENTS.md` and `docs/agents/*` +* Improve instruction clarity for `AGENTS.md` --- @@ -24,8 +24,8 @@ Select the primary intent of this PR: List the touched agent instruction files. * [ ] `AGENTS.md` -* [ ] `CLAUDE.md` -* [ ] `GEMINI.md` +* [ ] `roles//AGENTS.md` +* [ ] `docs/agents/*` Additional notes: @@ -61,7 +61,7 @@ Key points: Describe how the instruction change was validated. * [ ] Conflicting or ambiguous guidance checked -* [ ] Consistency between `AGENTS.md`, `CLAUDE.md`, and `GEMINI.md` verified +* [ ] Consistency between `AGENTS.md`, `roles//AGENTS.md`, and `docs/agents/*` verified * [ ] Examples, workflows, or affected contributor paths reviewed --- diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 51196e8af2..bd2ad4ef11 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -13,21 +13,21 @@ flowchart TB pr["pull_request: opened, synchronize, reopened, ready_for_review"] --> eprc["entry-pr-change-orchestrate.yml"] dispatch["workflow_dispatch"] --> eman["entry-manual-steer.yml"] - epl --> orch["ci-orchestrator.yml"] - epl -->|"version tag on main"| relv["release-version.yml"] + epl --> orch["call-orchestrator.yml"] + epl -->|"version tag on main"| relv["call-release-version.yml"] eprc --> orch - eprc -->|"fork PRs: privileged prebuild"| imgbuild["images-build-ci.yml"] - eprc -->|"fork PRs: privileged prebuild"| imgmirror["images-mirror-missing.yml"] + eprc -->|"fork PRs: privileged prebuild"| imgbuild["call-images-build-ci.yml"] + eprc -->|"fork PRs: privileged prebuild"| imgmirror["call-images-mirror-missing.yml"] eman --> orch - subgraph orchestrator["ci-orchestrator.yml jobs"] + subgraph orchestrator["call-orchestrator.yml jobs"] waitfork["wait-fork-prereq-run"] --> forkready["fork-prereqs-ready"] - lintwf["lint.yml: make lint + hadolint"] - testwf["test.yml: make test"] + lintwf["call-lint.yml: make lint + hadolint"] + testwf["call-test.yml: make test"] codeql["cron-security-codeql.yml"] - buildci["build-ci-images: images-build-ci.yml"] --> dns["test-dns.yml"] - mirror["images-mirror-missing.yml"] + buildci["build-ci-images: call-images-build-ci.yml"] --> dns["call-test-dns.yml"] + mirror["call-images-mirror-missing.yml"] subgraph chain["serial chunk chain"] chunk0["test-deploy-chunk-0"] @@ -42,12 +42,12 @@ flowchart TB mirror --> chain buildci --> chain - chunk0 --> smoke["test-runner-smoke.yml"] + chunk0 --> smoke["call-test-runner-smoke.yml"] chain --> report["report-main-failures"] - instmake["test-install-make.yml"] - instpkgmgr["test-install-pkgmgr.yml"] - mirror --> devenv["test-workspace: test-workspace.yml"] + instmake["call-test-install-make.yml"] + instpkgmgr["call-test-install-pkgmgr.yml"] + mirror --> devenv["test-workspace: call-test-workspace.yml"] chain --> donegate["done"] smoke --> donegate @@ -301,12 +301,12 @@ flowchart TB pushmain["push: main"] --> updatewf prtarget["pull_request_target: opened, reopened"] --> depclose["entry-pr-open-dependabot-close.yml"] - relhighest -.->|"gh workflow run"| relver["release-version.yml"] - relver --> imgbuildci["images-build-ci.yml"] - manual["workflow_dispatch"] --> mirrorcleanup["images-mirror-cleanup.yml"] + relhighest -.->|"gh workflow run"| relver["call-release-version.yml"] + relver --> imgbuildci["call-images-build-ci.yml"] + manual["workflow_dispatch"] --> mirrorcleanup["entry-manual-mirror-cleanup.yml"] ``` Also manually dispatchable: `cron-images-mirror-all.yml`, `cron-images-cleanup-ci.yml`, `cron-cleanup-stale.yml`, `cron-update.yml`, `cron-release-highest.yml`, `call-release-version.yml`, `call-lint.yml`, `call-test.yml`, `call-test-dns.yml`, -`test-workspace.yml`, `test-runner-smoke.yml`. +`call-test-workspace.yml`, `call-test-runner-smoke.yml`. diff --git a/.github/workflows/call-images-build-ci.yml b/.github/workflows/call-images-build-ci.yml index 1719d9a623..6167740b27 100644 --- a/.github/workflows/call-images-build-ci.yml +++ b/.github/workflows/call-images-build-ci.yml @@ -41,35 +41,13 @@ concurrency: cancel-in-progress: false jobs: - prepare: - name: 📋 Build Matrix - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.out.outputs.matrix }} - image_tag: ${{ steps.out.outputs.image_tag }} - steps: - - uses: actions/checkout@v7.0.0 - - - name: 📦 Install jq - run: ./scripts/install/apt.sh jq - - - name: 🧮 Compute matrix JSON - id: out - run: scripts/github/runner/images_build_outputs.sh "${{ inputs.distros }}" "${{ inputs.image_tag }}" - build: - name: 🏗️ Build (${{ matrix.distro }}) - needs: prepare + name: 💿 Image runs-on: ubuntu-latest permissions: contents: read packages: write - strategy: - fail-fast: false - matrix: - distro: ${{ fromJson(needs.prepare.outputs.matrix) }} - steps: - name: ⬇️ Checkout trusted workflow repo (helpers) uses: actions/checkout@v7.0.0 @@ -86,6 +64,13 @@ jobs: persist-credentials: false allow-unsafe-pr-checkout: ${{ inputs.allow_unsafe_checkout }} + - name: 📦 Install jq + run: ./scripts/install/apt.sh jq + + - name: 🧮 Compute the distro list and image tag + id: out + run: scripts/github/runner/images_build_outputs.sh "${{ inputs.distros }}" "${{ inputs.image_tag }}" + - name: 💽 Disk pressure (start) shell: bash run: bash scripts/github/runner/disk_pressure_report.sh START @@ -127,20 +112,42 @@ jobs: run: | set -euo pipefail echo "owner=$(scripts/meta/resolve/repository/owner.sh)" >> "$GITHUB_OUTPUT" + echo "name=$(scripts/meta/resolve/repository/name.sh)" >> "$GITHUB_OUTPUT" - - name: 🔨 Build & push (retry 7x, 20s) + - name: 🔨 Build & push every distro (retry 7x, 20s) if: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }} shell: bash env: GITHUB_REPOSITORY_OWNER: ${{ steps.owner.outputs.owner }} BUILD_CONTEXT_DIR: _src - MATRIX_DISTRO: ${{ matrix.distro }} - IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} + INFINITO_DISTROS: ${{ join(fromJson(steps.out.outputs.matrix), ' ') }} + IMAGE_TAG: ${{ steps.out.outputs.image_tag }} USE_NIX_TOKEN: ${{ !(github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository) }} NIX_GITHUB_TOKEN: ${{ github.token }} MAX_ATTEMPTS: "7" RETRY_DELAY_SECONDS: "20" - run: ./scripts/image/push.sh + run: ./scripts/image/push_all.sh + + - name: ⏳ Assert every distro image is in the registry + shell: bash + env: + REGISTRY: ghcr.io + OWNER: ${{ steps.owner.outputs.owner }} + REPO_PREFIX: ${{ steps.owner.outputs.name }} + CI_TAG: ${{ steps.out.outputs.image_tag }} + INFINITO_DISTROS: ${{ join(fromJson(steps.out.outputs.matrix), ' ') }} + WAIT_ATTEMPTS: "90" + WAIT_SLEEP_SECONDS: "10" + run: ./scripts/meta/wait/ci_images.sh + + - name: 📤 Upload per-distro build logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: image-build-logs + path: build/image-logs/ + retention-days: 7 + if-no-files-found: ignore - name: 💽 Disk pressure (post-build) if: always() diff --git a/.github/workflows/call-orchestrator.yml b/.github/workflows/call-orchestrator.yml index 72d9f67507..f37831656c 100644 --- a/.github/workflows/call-orchestrator.yml +++ b/.github/workflows/call-orchestrator.yml @@ -37,6 +37,11 @@ on: required: false type: string default: "0" + chunk_size: + description: "📦 Rows one chunk holds. Empty or 0 derives it from the runner slots a chunk may assume and the waves the queue window affords. Typed string for the same reason as resume_from_chunk." + required: false + type: string + default: "0" resume_from_chunk: description: "⏭️ Skip every chunk below this index. Use to continue a sweep after fixing what broke it, instead of re-running the green chunks. Typed string, not number: a workflow_dispatch number input reaches the caller as a string, and handing that to a number-typed workflow_call input makes GitHub reject the job before it is created." required: false @@ -161,14 +166,12 @@ jobs: && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build-ci-images.result == 'success' - && needs.images-mirror-missing.result == 'success' - && needs.test-dns.result == 'success' }} + && needs.images-mirror-missing.result == 'success' }} needs: - lint - test - build-ci-images - images-mirror-missing - - test-dns - validate-selection uses: ./.github/workflows/call-test-deploy.yml with: @@ -176,6 +179,7 @@ jobs: index: "0" sweep: ${{ inputs.sweep != '' && inputs.sweep || github.run_number }} offset: ${{ inputs.offset }} + chunk_size: ${{ inputs.chunk_size }} whitelist: ${{ inputs.whitelist }} priority: ${{ inputs.priority }} lifecycles: ${{ inputs.lifecycles }} @@ -192,7 +196,6 @@ jobs: && needs.test.result == 'success' && needs.build-ci-images.result == 'success' && needs.images-mirror-missing.result == 'success' - && needs.test-dns.result == 'success' && (!inputs.chunk_gate || contains(fromJSON('["success","skipped"]'), needs.test-deploy-chunk-0.result)) }} needs: @@ -200,7 +203,7 @@ jobs: - test - build-ci-images - images-mirror-missing - - test-dns + - validate-selection - test-deploy-chunk-0 uses: ./.github/workflows/call-test-deploy.yml with: @@ -208,6 +211,7 @@ jobs: index: "1" sweep: ${{ inputs.sweep != '' && inputs.sweep || github.run_number }} offset: ${{ inputs.offset }} + chunk_size: ${{ inputs.chunk_size }} whitelist: ${{ inputs.whitelist }} priority: ${{ inputs.priority }} lifecycles: ${{ inputs.lifecycles }} @@ -224,7 +228,6 @@ jobs: && needs.test.result == 'success' && needs.build-ci-images.result == 'success' && needs.images-mirror-missing.result == 'success' - && needs.test-dns.result == 'success' && (!inputs.chunk_gate || (contains(fromJSON('["success","skipped"]'), needs.test-deploy-chunk-0.result) && contains(fromJSON('["success","skipped"]'), needs.test-deploy-chunk-1.result))) }} @@ -233,7 +236,7 @@ jobs: - test - build-ci-images - images-mirror-missing - - test-dns + - validate-selection - test-deploy-chunk-0 - test-deploy-chunk-1 uses: ./.github/workflows/call-test-deploy.yml @@ -242,6 +245,7 @@ jobs: index: "2" sweep: ${{ inputs.sweep != '' && inputs.sweep || github.run_number }} offset: ${{ inputs.offset }} + chunk_size: ${{ inputs.chunk_size }} whitelist: ${{ inputs.whitelist }} priority: ${{ inputs.priority }} lifecycles: ${{ inputs.lifecycles }} @@ -258,7 +262,6 @@ jobs: && needs.test.result == 'success' && needs.build-ci-images.result == 'success' && needs.images-mirror-missing.result == 'success' - && needs.test-dns.result == 'success' && (!inputs.chunk_gate || (contains(fromJSON('["success","skipped"]'), needs.test-deploy-chunk-0.result) && contains(fromJSON('["success","skipped"]'), needs.test-deploy-chunk-1.result) @@ -268,7 +271,7 @@ jobs: - test - build-ci-images - images-mirror-missing - - test-dns + - validate-selection - test-deploy-chunk-0 - test-deploy-chunk-1 - test-deploy-chunk-2 @@ -278,6 +281,7 @@ jobs: index: "3" sweep: ${{ inputs.sweep != '' && inputs.sweep || github.run_number }} offset: ${{ inputs.offset }} + chunk_size: ${{ inputs.chunk_size }} whitelist: ${{ inputs.whitelist }} priority: ${{ inputs.priority }} lifecycles: ${{ inputs.lifecycles }} diff --git a/.github/workflows/call-test-deploy.yml b/.github/workflows/call-test-deploy.yml index 82de4d6579..8cd37c3bf6 100644 --- a/.github/workflows/call-test-deploy.yml +++ b/.github/workflows/call-test-deploy.yml @@ -21,6 +21,11 @@ on: required: false type: string default: "0" + chunk_size: + description: "Rows one chunk holds; 0 derives it from the runner slots and the queue window." + required: false + type: string + default: "0" whitelist: description: "Optional space-separated whitelist of app ids" required: false @@ -125,6 +130,9 @@ env: INFINITO_IMAGE_TAG: ci-${{ github.sha }} INFINITO_GHCR_MIRROR_PREFIX: mirror INFINITO_PLAYWRIGHT_KEEP: ${{ vars.PLAYWRIGHT_KEEP_ALL }} + INFINITO_API_OPENAI_KEY: ${{ secrets.API_OPENAI_KEY || vars.API_OPENAI_KEY }} + INFINITO_API_ANTHROPIC_KEY: ${{ secrets.API_ANTHROPIC_KEY || vars.API_ANTHROPIC_KEY }} + INFINITO_API_OPENROUTER_KEY: ${{ secrets.API_OPENROUTER_KEY || vars.API_OPENROUTER_KEY }} INFINITO_APP_DISCOVERY_RUNNER: host INFINITO_DOCKER_FILESYSTEM_ALLOWED: ${{ inputs.filesystem }} INFINITO_LIFECYCLES: ${{ inputs.lifecycles }} @@ -186,12 +194,14 @@ jobs: INFINITO_CI_SWEEP: ${{ inputs.sweep }} INFINITO_CI_CHUNK: ${{ inputs.index }} INFINITO_CI_OFFSET: ${{ inputs.offset }} + INFINITO_CI_CHUNK_SIZE: ${{ inputs.chunk_size }} run: ./scripts/github/resolve/output_apps.sh - name: 🗺️ Render the plan into the run summary shell: bash env: INFINITO_CI_SWEEP: ${{ inputs.sweep }} + INFINITO_CI_CHUNK_SIZE: ${{ inputs.chunk_size }} PLAN_DISTROS: ${{ inputs.distros }} PLAN_FILESYSTEM: ${{ inputs.filesystem }} PLAN_WHITELIST: ${{ steps.resolve_wl.outputs.whitelist }} diff --git a/.github/workflows/cron-collection-source.yml b/.github/workflows/cron-collection-source.yml new file mode 100644 index 0000000000..50dc753b10 --- /dev/null +++ b/.github/workflows/cron-collection-source.yml @@ -0,0 +1,80 @@ +name: "⚖️ Source benchmark" +run-name: "${{ github.event_name == 'schedule' && '⏰ ' || '' }}⚖️ Benchmark collection sources" + +on: + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +concurrency: + group: collection-source-${{ github.repository }} + cancel-in-progress: true + +permissions: + contents: read + actions: read + +jobs: + benchmark-collection-sources: + name: ⚖️ Time Galaxy against the git fallback + if: vars.CI_ENABLE_AUTO_UPDATES == 'true' + runs-on: ubuntu-latest + env: + HISTORY_PATH: build/collection-source-history.json + steps: + - name: ⬇️ Checkout + uses: actions/checkout@v7.0.0 + with: + ref: main + + - name: 🔧 Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + + - name: 📦 Install project with the deploy extra + env: + PYTHON: python3 + run: bash scripts/install/python.sh deploy + + - name: ♻️ Restore the sample history from the last run + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HISTORY_WORKFLOW: cron-collection-source.yml + run: bash scripts/github/update/restore_collection_history.sh + + - name: ⏱️ Time both collection sources + run: bash scripts/github/update/collection_source.sh --history "${HISTORY_PATH}" + + - name: 💾 Keep the sample history for the next run + uses: actions/upload-artifact@v7 + with: + name: collection-source-history + path: ${{ env.HISTORY_PATH }} + retention-days: 90 + + - name: 🔎 Check for changes + id: diff + run: bash scripts/github/resolve/check_git_changes.sh default.env + + - name: ⚙️ Generate app token + if: steps.diff.outputs.changed == 'true' + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ secrets.BOT_APP_CLIENT_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + + - name: 📂 Open PR if the preferred source changed + if: steps.diff.outputs.changed == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + UPDATE_BRANCH_PREFIX: update/collection-source + UPDATE_COMMIT_MESSAGE: "update(galaxy): retune the preferred collection source" + UPDATE_PR_TITLE: "update(galaxy): retune the preferred collection source" + UPDATE_PR_BODY: "Automated retune of `INFINITO_ANSIBLE_COLLECTIONS_SOURCE` in `default.env`. The preferred source's median install time reached at least 3x the other's over the last 12 daily samples; a failed install counts as worse than any duration." + UPDATE_BASE_BRANCH: main + run: bash scripts/github/update/open/pr.sh default.env diff --git a/.github/workflows/cron-release-highest.yml b/.github/workflows/cron-release-highest.yml index 75183086d3..8e17fadbae 100644 --- a/.github/workflows/cron-release-highest.yml +++ b/.github/workflows/cron-release-highest.yml @@ -79,4 +79,4 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.pick.outputs.tag }} - run: gh workflow run release-version.yml -f tag="${TAG}" + run: gh workflow run call-release-version.yml -f tag="${TAG}" diff --git a/.github/workflows/cron-update.yml b/.github/workflows/cron-update.yml index cf43307701..6e827796f2 100644 --- a/.github/workflows/cron-update.yml +++ b/.github/workflows/cron-update.yml @@ -112,6 +112,53 @@ jobs: UPDATE_BASE_BRANCH: main run: bash scripts/github/update/open/role_prs.sh roles + update-galaxy-pins: + name: 🌌 Update pinned Ansible collections + if: vars.CI_ENABLE_AUTO_UPDATES == 'true' + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout + uses: actions/checkout@v7.0.0 + with: + ref: main + + - name: 🔧 Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + + - name: 📦 Install update dependencies + run: bash scripts/github/update/install_python.sh + + - name: 🔄 Update pinned Ansible collections + run: bash scripts/github/update/galaxy_pins.sh + + - name: 🔎 Check for changes + id: diff + run: bash scripts/github/resolve/check_git_changes.sh requirements + + - name: ⚙️ Generate app token + if: steps.diff.outputs.changed == 'true' + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ secrets.BOT_APP_CLIENT_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + + - name: 📂 Open PR if collection pins changed + if: steps.diff.outputs.changed == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + UPDATE_BRANCH_PREFIX: update/galaxy-pins + UPDATE_COMMIT_MESSAGE: "update(galaxy): bump pinned Ansible collections" + UPDATE_PR_TITLE: "update(galaxy): bump pinned Ansible collections" + UPDATE_PR_BODY: "Automated update of the collection `version:` pins in `requirements/`. Both the Galaxy file and the git fallback are bumped together; upstream releases resolved via `git ls-remote --tags`." + UPDATE_BASE_BRANCH: main + run: bash scripts/github/update/open/pr.sh requirements + update-repository-refs: name: 🌿 Update repository refs if: vars.CI_ENABLE_AUTO_UPDATES == 'true' diff --git a/.github/workflows/entry-manual-steer.yml b/.github/workflows/entry-manual-steer.yml index 8fd1c6f438..edcad68b70 100644 --- a/.github/workflows/entry-manual-steer.yml +++ b/.github/workflows/entry-manual-steer.yml @@ -58,6 +58,11 @@ on: required: false default: "0" type: string + chunk_size: + description: "📦 Rows one chunk holds. Empty: derive it from slots and queue window" + required: false + default: "" + type: string chunk_gate: description: "🛑 Stop the chunk chain at its first failed chunk" required: false @@ -107,6 +112,7 @@ jobs: priority: ${{ inputs.priority }} sweep: ${{ inputs.sweep }} offset: ${{ inputs.offset }} + chunk_size: ${{ inputs.chunk_size != '' && inputs.chunk_size || '0' }} chunk_gate: ${{ inputs.chunk_gate }} filesystem: ${{ inputs.filesystem }} workspace: ${{ inputs.workspace }} diff --git a/AGENTS.md b/AGENTS.md index 5cd5e69a96..14b5e52c05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,21 +6,17 @@ - You MUST read every file under `docs/contributing/` (full directory walk, including subdirectories) for the full contributor guidance. - You MUST read every file under `docs/agents/` (full directory walk, including subdirectories) for the agent execution flow. - This file extends CONTRIBUTING.md with agent-specific instructions; on conflict between CONTRIBUTING.md and this file, this file wins. +- This file is runtime-agnostic and binding for every agent (Claude Code, Codex, Gemini CLI, other). There are no tool-specific extension files. -## Tool-Specific Extensions 🧩 +## Permission State Announcement at Session Start 📢 -At the start of every conversation, the agent MUST load its matching extension file in addition to AGENTS.md: - -- Claude Code → [CLAUDE.md](CLAUDE.md) -- Gemini CLI → [GEMINI.md](GEMINI.md) - -Agents MUST NOT read another tool's extension file. On conflict between AGENTS.md and the agent's own tool-specific extension file, the tool-specific file wins. +At the start of every new conversation, you MUST read [.claude/settings.json](.claude/settings.json) and output a one-time summary: sandbox status with concrete `allowWrite`/`denyRead` paths, plus the counts of `permissions.allow`/`ask`/`deny`. Derive content from the live file; do not hardcode. Do not repeat unless the operator asks, and do not propose `/sandbox` when it is already active. ## Reloading Instructions 🔄 -If agent instructions change mid-conversation (AGENTS.md, the tool-specific extension file, or any file referenced from them), the agent might not reload them automatically. Trigger a reload with: +If agent instructions change mid-conversation (AGENTS.md or any file referenced from it), the agent might not reload them automatically. Trigger a reload with: -> "Re-read AGENTS.md and the tool-specific extension file (CLAUDE.md or GEMINI.md) and apply all updated instructions." +> "Re-read AGENTS.md and apply all updated instructions." ## Permission Model 🔐 @@ -41,6 +37,50 @@ Agents whose runtime does not consult `.claude/settings.json` MUST still enforce Changes to the policy MUST edit [`.claude/settings.json`](.claude/settings.json). Per-entry rationale: [settings.md](docs/contributing/tools/agents/claude/settings.md); sandbox layer: [sandbox.md](docs/contributing/tools/agents/claude/sandbox.md). +## Interaction Rules 💬 + +- A question MUST NOT modify files, code, or state. Only explicit commands MAY. +- You MUST prefer commands permitted in [.claude/settings.json](.claude/settings.json) over commands that require interactive approval when an equivalent exists. + +## Code Execution ⚙️ + +- You MUST prefer `make` targets over raw `docker`/`docker compose`/`ansible-playbook`/`python`/shell invocations whenever an equivalent target exists in the [`Makefile`](Makefile). Inspect the `Makefile` first; fall back to the raw command only when no target covers the operation. The reason is operational consistency, not permissioning. Raw commands also auto-allow under the sandbox. +- **`docker exec` is FORBIDDEN. ⛔** You MUST NEVER invoke `docker exec` (nor nested `docker exec … docker exec …` into the DiD stack) directly. Always reach the live stack through `make` targets (e.g. `make compose-exec`, or the role-specific targets the `Makefile` exposes). If no `make` target covers what you need, ask the operator instead of falling back to raw `docker exec`. +- You SHOULD run sandbox-confined commands directly on the host. The sandbox bounds what they can read, write, and reach. See [sandbox.md](docs/contributing/tools/agents/claude/sandbox.md). +- For commands that legitimately cannot run inside the sandbox (e.g. operations needing access to `~/.ssh` or `~/.gnupg`), use `make compose-up` to start the stack and `make compose-exec` to drop into a container shell. The repository is mounted at `/opt/src/infinito` (see [compose.yml](compose.yml)), so code changes are immediately available there. +- Commands listed under `permissions.ask` in [.claude/settings.json](.claude/settings.json) still pause for explicit operator confirmation regardless of sandbox state. +- **Shell loops are FORBIDDEN. ⛔** You MUST NOT use `for`, `while`, `until`, or any other shell loop construct in any Bash tool call. Reason: shell control structures fall outside the sandbox auto-allow heuristic and trigger approval prompts even when every subcommand would individually auto-allow. +- **Multi-statement chains in shell invocations are FORBIDDEN. ⛔** You MUST NOT chain independent statements inside a single Bash tool call with **any** statement separator (`;`, literal newline, `&&`, `||`, `&` background operator, or subshell/brace groups around the same). The ban covers causally-dependent chains (`cd X && cmd`) just as much as optional ones, and applies to trailing `&` used to background a command (use the Bash tool's `run_in_background: true` parameter instead). Reason: identical to the loop rule. Split the work across **separate Bash tool calls** or use a single-command equivalent (`xargs`, `grep` with multiple args, a make target). +- **File creation via shell heredoc is FORBIDDEN. ⛔** You MUST NOT use `cat > file < file < file`, `echo "…" > file` for multi-line content) to create or overwrite files. Use the **Write tool**. For editing an existing file, use **Edit**, not `sed -i`/`awk -i`. Reason: Write/Edit land structured in the transcript and diff; heredoc + redirect shapes also fall out of the auto-allow heuristic and drop into ask. +- **For searching file contents, use the Grep tool.** If shelling out is unavoidable, use a single `grep` invocation with multiple file arguments (e.g. `grep -nE 'pattern' file1 file2 file3`) or a recursive call with a path/glob (e.g. `grep -rnE 'pattern' path/`). + +## Command output logging 📜 + +- For ANY non-trivial command (test runs, deploys, long pipelines), you MUST stream the FULL output to a file under `/tmp/` via `… 2>&1 | tee /tmp/.log` and grep / inspect that file repeatedly instead of re-running the command. Reason: re-running `make test` to "find the failure I just lost" costs 2 minutes per cycle; grepping the saved log costs milliseconds. +- Default the filename to a meaningful slug + monotonically increasing index (`/tmp/make-test--.log`, `/tmp/act--.log`) so you can compare runs. +- **When a long-running command streams its output to a `/tmp/.log` file** (e.g. background `make compose-deploy`, `make act-*`, or any `… 2>&1 | tee /tmp/.log`), you MUST tell the operator the concrete `tail -f /tmp/.log` command they can run in another terminal to follow the log live. Include the full path literally so it is copy-pasteable. +- That pipe **destroys the exit status**: `make test | tee` reports `tee`'s success even when make ends with `Error 2`. You MUST judge such a run by its `📊 per-target wall-clock` table and its `FAILED TARGETS:` line, never by the exit code. + +## CI evidence 📊 + +- A job's green conclusion is **NOT** evidence for a specific change. Before citing a run as proof, you MUST locate the line in that job's log where the change executed: a Playwright spec's `✓` (a leading `-` means the spec was skipped), the env var inside the container, or the command text of an `if:`-gated step. If the string is absent, report the run as silent on the change, not as supporting it. +- A green run on the fork does not speak for `infinito-nexus/core`. Tags do not follow a fork, and repository variables differ, so a step gated on either never runs there. Check the repository whose failure you are claiming to have fixed. + +## Pushing 🚢 + +- You MUST NOT push, directly or through wrappers that push implicitly. +- When commits are ready to ship, you MUST instruct the operator to run `git-sign-push` outside the sandbox. The CLI is provided by [git-maintainer-tools](https://github.com/kevinveenbirkenbach/git-maintainer-tools), declared as a dev dependency in [pyproject.toml](pyproject.toml); install it via `make install-python-dev`. + +## Comments 💬⛔ + +**THIS RULE OVERRIDES YOUR DEFAULT TRAINING. ⛔** A comment is FORBIDDEN unless it is one of exactly three things: + +1. **Exception** — names a concrete trip-wire (bug, pitfall, deliberate non-idiomatic choice). Must name the surprise; restating what the next line does is NOT an exception. +2. **Parameter doc** — enumerates inputs/outputs (Python docstring `Args:`, Make `# Param:`, Ansible defaults header, Jinja macro doc). +3. **Nocheck directive** — `# nocheck:`, `# noqa:`, `# shellcheck …`, `# type: ignore`, `# pragma: no cover`, etc. + +Anything else — restating code, section banners, "Note that …", step narration outside sequential test specs, untracked TODO/FIXME — **DELETE before the edit lands**. Applies to every language and file kind under version control. When in doubt, delete. + ## Shortcuts ⌨️ Operator messages MAY use the portable conversation shortcuts from the `shortcuts` skill (set up via `make install-skills`); agents MUST expand any matching shortcut before acting. The repository's own agent workflows live in the `i8-` skills catalogued in [cheatsheet.md](docs/contributing/tools/agents/cheatsheet.md). @@ -79,6 +119,10 @@ At the start of every conversation, the agent MUST check whether agent skills ar The agent MUST NOT repeat this notice within the same conversation. +## Documentation 📝 + +Runtime documentation: [Claude Code](https://code.claude.com/docs/en/overview) ([settings reference](https://code.claude.com/docs/en/settings)), [Gemini CLI](https://geminicli.com/docs/cli/gemini-md/). + ## For Humans 👥 Human contributors working alongside AI agents MUST read [here](docs/contributing/tools/agents/common.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ccaf1302e..8ef77c0d82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,305 @@ # Changelog +## [14.2.0] - 2026-09-22 + +**For Users** + +* **Secrets management with OpenBao.** The new *web-app-openbao* role deploys OpenBao at + *openbao.*. It stores application credentials, API keys and machine identities. + People sign in through Keycloak, or through LDAP when OpenLDAP is deployed. Their + *administrator*, *operator* or *reader* group decides what they can reach, and signing in + without one of those groups grants only the default policy. Services and automation use + their own AppRole identities, whose credentials change on every deploy, and no root token + is kept after the first deploy. A recovery key kept in the token store lets a later deploy + get back in if those credentials are lost. A static seal unseals the node on its own after + every restart. An internal PKI can be switched on, and Prometheus alerts when the node is + sealed or unreachable. The seal key lives only in the encrypted inventory, and a backup of + the store cannot be read without it, so back up the inventory together with the volume. + See [web-app-openbao](roles/web-app-openbao/README.md). + +* **LDAP import, export and Multi edit finish again in LAM.** The Content Security Policy + blocked the inline script that checks on LAM's background jobs, so these tools stayed + "in progress" forever. That script is allowed again. + +* **Swarm deploys no longer abort on a network that already exists.** Some networks are + created before their application's stack, as Prometheus does for every application it + scrapes. Those networks now carry the stack label, so the application's own deploy uses + the existing network instead of failing with "network with name already exists". + +**For Developers** + +* **Alert rules live in the role that owns the metric.** A role ships its Prometheus rules + in *templates/prometheus/alert_rules.yml.j2*. The new *alert_rule_roles* lookup includes + them only for roles that Prometheus actually scrapes, so a rules file needs no activation + check of its own. It must start at the *groups:* item level. OpenBao's seal alert is the + first rule to use it. + +* **Video for hand-built Playwright contexts.** A context created with + *browser.newContext()* ignored the project's video setting, so its artefact had a trace + but no video. Spread *recordVideoOptions(testInfo)* from *personas/utils/env.js* into the + context options to record one. + +* Image and dependency version jumps (net since 14.1.0): + * *web-app-pgadmin*: 9.17 to 9.18 + +**Contributors** + +* [Evangelos Tsakoudis](https://evangelostsak.com): the OpenBao role with its seal, + credential rotation, recovery and RBAC end-to-end suite, role-local Prometheus alert + rules, and the LAM and swarm network fixes that work uncovered +* [Alejandro Roman Ibanez](https://github.com/AlejandroRomanIbanez): video recording for + hand-built Playwright contexts +* [Kevin Veen-Birkenbach](https://veen.world): review and version maintenance + +## [14.1.0] - 2026-09-21 + +**For Users** + +* **Local models reach the gateway again.** Upstream re-pushed the *svc-ai-lmstudio* CPU + image with a server that listens on loopback only, so the role stayed green while litellm + could not connect and the AI features of Mattermost, Matrix, xWiki, Moodle and Zammad + failed their warm-up. The server binds the container network again and stays unpublished. + The model preload now waits until the daemon has finished starting, so a slow host no + longer ends up with a daemon that fails every later model load, and the readiness probe + is capped per attempt so a silent peer cannot hang it. See + [svc-ai-lmstudio](roles/svc-ai-lmstudio/README.md). + +**For Developers** + +* **CI deploys only the variants a change reaches.** An image bump in a provider no longer + redeploys the variants of its consumers that switch that provider off. The diff-derived + whitelist, used by the weekly update PRs, Dependabot and every other diff-driven run, + narrows each affected role to the variants whose round closure still reaches a changed + role, emitted as *role#0,2* selection tokens. A changed role and a role reached only + through *run_after* keep every variant. A pinned token naming a role that discovery does + not return now warns instead of aborting the run; role names are still checked against + *roles/* before the run starts. + +* **One instruction file for every agent.** *AGENTS.md* now holds every agent rule, and + *CLAUDE.md* and *GEMINI.md* are gone. The rules they carried bind every runtime anyway, + and the PR templates, scope tables, requirement documents and docs that named the old + files now name *AGENTS.md*. + +* Image and dependency version jumps (net since 14.0.0): + * *svc-ai-ollama*: 0.34.0 to 0.34.2 + * *svc-net-tor* (Debian base): 13.6-slim to 13.7-slim + * *web-app-baserow*: 2.3.3 to 2.3.4 + * *web-app-erpnext*: v16.34.2 to v16.35.0 + * *web-app-funkwhale* (front, api): 2.0.10 to 2.0.11 + * *web-app-jitsi* (web, prosody, jicofo, jvb): stable-9646 to stable-11031 + * *web-app-keycloak*: 26.7.3 to 26.7.4 + * *web-app-opentalk* (LiveKit): v1.13.6 to v1.13.7 + * *web-app-opentalk* (RabbitMQ): 4.3.5 to 4.3.6 + * *web-app-peertube*: v8.2.4 to v8.3.0 + * *web-app-pihole*: 2026.07.2 to 2026.09.0 + * *web-app-prometheus* (Alertmanager): v0.34.0 to v0.34.1 + * Ansible collection *hetzner.hcloud*: 7.0.1 to 7.1.0 + * *ruff* (dev): 0.16.6 to 0.16.7 + +**Contributors** + +* [Kevin Veen-Birkenbach](https://veen.world): LM Studio reachability and startup, + variant-narrowed CI selection, the single agent instruction file, and version maintenance + +## [14.0.0] - 2026-09-19 + +**For Users** + +* **AI assistance inside the applications you already run.** A gateway role, + *svc-ai-litellm*, fronts OpenAI, Anthropic and OpenRouter from declared keys, with + *svc-ai-ollama* and the new *svc-ai-lmstudio* serving local models from one shared + catalogue, so inference can stay inside the deployment. Sixteen applications use it for + their own built-in AI features instead of calling a vendor directly: WordPress AI Engine, + MediaWiki AIEditingAssistant, Moodle's AI subsystem, Zammad Smart Assist, the xWiki LLM + extension, Discourse AI, the Matrix ChatGPT bridge, Nextcloud, Mattermost Agents, n8n, + Open WebUI and Flowise. The gateway's admin UI signs in through Keycloak. See + [svc-ai-litellm](roles/svc-ai-litellm/README.md) and + [svc-ai-lmstudio](roles/svc-ai-lmstudio/README.md). + +* **Your tools reachable from your assistants.** About twenty applications now speak MCP. + Gitea, GitLab, Mattermost, Nextcloud, n8n, Baserow, Jenkins, Home Assistant, Moodle, + Snipe-IT and WordPress serve their own endpoints; ten more get one through a locked-down + sidecar. Open WebUI, Flowise and the agents discover what the deployment offers and + register it automatically. Access is bounded rather than assumed: blocked paths are + declared, every served path is gated, tools a provider calls mutating are withheld, + reader and writer roles are separate, and each provider authenticates callers as its own + service account. See [svc-ai-mcp-adapter](roles/svc-ai-mcp-adapter/README.md). + +* **Agents as employees, with a kernel between them and the host.** *web-app-hermes* and + *web-app-openclaw* deploy agents behind the single sign-on gate, wired to the gateway's + models and to the MCP servers discovery hands them, each proving on deploy that it can + actually reach them. *svc-virt-kata* runs that tier under a kernel-isolating container + runtime, and *svc-ai-robot* embodies an agent on a dedicated device. Every sandboxed + agent acts as its own platform account rather than borrowing yours. See + [web-app-hermes](roles/web-app-hermes/README.md), + [web-app-openclaw](roles/web-app-openclaw/README.md) and + [svc-virt-kata](roles/svc-virt-kata/README.md). + +* **Home automation joins the platform.** *web-app-homeassistant* deploys the automation + hub, exposes it as an MCP server so agents can drive it, persists the token it mints, and + is reachable over tor. See [web-app-homeassistant](roles/web-app-homeassistant/README.md). + +* **Changing a password changes it everywhere.** A user declares how its password must be + generated, the inventory pins one for every declared user, a new generator produces + API-key shapes, the deploy rotates credentials between its two passes, and a reset command + exists. Applications that persist an administrator password at install now realign it when + the declared one changes: Nextcloud, Moodle, EspoCRM, Jellyfin, Listmonk, Discourse, + Checkmk, xWiki, n8n, Matrix and the dedicated MariaDB and Postgres engines. Checkmk keeps + a 401 fatal so a wrong password cannot lock the account out. + +* **Onions, firewall and name resolution.** *svc-net-firewall* owns the packet filter as one + nftables table per role. Tor carries mail to a .onion in both deploy modes, rejects forged + sources on the guarded ports, and binds its dnsmasq listeners statically so a failed + re-enumeration can no longer drop every one of them. The deployment declares its own + address space once, the egress range moved out of the Docker pool, and the container + resolver follows the live bridge instead of a guessed address. Every host-bound port + requires an explicit onion decision. See + [svc-net-firewall](roles/svc-net-firewall/README.md). + +* **Application fixes you will notice.** Nextcloud installs an OnlyOffice release upstream + actually published, keeps social login working over tor, and makes its OIDC account an + administrator. Element shows invites again through the new room list. Moodle survives the + container roll during installation. Mattermost keeps its configuration in Postgres instead + of on a shared volume, and its plugin tree is per node. GitLab creates the default + organization a headless install never gets. Jellyfin waits for health before driving the + setup wizard. Keycloak registers redirect URIs only for the apps a play deploys, keeps the + allowed-origins claim out of the access token, and no longer caches LDAP federation past a + fresh grant. The platform's own health mail no longer eats the sender quota. + +* **Operating a deployment.** New make targets reach a single deployed application: + *compose-app-exec*, *compose-app-logs* and *compose-app-restart*. Diagnostics collect the + logs an application writes to a file, including from busybox containers. Health probes use + a fast start cadence with a window that outlasts a real boot, so a slow starter is no + longer reaped. The CSP health check accepts a status code a vhost declares instead of + dropping the vhost, and gives onion vhosts a navigation budget Tor can meet. + +**For Developers** + +* **MCP is declared, not wired.** A role states an *mcp* block in *meta/services.yml*, and + the vocabulary, discovery and rendering layer derives endpoints, clients, credentials and + network edges from it. A client-provider pairing is declared once, on the provider; + *sys-svc-mcp-reconcile* converges clients from the complete provider set; a refused pairing + is expressed as off rather than gated at the call site; and *svc-ai-mcp-adapter* is a + reusable per-provider sidecar rather than a standalone application. The contract is proven + on deploy, including refusal on *initialize* and the tool inventory itself. The design, + its delegation rules and a machine-checkable audit live under + [docs/contributing/design/role/services/mcp.md](docs/contributing/design/role/services/mcp.md). + +* **Registry and variant changes.** A variant can dictate the configuration of the providers + it pulls in, and an MCP round decides for itself what it carries, which is the breaking + change in this release. Fifteen moving pins gained names the registry can order, every MCP + provider is registered as a shared service, *meta/schema.yml* finished its rename to + *meta/secrets.yml*, role scripts are sorted into per-language folders, and *meta/addons* is + now an accepted declaration site for pins and plugins. + +* **Lints that fail closed.** Five properties that used to pass silently now fail. The PHP + and Ruby that roles ship are syntax-checked, and so are shell scripts that ship as Jinja + templates. *sys-service* owns every systemd unit a role installs. Healthcheck timings are + judged against what the base image was measured to hold, a variable-free healthcheck + belongs in *meta/services.yml*, an unenforceable NFS state layout fails at author time, a + sandboxed role cannot be pinned to the manager, credentials are kept out of volumes + declared non-secret, *no_log* is required on every task carrying one and forbidden in the + test roles, and a JavaScript default for an unset environment variable is rejected. + +* **Pins that cannot age unnoticed.** Image pins are classified and pull references built + from the class, a moving tag declares itself with the marker it already carries, and a + role's images move as one release or not at all. The updater reads addon declarations, + orders vendor patch counters and five-component versions, reads a release line written + before the number as a family, and now also watches pinned pip requirements and Ansible + collections. Seven roles moved off the Debian codename base onto slim, and one Ubuntu + mirror list with failover, a short timeout and a health test serves both the package cache + and the image builds. *js-yaml* was lifted to 4.3.2 for GHSA-2883-xcg3-v3hh. + +* **CI selects, resumes and proves.** A fourth deploy chunk stops a short priority chunk + stranding budget, a manual run can set the chunk size the formula would derive, and a + retrigger can override the chunk gate. Retriggering works by role and by what never got a + verdict, resumes behind the green window rather than the deployed one, retires suites the + source run already passed, and cancels the run before retriggering its failed roles. Each + role's README instructions are replayed on its smallest deploy row, in the mode its + *services.yml* declares. Distro images build concurrently on one runner, the matrix job + folds into the image job, and the deploy chunks no longer wait on the DNS test. + +* **Swarm, measured rather than assumed.** The deploy waits for its own update instead of + guessing after it, refuses to call a stack converged while it is still updating, survives a + task that failed and came back, reports what converged rather than only what did not, and + says why the convergence probe refused. Placement is constrained to a role's declared + platform arch, redis and memcached sidecars get a deploy block, and a run-once service no + longer strands the stack deploy waiting for a replica it never has. The rescue dump + captures the contents of the state volumes. In the test lab, workers are labelled + kata-capable only when the app is sandboxed, NFS detaches before the node containers die, + and the sandbox tier gets a second node. + +* **Faster gates.** Per-host setup runs once per host instead of once per application, a + role's volumes are answered from its own file instead of a whole-tree build, the Keycloak + RBAC root group resolves once rather than once per app, the Playwright fixture copy the + tree copy already made is dropped, logout helpers wait for a control instead of sleeping, + and the OIDC login helper stops waiting out probes that cannot match. Onion timeouts scale + by tor flavor instead of a flat multiplier, and every Playwright timeout has one source. + +* **Documentation and agent instructions.** Six new requirement documents cover the LLM + gateway and model backends, agent employees, Home Assistant, the robot role, the MCP proxy + expansion and native AI gateway integration. The MCP design docs are nested under their own + folder with an audit the lints can check. The role README template asks the reader for a + domain the deployment can resolve, enforces consistent Credits attribution, and drops the em + dash. Documentation generators are gathered under *cli/build/docs*, and a new CLI edits role + bonds in a matrix instead of hunting through files. + +* Image and dependency version jumps (net since 13.0.0): + * *svc-ai-ollama*: 0.32.15 to 0.34.0 + * *svc-db-elasticsearch*: 9.5.2 to 9.5.3 + * *svc-db-qdrant*: v1.19.0 to v1.19.1 + * *sys-ctl-hlth-csp* (csp-checker): 2.2.1 to 3.0.4 + * *sys-lint* (shfmt): v3.13.1 to v3.14.1 + * *test-e2e-playwright*: v1.62.1-noble to v1.63.0-noble + * *web-app-bluesky* (view): 1.131.1 to 1.132.0 + * *web-app-bookwyrm*: v0.9.2 to v0.9.3 + * *web-app-confluence*: 10.2.15 to 10.2.18 + * *web-app-dashboard*: 2.0.0 to 2.1.3 + * *web-app-erpnext*: v16.32.3 to v16.34.2 + * *web-app-espocrm*: 10.0.6 to 10.0.8 + * *web-app-funkwhale*: 2.0.9 to 2.0.10 + * *web-app-gitea*: 1.27.2 to 1.27.3 + * *web-app-gitlab* (webservice, sidekiq, workhorse, gitaly, shell, rails): v19.3.0 to v19.3.2 + * *web-app-jira*: 11.3.10 to 11.3.11 + * *web-app-keycloak*: 26.7.2 to 26.7.3 + * *web-app-matrix* (Synapse): v1.159.0 to v1.160.0 + * *web-app-matrix* (Element): v1.12.26 to v1.12.28 + * *web-app-mattermost*: 11.10.1 to 11.11.0 + * *web-app-n8n*: 1.95.3 to 1.100.1 + * *web-app-nextcloud* (proxy): 1.31.3-alpine to 1.31.5-alpine + * *web-app-opentalk* (LiveKit): v1.13.5 to v1.13.6 + * *web-app-opentalk* (RabbitMQ): 4.3.4 to 4.3.5 + * *web-app-penpot* (frontend, backend, exporter): 2.17.1 to 2.17.2 + * *web-app-seaweedfs*: 4.44 to 4.46 + * *web-app-semaphore*: v2.19.8 to v2.19.14 + * *web-app-socialhome*: 2026.6.16 to 2026.9.18 + * *web-app-yourls*: 1.10.4-apache to 1.10.6-apache + * *web-app-zammad*: 6.5.0 to 7.1.2 + * *web-svc-coturn*: 4.17.2 to 4.18.0 + +* Moving tags that now carry a release, one per role: *web-app-discourse* master to + v2026.8.0, *web-app-jenkins* lts to 2.568.3-lts, *web-app-openwebui* main to 0.11.0, + *web-app-pretix* stable to 2026.7.0, *web-app-xwiki* lts-postgres-tomcat to + 17.10.13-postgres-tomcat, *web-app-pixelfed* latest to 20260917, *web-app-matrix* (upstream + playbook ref) master to a commit pin, *svc-db-memcached* alpine to 1.6.45-alpine, + *svc-db-redis* alpine to 8.10.1-alpine, *svc-prx-openresty* alpine to 1.31.1.1-alpine, + *svc-runner* (BuildKit) buildx-stable-1 to v0.33.0, *web-app-bigbluebutton* (dockerize) + latest to v0.15.1, and the nginx sidecars of *web-app-fediwall*, *web-app-littlejs*, + *web-app-taiga* and *web-svc-coturn* to 1.31.6-alpine. The Debian codename bases became numeric slim tags in + *svc-db-openldap* (12.15-slim), *svc-net-tor* (13.6-slim), *web-app-bookwyrm*, + *web-app-bridgy-fed*, *web-app-postmarks*, *web-app-roulette-wheel*, *web-svc-cdn* and + *web-svc-simpleicons*, and *web-app-flowise* is built from node 24-slim instead of pulled + (Flowise itself stays at 3.1.4). + +**Contributors** + +* [Kevin Veen-Birkenbach](https://veen.world): LLM gateway and model backends, the declared + MCP layer and its sidecar, agent employees and the kernel-isolated tier, Home Assistant, + credential declaration and rotation, firewall/tor/DNS ownership, variant-dictated provider + configuration, the fail-closed lints, CI selection and resume, swarm convergence, and + version maintenance +* [Prageeth Panicker](https://github.com/pragepani): .ansible cache directory ignored + ## [13.0.0] - 2026-08-26 * **Tor as a first-class deployment axis.** The new *svc-net-tor* role routes onion diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index c468b65997..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,57 +0,0 @@ -# CLAUDE.md - -## Startup: MUST DO at the Start of Every Conversation 🚀 - -You MUST read `AGENTS.md` and follow all instructions in it at the start of every conversation before doing anything else. - -## Permission State Announcement at Session Start 📢 - -At the start of every new conversation (after reading `AGENTS.md`), you MUST read [.claude/settings.json](.claude/settings.json) and output a one-time summary: sandbox status with concrete `allowWrite`/`denyRead` paths, plus the counts of `permissions.allow`/`ask`/`deny`. Derive content from the live file; do not hardcode. Do not repeat unless the operator asks, and do not propose `/sandbox` when it is already active. - -## Interaction Rules 💬 - -- A question MUST NOT modify files, code, or state. Only explicit commands MAY. -- You MUST prefer commands permitted in [.claude/settings.json](.claude/settings.json) over commands that require interactive approval when an equivalent exists. - -## Code Execution ⚙️ - -- You MUST prefer `make` targets over raw `docker`/`docker compose`/`ansible-playbook`/`python`/shell invocations whenever an equivalent target exists in the [`Makefile`](Makefile). Inspect the `Makefile` first; fall back to the raw command only when no target covers the operation. The reason is operational consistency, not permissioning. Raw commands also auto-allow under the sandbox. -- **`docker exec` is FORBIDDEN. ⛔** You MUST NEVER invoke `docker exec` (nor nested `docker exec … docker exec …` into the DiD stack) directly. Always reach the live stack through `make` targets (e.g. `make compose-exec`, or the role-specific targets the `Makefile` exposes). If no `make` target covers what you need, ask the operator instead of falling back to raw `docker exec`. -- You SHOULD run sandbox-confined commands directly on the host. The sandbox bounds what they can read, write, and reach. See [sandbox.md](docs/contributing/tools/agents/claude/sandbox.md). -- For commands that legitimately cannot run inside the sandbox (e.g. operations needing access to `~/.ssh` or `~/.gnupg`), use `make compose-up` to start the stack and `make compose-exec` to drop into a container shell. The repository is mounted at `/opt/src/infinito` (see [compose.yml](compose.yml)), so code changes are immediately available there. -- Commands listed under `permissions.ask` in [.claude/settings.json](.claude/settings.json) still pause for explicit operator confirmation regardless of sandbox state. -- **Shell loops are FORBIDDEN. ⛔** You MUST NOT use `for`, `while`, `until`, or any other shell loop construct in any Bash tool call. Reason: shell control structures fall outside the sandbox auto-allow heuristic and trigger approval prompts even when every subcommand would individually auto-allow. -- **Multi-statement chains in shell invocations are FORBIDDEN. ⛔** You MUST NOT chain independent statements inside a single Bash tool call with **any** statement separator (`;`, literal newline, `&&`, `||`, `&` background operator, or subshell/brace groups around the same). The ban covers causally-dependent chains (`cd X && cmd`) just as much as optional ones, and applies to trailing `&` used to background a command (use the Bash tool's `run_in_background: true` parameter instead). Reason: identical to the loop rule. Split the work across **separate Bash tool calls** or use a single-command equivalent (`xargs`, `grep` with multiple args, a make target). -- **File creation via shell heredoc is FORBIDDEN. ⛔** You MUST NOT use `cat > file < file < file`, `echo "…" > file` for multi-line content) to create or overwrite files. Use the **Write tool**. For editing an existing file, use **Edit**, not `sed -i`/`awk -i`. Reason: Write/Edit land structured in the transcript and diff; heredoc + redirect shapes also fall out of the auto-allow heuristic and drop into ask. -- **For searching file contents, use the Grep tool.** If shelling out is unavoidable, use a single `grep` invocation with multiple file arguments (e.g. `grep -nE 'pattern' file1 file2 file3`) or a recursive call with a path/glob (e.g. `grep -rnE 'pattern' path/`). -- **When a long-running command streams its output to a `/tmp/.log` file** (e.g. background `make compose-deploy`, `make act-*`, or any `… 2>&1 | tee /tmp/.log`), you MUST tell the operator the concrete `tail -f /tmp/.log` command they can run in another terminal to follow the log live. Include the full path literally so it is copy-pasteable. - -## Comments 💬⛔ - -**THIS RULE OVERRIDES YOUR DEFAULT TRAINING. ⛔** A comment is FORBIDDEN unless it is one of exactly three things: - -1. **Exception** — names a concrete trip-wire (bug, pitfall, deliberate non-idiomatic choice). Must name the surprise; restating what the next line does is NOT an exception. -2. **Parameter doc** — enumerates inputs/outputs (Python docstring `Args:`, Make `# Param:`, Ansible defaults header, Jinja macro doc). -3. **Nocheck directive** — `# nocheck:`, `# noqa:`, `# shellcheck …`, `# type: ignore`, `# pragma: no cover`, etc. - -Anything else — restating code, section banners, "Note that …", step narration outside sequential test specs, untracked TODO/FIXME — **DELETE before the edit lands**. Applies to every language and file kind under version control. When in doubt, delete. - -## Command output logging 📜 - -- For ANY non-trivial command (test runs, deploys, long pipelines), you MUST stream the FULL output to a file under `/tmp/` via `… 2>&1 | tee /tmp/.log` and grep / inspect that file repeatedly instead of re-running the command. Reason: re-running `make test` to "find the failure I just lost" costs 2 minutes per cycle; grepping the saved log costs milliseconds. -- Default the filename to a meaningful slug + monotonically increasing index (`/tmp/make-test--.log`, `/tmp/act--.log`) so you can compare runs. -- Tell the operator the exact `tail -f /tmp/.log` command for background runs, per the existing rule. - -## Pushing 🚢 - -- You MUST NOT push, directly or through wrappers that push implicitly. -- When commits are ready to ship, you MUST instruct the operator to run `git-sign-push` outside the sandbox. The CLI is provided by [git-maintainer-tools](https://github.com/kevinveenbirkenbach/git-maintainer-tools), declared as a dev dependency in [pyproject.toml](pyproject.toml); install it via `make install-python-dev`. - -## Configuration 🛠️ - -- Project-level permissions and sandbox rules are defined in [.claude/settings.json](.claude/settings.json). -- See [code.claude.com](https://code.claude.com/docs/en/settings) for documentation on how to modify it. - -## Documentation 📝 - -See the [Claude Code documentation](https://code.claude.com/docs/en/overview) for further information. For human contributor guidance on working with agents, see [here](docs/contributing/tools/agents/common.md). diff --git a/Dockerfile b/Dockerfile index b1f8bfc0f8..25e3f98710 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,24 @@ RUN set -euo pipefail; \ echo 'accept-flake-config = true' >> /etc/nix/nix.conf; \ fi +COPY default.env ${INFINITO_SRC_DIR}/default.env +COPY scripts/install ${INFINITO_SRC_DIR}/scripts/install +COPY roles/dev-python/files/shell ${INFINITO_SRC_DIR}/roles/dev-python/files/shell +COPY requirements ${INFINITO_SRC_DIR}/requirements +COPY utils/__init__.py ${INFINITO_SRC_DIR}/utils/__init__.py +COPY utils/install/__init__.py ${INFINITO_SRC_DIR}/utils/install/__init__.py +COPY utils/install/collections.py ${INFINITO_SRC_DIR}/utils/install/collections.py + +# hadolint ignore=DL3008,DL3033,DL3041,SC1090,SC3040 +RUN set -euo pipefail; \ + source <(grep -hE '^INFINITO_PYTHON_INSTALL_SCRIPT=' "${INFINITO_SRC_DIR}/default.env"); \ + /bin/bash "${INFINITO_SRC_DIR}/${INFINITO_PYTHON_INSTALL_SCRIPT:?}" ensure; \ + VENV="${INFINITO_VENV_DIR}" bash "${INFINITO_SRC_DIR}/scripts/install/venv.sh"; \ + ${PIP} install --upgrade pip setuptools wheel; \ + ${PIP} install ansible PyYAML; \ + ANSIBLE_COLLECTIONS_DIR="${HOME}/.ansible/collections" \ + bash "${INFINITO_SRC_DIR}/scripts/install/ansible.sh" + COPY . ${INFINITO_SRC_DIR} # hadolint ignore=DL3008,DL3033,DL3041,SC1090 diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 0b42f87f5a..0000000000 --- a/GEMINI.md +++ /dev/null @@ -1,13 +0,0 @@ -# GEMINI.md - -## Purpose - -This file extends [AGENTS.md](AGENTS.md) with Gemini-specific additions. You MUST read `AGENTS.md` first as the entry point for all agent instructions. - -## Current Status - -- No Gemini-specific additions are currently defined. - -## Documentation - -See the [Gemini CLI documentation](https://geminicli.com/docs/cli/gemini-md/). For human contributor guidance on working with agents, see [here](docs/contributing/tools/agents/common.md). diff --git a/Makefile b/Makefile index 3845d55688..605131bfe7 100644 --- a/Makefile +++ b/Makefile @@ -448,7 +448,6 @@ lint: install-lint lint-packages \ lint-php \ lint-playwright \ - lint-php \ lint-python \ lint-ruby \ lint-shellcheck \ @@ -808,7 +807,7 @@ swarm-zombie: install-act ACT_WORKFLOW=.github/workflows/call-test-deploy.yml \ ACT_JOB=deploy \ ACT_MATRIX="apps:$(app);variant:$(or $(variant),0);mode:swarm" \ - ACT_INPUTS="whitelist=$(app) distros=$(SWARM_DISTROS) index=0 sweep=0 modes=swarm disable=$(disable)" \ + ACT_INPUTS="whitelist=$(app)#$(or $(variant),0)@swarm distros=$(SWARM_DISTROS) index=0 sweep=0 modes=swarm disable=$(disable)" \ bash scripts/tests/deploy/act/workflow.sh .PHONY: system-purge diff --git a/README.md b/README.md index bd41331f35..619a9d0c95 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Every invokable role in the tested lifecycle envelope, with its upstream homepag | [Fediwall](roles/web-app-fediwall/) | beta | Self-hosted Fediverse wall for showing recent public posts by hashtag or account in a live masonry grid | [🌐](https://github.com/defnull/fediwall) [🛠️](roles/web-app-fediwall/README.md#quick-setup) | [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Friendica](roles/web-app-friendica/), [Mastodon](roles/web-app-mastodon/), [Matomo](roles/web-app-matomo/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/) | | [Fider](roles/web-app-fider/) | beta | Open-source community feedback platform for collecting, voting on, and prioritizing feature requests and ideas | [🌐](https://www.fider.io/) [🛠️](roles/web-app-fider/README.md#quick-setup) | [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Hermes Agent](roles/web-app-hermes/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [Open WebUI](roles/web-app-openwebui/), [OpenClaw](roles/web-app-openclaw/), [PostgreSQL](roles/svc-db-postgres/), [Prometheus](roles/web-app-prometheus/), [SeaweedFS](roles/web-app-seaweedfs/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) | | [File Server](roles/web-svc-file/) | beta | Static file server over HTTPS with directory listing, .well-known handling, and automatic TLS certificates | [🛠️](roles/web-svc-file/README.md#quick-setup) | [Corporate Design](roles/web-svc-css/), [Matomo](roles/web-app-matomo/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/) | +| [Firewall ruleset](roles/svc-net-firewall/) | beta | nftables packet filter whose rules each role owns as its own table, replaced atomically and reloaded at boot | [🌐](https://www.netfilter.org/projects/nftables/) [🎬](https://www.youtube.com/watch?v=EGKhIljDPCw) [🛠️](roles/svc-net-firewall/README.md#quick-setup) | | | [Flowise](roles/web-app-flowise/) | beta | Low-code visual builder for creating, testing, and publishing LLM and RAG workflows as HTTP endpoints | [🌐](https://flowiseai.com/) [🎬](https://www.youtube.com/watch?v=h_0TJTY6Ulg) [🛠️](roles/web-app-flowise/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [LiteLLM Gateway](roles/svc-ai-litellm/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [Ollama](roles/svc-ai-ollama/), [PostgreSQL](roles/svc-db-postgres/), [Prometheus](roles/web-app-prometheus/), [Qdrant](roles/svc-db-qdrant/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) | | [Friendica](roles/web-app-friendica/) | beta | Decentralized, federated social-networking platform for peer-to-peer communication and community building | [🌐](https://friendi.ca/) [🛠️](roles/web-app-friendica/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [MariaDB](roles/svc-db-mariadb/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) | | [Funkwhale](roles/web-app-funkwhale/) | beta | Self-hosted, federated platform for sharing, hosting, and streaming your own music and audio library | [🌐](https://www.funkwhale.audio/) [🛠️](roles/web-app-funkwhale/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [PostgreSQL](roles/svc-db-postgres/), [Prometheus](roles/web-app-prometheus/), [Redis](roles/svc-db-redis/), [SeaweedFS](roles/web-app-seaweedfs/), [Tor Onion Service](roles/svc-net-tor/), [Typesense](roles/svc-db-typesense/), [Universal Logout](roles/web-svc-logout/) | @@ -176,7 +177,7 @@ Every invokable role in the tested lifecycle envelope, with its upstream homepag | [SuiteCRM](roles/web-app-suitecrm/) | beta | Open-source CRM platform for sales, service, cases, and campaign management with workflow automation | [🌐](https://suitecrm.com/) [🎬](https://www.youtube.com/watch?v=pOAs_C3Fp_E) [🛠️](roles/web-app-suitecrm/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [MariaDB](roles/svc-db-mariadb/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) | | [Swapfile](roles/svc-opt-swapfile/) | beta | On-target automation for creating and activating swap space via a cloned swapfile script | [🛠️](roles/svc-opt-swapfile/README.md#quick-setup) | | | [Taiga](roles/web-app-taiga/) | beta | Open-source agile project management platform for Scrum, Kanban, and hybrid team workflows | [🌐](https://taiga.io/) [🎬](https://www.youtube.com/watch?v=TYPX8nQEU5I) [🛠️](roles/web-app-taiga/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [PostgreSQL](roles/svc-db-postgres/), [Prometheus](roles/web-app-prometheus/), [SeaweedFS](roles/web-app-seaweedfs/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) | -| [Tor Onion Service](roles/svc-net-tor/) | alpha | Tor daemon publishing the node as a v3 onion service | [🛠️](roles/svc-net-tor/README.md#quick-setup) | [Asset Mirror (Privacy Proxy)](roles/web-svc-mirror/) | +| [Tor Onion Service](roles/svc-net-tor/) | beta | Tor daemon publishing the node as a v3 onion service | [🛠️](roles/svc-net-tor/README.md#quick-setup) | [Asset Mirror (Privacy Proxy)](roles/web-svc-mirror/) | | [Typesense](roles/svc-db-typesense/) | beta | Central Dockerized search engine shared by multiple apps with per-consumer scoped API keys | [🌐](https://typesense.org/) [🛠️](roles/svc-db-typesense/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Tor Onion Service](roles/svc-net-tor/) | | [Unbound](roles/svc-dns-unbound/) | beta | Central recursive/forwarding DNSSEC resolver on a static IP for consumer stacks to target as their DNS | [🌐](https://nlnetlabs.nl/projects/unbound/about/) [🛠️](roles/svc-dns-unbound/README.md#quick-setup) | | | [Universal Logout](roles/web-svc-logout/) | beta | Central logout proxy for single-action sign-out across all OIDC-connected apps, with Nginx snippets and a live status UI | [🛠️](roles/web-svc-logout/README.md#quick-setup) | [Corporate Design](roles/web-svc-css/), [Matomo](roles/web-app-matomo/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/) | diff --git a/cli/administration/deploy/development/init.py b/cli/administration/deploy/development/init.py index 6b69dd919b..de711ef911 100644 --- a/cli/administration/deploy/development/init.py +++ b/cli/administration/deploy/development/init.py @@ -175,9 +175,9 @@ def handler(args: argparse.Namespace) -> int: if len(plan) == 1: _, inv_dir, round_variants, include_R, _purge_set = plan[0] - non_zero = {a: i for a, i in round_variants.items() if i} - suffix = f" variants={non_zero}" if non_zero else "" shown = built_includes.get(inv_dir, include_R) + non_zero = {a: i for a, i in round_variants.items() if i and a in shown} + suffix = f" variants={non_zero}" if non_zero else "" print( f">>> Inventory initialized at {inv_dir} " f"(include={','.join(shown)} " @@ -189,8 +189,8 @@ def handler(args: argparse.Namespace) -> int: f"(primary_apps={','.join(primary_apps)}):" ) for round_index, inv_dir, round_variants, include_R, _purge_set in plan: - non_zero = {a: i for a, i in round_variants.items() if i} shown = built_includes.get(inv_dir, include_R) + non_zero = {a: i for a, i in round_variants.items() if i and a in shown} print( f" [round {round_index}] {inv_dir} " f"include={','.join(shown)} " diff --git a/cli/administration/deploy/development/inventory/legacy_resolver.py b/cli/administration/deploy/development/inventory/legacy_resolver.py index 5b70ba0698..0ea121f90c 100644 --- a/cli/administration/deploy/development/inventory/legacy_resolver.py +++ b/cli/administration/deploy/development/inventory/legacy_resolver.py @@ -6,96 +6,22 @@ to this path — the `CombinedResolver` walks `dependencies` and evaluated at deploy time against `group_names`. `run_after` is NOT followed for inclusion (it is a pure ordering hint), so a variant that disables a service does not get the provider re-added through the -ordering edge. This module owns the two helpers that path needs and -nothing else; the variant-only path lives in `.variants`. +ordering edge. This module owns the include resolution that path needs +and nothing else; the round's variant-merged services maps come from +`utils.roles.applications.variants`, which the CI selection resolves its +closure from as well. """ from __future__ import annotations -from collections.abc import Iterable, Mapping, Sequence -from pathlib import Path +from typing import TYPE_CHECKING -from utils.cache.applications import get_variants -from utils.cache.base import _deep_merge -from utils.cache.yaml import load_yaml_any -from utils.roles.mapping import ROLE_FILE_META_SERVICES +from utils.roles.applications.variants import services_overrides_for_round -from .nested_overrides import apply_topic, collect_provider_overrides +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence - -def _build_services_overrides_for_round( - *, - roles_dir: str, - round_index: int, - primary_app_variants: Mapping[str, int], -) -> dict[str, dict]: - """For every role with `meta/variants.yml`, return the services map - that results from merging the round's variant payload onto the - role's on-disk `meta/services.yml`. - - Apps in `primary_app_variants` use the supplied (already clamped) - index. Other roles with their own variants clamp `round_index` to - their own variant count. Roles without variants are absent from the - result so the resolver falls through to its disk-read path. - - The merged map is what the inventory ALSO bakes into host_vars, so - feeding the same dict into `CombinedResolver(services_overrides=...)` - eliminates the topology-vs-host_vars drift that variant-blind - resolution produced. - """ - variants_per_app = get_variants(roles_dir=roles_dir) - overrides: dict[str, dict] = {} - roles_path = Path(roles_dir) - for role_name, variant_list in variants_per_app.items(): - if not variant_list: - continue - variant_count = max(1, len(variant_list)) - if role_name in primary_app_variants: - idx = primary_app_variants[role_name] - else: - idx = round_index if round_index < variant_count else 0 - if not 0 <= idx < len(variant_list): - idx = 0 - variant_payload = variant_list[idx] if variant_list else {} - if not isinstance(variant_payload, Mapping): - variant_payload = {} - variant_services = variant_payload.get("services", {}) - if not isinstance(variant_services, Mapping): - continue - services_path = roles_path / role_name / ROLE_FILE_META_SERVICES - if not services_path.exists(): - continue - try: - base_services = load_yaml_any(services_path) or {} - except Exception: # noqa: S112 best-effort iteration over role files; skip malformed input - continue - if not isinstance(base_services, Mapping): - continue - merged = _deep_merge(dict(base_services), dict(variant_services)) - if isinstance(merged, dict): - overrides[role_name] = merged - _apply_nested_service_maps(overrides, roles_dir=roles_dir) - return overrides - - -def _apply_nested_service_maps(overrides: dict[str, dict], *, roles_dir: str) -> None: - """Replace a pulled-in provider's services map with what the round demands. - - Only the ``services`` topic reaches this path: the closure walks service - edges, so the other config topics a variant may override are the inventory - bake's business. - - Args: - overrides: ``{role: services map}``, mutated in place. - roles_dir: roles directory the service registry is built from. - """ - payloads = {role: {"services": services} for role, services in overrides.items()} - for provider, topics in collect_provider_overrides( - payloads, roles_dir=roles_dir - ).items(): - if "services" not in topics: - continue - overrides[provider] = apply_topic(overrides.get(provider), topics["services"]) +_build_services_overrides_for_round = services_overrides_for_round def _resolve_round_include( diff --git a/cli/administration/deploy/development/inventory/nested_overrides.py b/cli/administration/deploy/development/inventory/nested_overrides.py deleted file mode 100644 index b17a362b13..0000000000 --- a/cli/administration/deploy/development/inventory/nested_overrides.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Collect the provider config a round's variants dictate. - -The topic vocabulary and merge semantics live in -``utils.roles.applications.topics``; this module resolves which role demands -what, and refuses two roles demanding different things for one provider. - -Only a config topic counts, and only on an entry naming another role's -service. A role's own entity entry is skipped, which is what keeps the two -existing `services..users` / `services..domains` declarations from -being read as overrides. -""" - -from __future__ import annotations - -from collections.abc import Mapping -from pathlib import Path -from typing import Any - -from utils.roles.applications.services.registry import ( - build_service_registry_from_roles_dir, -) -from utils.roles.applications.topics import CONFIG_TOPICS, apply_topic, provider_of - -__all__ = ["CONFIG_TOPICS", "NestedOverrideConflictError", "apply_topic"] - - -class NestedOverrideConflictError(RuntimeError): - """Two roles in one round demand different config for one provider.""" - - -def _claims(payloads: Mapping[str, Any]) -> list[tuple[str, str, str, Any]]: - """Return the ``(role, service_key, topic, value)`` a round's payloads carry. - - Args: - payloads: ``{role: variant payload}`` for one round. - - Reading this before the service registry keeps a round whose variants - override nothing from paying for the registry at all. - """ - claims: list[tuple[str, str, str, Any]] = [] - for role_name, payload in payloads.items(): - if not isinstance(payload, Mapping): - continue - services = payload.get("services") - if not isinstance(services, Mapping): - continue - for service_key, entry in services.items(): - if not isinstance(entry, Mapping): - continue - claims.extend( - (role_name, service_key, topic, entry[topic]) - for topic in sorted(CONFIG_TOPICS & set(entry)) - ) - return claims - - -def collect_provider_overrides( - payloads: Mapping[str, Any], *, roles_dir: str -) -> dict[str, dict[str, Any]]: - """Return ``{provider: {topic: value}}`` demanded by the round's payloads. - - Args: - payloads: ``{role: variant payload}`` for one round. - roles_dir: roles directory the service registry is built from. - - Raises: - NestedOverrideConflictError: two roles demand different values for the - same topic of the same provider. - """ - claims = _claims(payloads) - if not claims: - return {} - - registry = build_service_registry_from_roles_dir(Path(roles_dir)) - demanded: dict[str, dict[str, Any]] = {} - claimed_by: dict[tuple[str, str], str] = {} - for role_name, service_key, topic, value in claims: - provider = provider_of(service_key, registry) - if not provider or provider == role_name: - continue - previous = claimed_by.get((provider, topic)) - if previous and demanded[provider][topic] != value: - raise NestedOverrideConflictError( - f"{role_name} and {previous} demand different " - f"{topic!r} for {provider!r} in the same round; a " - f"round deploys it once, so the two cannot both hold" - ) - demanded.setdefault(provider, {})[topic] = value - claimed_by[(provider, topic)] = role_name - return demanded diff --git a/cli/administration/deploy/development/inventory/payload.py b/cli/administration/deploy/development/inventory/payload.py index 6421739441..ce9b6a4561 100644 --- a/cli/administration/deploy/development/inventory/payload.py +++ b/cli/administration/deploy/development/inventory/payload.py @@ -14,8 +14,8 @@ from collections.abc import Mapping, Sequence from typing import Any from utils.cache.applications import get_variant_overrides_only - -from .nested_overrides import apply_topic, collect_provider_overrides +from utils.roles.applications.topics import apply_topic +from utils.roles.applications.variants import collect_provider_overrides def _resolve_variant_payloads( diff --git a/cli/administration/deploy/development/inventory/planner.py b/cli/administration/deploy/development/inventory/planner.py index 8dcfdff18d..99e64a74c4 100644 --- a/cli/administration/deploy/development/inventory/planner.py +++ b/cli/administration/deploy/development/inventory/planner.py @@ -103,11 +103,10 @@ def plan_dev_inventory_matrix( round_include = per_variant_includes[round_index] round_variants = dict(primary_round_variants) - for dep in round_include: + for dep, dep_variants in variants_per_app.items(): if dep in round_variants: continue - dep_variants = variants_per_app.get(dep) or [{}] - dep_count = max(1, len(dep_variants)) + dep_count = max(1, len(dep_variants or [{}])) round_variants[dep] = round_index if round_index < dep_count else 0 inv_dir = f"{base}-{round_index}" if total_rounds > 1 else base diff --git a/cli/administration/inventory/credentials/reset/cli.py b/cli/administration/inventory/credentials/reset/cli.py index 0d901aec54..9287c30b65 100644 --- a/cli/administration/inventory/credentials/reset/cli.py +++ b/cli/administration/inventory/credentials/reset/cli.py @@ -119,6 +119,18 @@ def main(argv: list[str] | None = None) -> int: default=[], help="Application ids and user keys to leave untouched (e.g. administrator).", ) + parser.add_argument( + "--include", + nargs="*", + default=None, + help=( + "Application ids whose credentials rotate. Absent rotates every " + "application block in the file, which on a matrix inventory is " + "every mirror artefact too, not just the ids the round " + "provisioned. User passwords ignore this and always rotate in " + "full." + ), + ) parser.add_argument( "--app-variants", default=None, @@ -161,16 +173,26 @@ def main(argv: list[str] | None = None) -> int: if not application_ids: raise SystemExit(f"No applications block to rotate in {host_vars_file}") + credential_ids = None + if args.include is not None: + wanted = set(args.include) + credential_ids = [app_id for app_id in application_ids if app_id in wanted] + if not credential_ids: + raise SystemExit(f"--include matches no block in {host_vars_file}") + if args.backup: print(f"[INFO] Pre-rotation copy: {_backup(host_vars_file)}") exclude = set(args.exclude) print( f"[INFO] Rotating credentials in {host_vars_file} " - f"({len(application_ids)} applications, excluding {sorted(exclude) or 'nothing'})" + f"({len(credential_ids or application_ids)} applications, " + f"{len(application_ids)} for users, " + f"excluding {sorted(exclude) or 'nothing'})" ) rotated = reset_credentials( application_ids=application_ids, + credential_ids=credential_ids, roles_dir=(project_root / "roles").resolve(), host_vars_file=host_vars_file, vault_password_file=vault_password_file, diff --git a/cli/administration/inventory/provision/reset.py b/cli/administration/inventory/provision/reset.py index 19f24de29c..54e5bfba82 100644 --- a/cli/administration/inventory/provision/reset.py +++ b/cli/administration/inventory/provision/reset.py @@ -117,13 +117,17 @@ def _drop_app_credentials( document: CommentedMap, exclude: set[str], pinned: dict[str, set[tuple[str, ...]]], + application_ids: list[str], ) -> int: - """Remove the generated credentials of every application in ``document``. + """Remove the generated credentials of the applications being rotated. Args: document: the host_vars document. exclude: application ids to leave untouched. pinned: per-application credential paths that must survive a rotation. + application_ids: the ids the caller regenerates afterwards. Passing a + narrower set here than the caller regenerates would drop + credentials nothing writes back. Returns: How many values were removed. @@ -132,10 +136,13 @@ def _drop_app_credentials( if not isinstance(applications, CommentedMap): return 0 + rotated = set(application_ids) dropped = 0 for application_id, application in applications.items(): if application_id in exclude or not isinstance(application, CommentedMap): continue + if str(application_id) not in rotated: + continue secrets = application.get(SECRETS_KEY) if not isinstance(secrets, CommentedMap): continue @@ -194,6 +201,7 @@ def reset_credentials( exclude: set[str], workers: int = 4, app_variants: dict[str, int] | None = None, + credential_ids: list[str] | None = None, ) -> int: """Replace every generated credential in ``host_vars_file`` with a fresh one. @@ -209,6 +217,9 @@ def reset_credentials( exclude: application ids and user keys to leave untouched. workers: worker threads for credentials generation. app_variants: variant index per app, as at provision time. + credential_ids: application ids whose credentials rotate; ``None`` + rotates all of ``application_ids``. User passwords ignore it and + always resolve through the full ``application_ids``. Returns: How many values were rotated. @@ -216,11 +227,12 @@ def reset_credentials( if not schema and not users: raise SystemExit("reset_credentials: pick at least one of schema, users") + rotated_ids = application_ids if credential_ids is None else credential_ids document = load_document(host_vars_file) dropped = 0 if schema: dropped += _drop_app_credentials( - document, exclude, _pinned_credentials(roles_dir) + document, exclude, _pinned_credentials(roles_dir), rotated_ids ) if users: dropped += _drop_user_passwords(document, roles_dir, application_ids, exclude) @@ -228,7 +240,7 @@ def reset_credentials( if schema: generate_credentials_for_roles( - application_ids=application_ids, + application_ids=rotated_ids, roles_dir=roles_dir, host_vars_file=host_vars_file, vault_password_file=vault_password_file, diff --git a/cli/administration/inventory/provision/services_disabler.py b/cli/administration/inventory/provision/services_disabler.py index 7f01e59260..33b4ac0cb1 100644 --- a/cli/administration/inventory/provision/services_disabler.py +++ b/cli/administration/inventory/provision/services_disabler.py @@ -86,6 +86,21 @@ def find_roles_with_service(service_name: str, roles_dir: Path) -> set[str]: return role_ids +def _declared_toggles(app_id: str, svc_name: str, roles_dir: Path) -> list[str]: + """The toggles this role's contract carries for this service, in file order. + + `compare_application_keys` rejects any host_vars key the role contract does + not declare, so writing a toggle the role never had turns a `disable=` run + into an inventory the validator refuses. 34 service blocks declare + `enabled` without `shared`. + """ + services = _load_yaml_mapping_tolerant(roles_dir / app_id / ROLE_FILE_META_SERVICES) + entry = services.get(svc_name) if services else None + if not isinstance(entry, dict): + return [] + return [flag for flag in ("enabled", "shared") if flag in entry] + + def find_provider_roles(services: list[str], roles_dir: Path) -> dict[str, str]: """ Return mapping of service_name -> application_id for the provider role of each @@ -204,17 +219,19 @@ def apply_services_disabled( svc_map = CommentedMap() app_data["services"] = svc_map + flags = _declared_toggles(app_id, svc_name, roles_dir) + if not flags: + continue + svc = svc_map.get(svc_name) if not isinstance(svc, CommentedMap): svc = CommentedMap() svc_map[svc_name] = svc - svc["enabled"] = False - svc["shared"] = False + for flag in flags: + svc[flag] = False changed = True - print( - f"[INFO] disable: {app_id}.services.{svc_name} " - "→ enabled=false, shared=false" - ) + written = ", ".join(f"{flag}=false" for flag in flags) + print(f"[INFO] disable: {app_id}.services.{svc_name} → {written}") if changed: with host_vars_file.open("w", encoding="utf-8") as f: diff --git a/cli/build/docs/readme/generate.py b/cli/build/docs/readme/generate.py index 40a76041ab..201362567f 100644 --- a/cli/build/docs/readme/generate.py +++ b/cli/build/docs/readme/generate.py @@ -70,6 +70,19 @@ def _app_name(preamble: str, role_name: str) -> str: return entity.replace("-", " ").title() +def _needs_clearnet_resolver(role_dir) -> bool: + """Whether the role refuses to deploy without ``networks.internet.dns``. + + Only the inventories under ``inventories/development/`` set that value, so a + role asserting it has to name it in the block an operator copies, or the + documented deploy aborts on the assert. + """ + return any( + "networks.internet.dns" in read_text(str(path)) + for path in sorted((role_dir / "tasks").rglob("*.yml")) + ) + + def _base_context(role_dir, role_name: str, app_name: str, *, invokable: bool) -> dict: return { "application_id": role_name, @@ -84,6 +97,7 @@ def _base_context(role_dir, role_name: str, app_name: str, *, invokable: bool) - "application_author": _role_author(role_dir), "application_author_url": author_urls().get(_role_author(role_dir)), "application_is_host": not role_has_stack(role_dir), + "application_needs_clearnet_resolver": _needs_clearnet_resolver(role_dir), "cosmos_mermaid": derive_cosmos_mermaid(role_dir, role_name), "cosmos_legend": _cosmos_legend(), } diff --git a/cli/contributing/update/collection_source/__init__.py b/cli/contributing/update/collection_source/__init__.py new file mode 100644 index 0000000000..ab2c0b6137 --- /dev/null +++ b/cli/contributing/update/collection_source/__init__.py @@ -0,0 +1,5 @@ +"""CLI entry point package for retuning the preferred collection source.""" + +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] diff --git a/cli/contributing/update/collection_source/__main__.py b/cli/contributing/update/collection_source/__main__.py new file mode 100755 index 0000000000..f0ffb7694e --- /dev/null +++ b/cli/contributing/update/collection_source/__main__.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import time +from pathlib import Path +from tempfile import TemporaryDirectory + +from utils.update.collection_source import ( + RATIO, + SOURCES, + WINDOW, + append_sample, + decide, + load_history, +) + +from . import PROJECT_ROOT + +SETTING = "INFINITO_ANSIBLE_COLLECTIONS_SOURCE" +REQUIREMENTS = { + "galaxy": Path("requirements/requirements.galaxy.yml"), + "git": Path("requirements/requirements.git.yml"), +} + + +def read_preferred(env_file: Path) -> str: + """The source currently tried first, as declared in default.env. + + Args: + env_file: the default.env holding the setting. + """ + match = re.search( + rf"^{SETTING}=(\S+)\s*$", + env_file.read_text( + encoding="utf-8" + ), # nocheck: cache-read -- write_preferred rewrites this file in the same run + re.MULTILINE, + ) + if match is None: + raise SystemExit(f"{SETTING} is not declared in {env_file}") + return match.group(1) + + +def write_preferred(env_file: Path, source: str) -> None: + """Point the setting at SOURCE. + + Args: + env_file: the default.env holding the setting. + source: the source to try first from now on. + """ + text = env_file.read_text( + encoding="utf-8" + ) # nocheck: cache-read -- rewritten on the next line + env_file.write_text( + re.sub(rf"^{SETTING}=\S+$", f"{SETTING}={source}", text, flags=re.MULTILINE), + encoding="utf-8", + ) + + +def measure(repo_root: Path, requirements: Path, timeout: int) -> float | None: + """Seconds a cold install from REQUIREMENTS takes, or None when it fails. + + Args: + repo_root: repository root the requirements path is relative to. + requirements: the requirements file to install from. + timeout: seconds after which the install counts as failed. + """ + with TemporaryDirectory() as target: + started = time.monotonic() + try: + completed = subprocess.run( + [ + sys.executable, + "-m", + "ansible.cli.galaxy", + "collection", + "install", + "-r", + str(repo_root / requirements), + "-p", + target, + "--force-with-deps", + ], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired: + return None + elapsed = time.monotonic() - started + return elapsed if completed.returncode == 0 else None + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Time both collection sources, record the sample, and retune the " + "preferred default when one source is durably slower." + ) + ) + parser.add_argument("--repo-root", type=Path, default=PROJECT_ROOT) + parser.add_argument( + "--history", + type=Path, + default=None, + help="JSON file holding the rolling window of samples", + ) + parser.add_argument( + "--timeout", + type=int, + default=900, + help="Seconds after which one install counts as failed", + ) + args = parser.parse_args() + + repo_root = args.repo_root.resolve() + env_file = repo_root / "default.env" + history_file = ( + args.history or repo_root / "build" / "collection-source-history.json" + ) + + sample: dict[str, float | None] = {} + for source in SOURCES: + seconds = measure(repo_root, REQUIREMENTS[source], args.timeout) + sample[source] = seconds + print(f"{source}: {'failed' if seconds is None else f'{seconds:.1f}s'}") + + history = append_sample(load_history(history_file), sample) + history_file.parent.mkdir(parents=True, exist_ok=True) + history_file.write_text(json.dumps(history, indent=2), encoding="utf-8") + + preferred = read_preferred(env_file) + verdict = decide(history, preferred) + print( + f"samples: {verdict.samples}/{WINDOW}, " + f"medians: {verdict.medians}, preferred: {verdict.preferred}" + ) + + if not verdict.changed: + print("Preferred collection source stays unchanged.") + return 0 + + write_preferred(env_file, verdict.proposed) + print( + f"Preferred collection source {verdict.preferred} -> {verdict.proposed}: " + f"its median is at least {RATIO}x the other's over {WINDOW} samples." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cli/contributing/update/galaxy/__init__.py b/cli/contributing/update/galaxy/__init__.py new file mode 100644 index 0000000000..f79db0b735 --- /dev/null +++ b/cli/contributing/update/galaxy/__init__.py @@ -0,0 +1,5 @@ +"""CLI entry point package for pinned Ansible collection updates.""" + +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] diff --git a/cli/contributing/update/galaxy/__main__.py b/cli/contributing/update/galaxy/__main__.py new file mode 100755 index 0000000000..c83fb083be --- /dev/null +++ b/cli/contributing/update/galaxy/__main__.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from pathlib import Path + +from utils.update.galaxy import apply_updates, find_outdated_updates + +from . import PROJECT_ROOT + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Update pinned Ansible collection versions in requirements/ to their " + "latest upstream release tag." + ) + ) + parser.add_argument( + "--repo-root", + type=Path, + default=PROJECT_ROOT, + help="Repository root to scan and update", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print planned updates without modifying files", + ) + args = parser.parse_args() + + repo_root = args.repo_root.resolve() + updates = find_outdated_updates(repo_root) + + if not updates: + print("Pinned Ansible collections are already up to date.") + return 0 + + print("Planned collection pin updates:") + for update in updates: + print(f"- {update.pin.fqcn} {update.pin.version} -> {update.latest}") + + if args.dry_run: + return 0 + + changed_paths = apply_updates(repo_root, updates) + if changed_paths: + print("\nUpdated files:") + for path in changed_paths: + print(f"- {path.relative_to(repo_root)}") + else: + print("\nNo files changed.") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cli/meta/ci/dev_runtime.py b/cli/meta/ci/dev_runtime.py index ff4f248163..ebb196d178 100644 --- a/cli/meta/ci/dev_runtime.py +++ b/cli/meta/ci/dev_runtime.py @@ -28,7 +28,7 @@ def image_label(image: str) -> str: """Shorten a dev-runtime image to its last two registry segments. ``quay.io/centos/centos:latest`` becomes ``centos:latest``; a two-segment - image such as ``debian:bookworm`` is returned unchanged. + image such as ``debian:13`` is returned unchanged. Args: image: full image reference. diff --git a/cli/meta/ci/slots.py b/cli/meta/ci/slots.py index 2a59db7fd0..bffd2df69e 100644 --- a/cli/meta/ci/slots.py +++ b/cli/meta/ci/slots.py @@ -142,7 +142,15 @@ def waves(repo_root: Path | None = None) -> int: def chunk_size(repo_root: Path | None = None) -> int: - """Rows one chunk may hold before its tail risks the queue cancel.""" + """Rows one chunk may hold before its tail risks the queue cancel. + + ``INFINITO_CI_CHUNK_SIZE`` takes precedence when set; 0 derives the value + from the runner slots a chunk may assume and the waves the queue window + affords. + """ + override = setting("INFINITO_CI_CHUNK_SIZE") + if override > 0: + return override return setting("INFINITO_CI_CONCURRENCY") * waves(repo_root) diff --git a/cli/meta/ci/validate.py b/cli/meta/ci/validate.py index 97708c884d..abc3943f76 100644 --- a/cli/meta/ci/validate.py +++ b/cli/meta/ci/validate.py @@ -15,16 +15,18 @@ here instead, and the exit code gates the run. What makes a token bad, in the order this checks it: * it does not parse, or names a mode or onion state that does not exist; -* it names a role the discovery query does not return -- a typo, a role - outside the run's lifecycle envelope, or a role that no longer exists; * it pins a variant the role does not declare (any more): the usual case is a list carried over from an older run whose variants have since been renumbered; * it pins a mode the row cannot take, an onion state the row, the mode or the run's own tor axis rules out, or a distro or filesystem the run's own pool does not hold. -A bare role name that matches nothing is reported too, but as a warning: the -diff-derived whitelist legitimately names roles the envelope filters out. +A token naming a role the discovery query does not return at all is reported +too, but as a warning, whether or not it pins axes: the diff-derived whitelist +legitimately names roles the envelope filters out, and it pins the variants a +change reaches. Role names themselves are checked against `roles/` before the +run starts (scripts/github/resolve/effective_whitelist.sh), so a typo is caught +there rather than swallowed here. """ from __future__ import annotations @@ -85,12 +87,13 @@ def problems( warnings: list[str] = [] for pin in pins: token = selection.describe(pin) + if pin.app not in discovered_apps: + warnings.append( + f"{label}: {token!r} matches no discovered row " + f"(no row in this run's mode and lifecycle envelope)" + ) + continue if not pin.pinned: - if pin.app not in discovered_apps: - warnings.append( - f"{label}: {token!r} matches no discovered row " - f"(no row in this run's mode and lifecycle envelope)" - ) continue variants = pin.variants or (None,) for variant in variants: diff --git a/cli/meta/roles/applications/resolution/affected/__main__.py b/cli/meta/roles/applications/resolution/affected/__main__.py index 7a600e0056..b834fd2eb4 100755 --- a/cli/meta/roles/applications/resolution/affected/__main__.py +++ b/cli/meta/roles/applications/resolution/affected/__main__.py @@ -10,8 +10,13 @@ Usage: python -m cli.meta.roles.applications.resolution.affected --changed-roles ROLE [ROLE ...] Output: - Whitespace-separated, sorted list of affected role names (single line), - including the seed roles themselves. + Whitespace-separated, sorted list of selection tokens (single line), + including the seed roles themselves. A role whose variants do not all + reach a seed is narrowed to the ones that do (``role#0,2``, + :mod:`utils.github.variant.selection`): a variant that switches its + provider off does not inherit the seed's change, so deploying it verifies + nothing. The narrowing is transitive over the whole chain, because each + round's closure is walked on that round's variant-merged services maps. Exit codes: 0 Success. The closure was computed and printed. @@ -38,9 +43,13 @@ from cli.meta.roles.applications.resolution.combined.role_introspection import ( has_application_id, load_run_after, ) +from utils.cache.applications import get_variants +from utils.roles.applications.variants import services_overrides_for_round +from utils.roles.display import VARIANT_SEPARATOR if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Sequence + from pathlib import Path EXIT_NON_MODELLABLE_SEED = 2 @@ -122,12 +131,85 @@ def affected_roles(changed: Iterable[str]) -> list[str]: return sorted(affected) +def _round_resolvers(roles_path: Path, rounds: int) -> list[CombinedResolver]: + """One resolver per deploy round, each walking that round's + variant-merged services maps. + + The maps are built by the same helper + (:func:`utils.roles.applications.variants.services_overrides_for_round`) + the matrix planner feeds its rounds from, so the topology this reads is + the topology round ``i`` deploys -- including every pulled-in role's own + variant, which is what carries the narrowing across generations. + ``run_after`` is not followed, for the reason the planner does not follow + it either: an ordering hint would re-add a provider the variant switched + off. + """ + return [ + CombinedResolver( + services_overrides=services_overrides_for_round( + roles_dir=str(roles_path), + round_index=index, + primary_app_variants={}, + ), + follow_run_after=False, + ) + for index in range(rounds) + ] + + +def _pinned( + role: str, seeds: set[str], resolvers: Sequence[CombinedResolver], count: int +) -> str: + """*role*, narrowed to the variants whose own closure reaches a seed. + + Returns the bare role id whenever the narrowing cannot be trusted: no + variant reaching a seed means the role is affected through an edge the + round closure does not model (a ``run_after`` hint, or a resolution the + variant merge refused), and verifying none of it is worse than verifying + all of it. + """ + try: + hits = [ + index + for index in range(count) + if seeds.intersection(resolvers[index].resolve(role)) + ] + except CombinedResolutionError: + return role + if not hits or len(hits) == count: + return role + return role + VARIANT_SEPARATOR + ",".join(str(index) for index in hits) + + +def affected_selection(changed: Iterable[str]) -> list[str]: + """:func:`affected_roles` as selection tokens, variant-narrowed. + + A seed role keeps every variant: its own files changed, so each of them + has to redeploy regardless of which providers it pulls. + """ + seeds: set[str] = {r.strip() for r in changed if r and r.strip()} + affected = affected_roles(changed) + variants = get_variants(roles_dir=str(roles_dir())) + counts = {role: len(variants.get(role) or ()) for role in affected} + rounds = max(counts.values(), default=0) + if rounds < 2: + return affected + resolvers = _round_resolvers(roles_dir(), rounds) + return [ + role + if role in seeds or counts[role] < 2 + else _pinned(role, seeds, resolvers, counts[role]) + for role in affected + ] + + def main() -> None: parser = argparse.ArgumentParser( description=( "Print all roles whose transitive prerequisite closure " "(run_after + dependencies + services) contains any of the " - "given seed roles. Seed roles themselves are included." + "given seed roles, each narrowed to the variants that reach one. " + "Seed roles themselves are included, with every variant." ) ) parser.add_argument( @@ -137,7 +219,7 @@ def main() -> None: help="Seed role names (folder names under ./roles).", ) args = parser.parse_args() - print(" ".join(affected_roles(args.changed_roles))) + print(" ".join(affected_selection(args.changed_roles))) if __name__ == "__main__": diff --git a/compose.yml b/compose.yml index f7d2be5738..635f1cea9a 100644 --- a/compose.yml +++ b/compose.yml @@ -71,6 +71,9 @@ services: - letsencrypt:/etc/letsencrypt/ environment: + INFINITO_API_ANTHROPIC_KEY: ${INFINITO_API_ANTHROPIC_KEY} + INFINITO_API_OPENAI_KEY: ${INFINITO_API_OPENAI_KEY} + INFINITO_API_OPENROUTER_KEY: ${INFINITO_API_OPENROUTER_KEY} INFINITO_BUILDKIT_ENABLED: ${INFINITO_BUILDKIT_ENABLED} INFINITO_CONTAINER_CA_ENABLED: ${INFINITO_CONTAINER_CA_ENABLED} INFINITO_DIR_VAR_LIB: ${INFINITO_DIR_VAR_LIB} diff --git a/compose/swarm/playbook.yml b/compose/swarm/playbook.yml index a4e11016c3..6be12b1fb1 100644 --- a/compose/swarm/playbook.yml +++ b/compose/swarm/playbook.yml @@ -134,7 +134,7 @@ timeout: 30 when: lab_is_mgr or inventory_hostname in [lab_nfs, lab_backup] - - name: own the whole dnsmasq config (fedora and centos ship local-service, which implies bind-interfaces and dnsmasq refuses next to our bind-dynamic) + - name: own the whole dnsmasq config (the packaging appends --local-service unconditionally and only an interface, except-interface, listen-address or auth-server directive resets it; without that reset a failed interface re-enumeration empties dnsmasq's local-subnet list and it rejects every query, loopback included, until an address event rebuilds it) template: # nocheck: stack-host-template - lab node system config, every DNS node writes its own /etc/dnsmasq.conf and IS_STACK_HOST does not exist in this play src: dnsmasq.conf.j2 dest: /etc/dnsmasq.conf diff --git a/compose/swarm/templates/dnsmasq.conf.j2 b/compose/swarm/templates/dnsmasq.conf.j2 index ef198a34b5..48dfe761fb 100644 --- a/compose/swarm/templates/dnsmasq.conf.j2 +++ b/compose/swarm/templates/dnsmasq.conf.j2 @@ -5,3 +5,4 @@ server={{ resolver }} {% endfor %} address=/{{ lab_domain }}/{{ lab_domain_target }} local=/{{ lab_domain }}/ +interface=* diff --git a/default.env b/default.env index 6f4233db7a..f079196ae4 100644 --- a/default.env +++ b/default.env @@ -16,6 +16,18 @@ # Git repository holding the operator's terminal aliases (an `aliases` file at repo root); developers override this with their own repo. INFINITO_ALIAS_REPOSITORY=https://github.com/kevinveenbirkenbach/alias +# Requirements file scripts/install/ansible.sh tries first, `galaxy` or `git`; the other one stays the fallback. +INFINITO_ANSIBLE_COLLECTIONS_SOURCE=galaxy + +# Anthropic API key the development inventory feeds into API.anthropic.api_key; empty leaves the anthropic routes unpublished. +INFINITO_API_ANTHROPIC_KEY="" + +# OpenAI API key the development inventory feeds into API.openai.api_key; empty leaves the openai routes unpublished. +INFINITO_API_OPENAI_KEY="" + +# OpenRouter API key the development inventory feeds into API.openrouter.api_key; empty leaves openrouter/auto unpublished. +INFINITO_API_OPENROUTER_KEY="" + # Where scripts/meta/resolve/apps.sh runs the meta-CLI: host | docker. INFINITO_APP_DISCOVERY_RUNNER=docker @@ -94,6 +106,9 @@ INFINITO_CACHE_STACK=true # Index of the deploy chunk the discovery emits; each chunk block of call-orchestrator.yml passes its own. INFINITO_CI_CHUNK=0 +# Rows one deploy chunk holds, overriding the concurrency-times-waves derivation; 0 derives it. +INFINITO_CI_CHUNK_SIZE=0 + # Runner slots a deploy chunk may assume are free; with the job timeout it sizes a chunk in cli.meta.ci.slots. INFINITO_CI_CONCURRENCY=20 diff --git a/docs/contributing/actions/testing/suppression.md b/docs/contributing/actions/testing/suppression.md index 8746107ca3..b0f3939497 100644 --- a/docs/contributing/actions/testing/suppression.md +++ b/docs/contributing/actions/testing/suppression.md @@ -44,9 +44,11 @@ The placement rule is per check. The catalog "Position" column uses these labels | Rule | Position | Affected test | Effect | | --------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `url` | same or above | [test_urls_reachable.py](../../../../tests/external/repository/test_urls_reachable.py) | Skips probing the literal HTTP(S) URL. Use for CDN roots and API base URLs that 4xx without a path. | +| `onion-flag` | same or above | [test_onion_port_flag.py](../../../../tests/lint/ansible/services/test_onion_port_flag.py) | Accepts a `false` under `ports.onion`. The marker MUST carry a reason naming why that port stays off the node onion; a bare marker is rejected. | | `docker-version` | same or above | [test_image_versions.py](../../../../tests/external/update/docker/test_image_versions.py), [test_semver_pinning.py](../../../../tests/lint/ansible/services/test_semver_pinning.py) | Skips both the live version-update warning and the non-semver-pin lint for that image's `version:` key. | | `repository-version` | same or above | [test_repository_versions.py](../../../../tests/external/update/repository/test_repository_versions.py), [test_semver_pinning.py](../../../../tests/lint/ansible/services/test_semver_pinning.py) | Skips both the live ref-update warning and the non-semver-pin lint for that entity's `ref:` key. | | `dockerfile-arg-default` | same or above | [test_arg_without_default.py](../../../../tests/lint/docker/dockerfile/test_arg_without_default.py) | Allows the marked `ARG` to carry a default. The caller normally supplies every build argument so a missing one fails the build instead of selecting a base image nobody chose. | +| `debian-codename` | same or above | [test_no_debian_codename_tags.py](../../../../tests/lint/repository/test_no_debian_codename_tags.py) | Allows an image reference whose tag names a Debian release by codename. Reserved for an upstream that publishes no numeric tag; the marker MUST name which upstream, because a codename pin can never be raised by the updater. | | `direct-yaml` | same line / span | [test_no_direct_calls.py](../../../../tests/lint/repository/yaml/test_no_direct_calls.py) | Allows the marked `yaml.safe_load` / `safe_dump` call to bypass `utils.cache.yaml`. MUST use `nocheck:`, not `noqa:`. | | `curl-timeout` | same or above | [test_curl_via_filter.py](../../../../tests/lint/repository/test_curl_via_filter.py) | Allows a bare curl invocation without the `curl` filter or inline `--max-time`. | | `redundant-bool-ternary` | same line | [test_no_redundant_bool_patterns.py](../../../../tests/lint/filesystem/python/test_no_redundant_bool_patterns.py) | Allows the redundant `\| bool \| ternary('true','false')` pattern when a consumer requires the lowercase tokens. | @@ -106,7 +108,13 @@ The placement rule is per check. The catalog "Position" column uses these labels | `image-healthcheck` | same or above the service key | [test_image_healthcheck_declared.py](../../../../tests/integration/roles/test_image_healthcheck_declared.py) | Allows a `roles/*/meta/services.yml` entry whose image ships no `HEALTHCHECK` to declare none either. Default path: add `healthcheck:` to the entry, because a container without a probe counts as healthy the moment its process starts — which is how a service that came back empty passes a restore drill. Reserved for containers nothing can probe: a one-shot process that exits, or an image with neither a port nor a shell. | | `csp-unsafe` | same line | [test_csp_unsafe_requires_justification.py](../../../../tests/lint/ansible/roles/meta/test_csp_unsafe_requires_justification.py) | Documents an `unsafe-*: true` flag in `roles/*/meta/csp.yml`. MUST be paired with a `Reason:` on the same line naming what breaks without the relaxation. `unsafe-inline` re-admits the inline execution an XSS needs and `unsafe-eval` re-admits string-to-code, so the flag has to be verified against the running app, not copied from another role. | | `min-storage-headroom` | same line OR comment block above key | [test_min_storage_headroom.py](../../../../tests/external/update/docker/test_min_storage_headroom.py) | Allows a `roles/*/meta/services.yml` `min_storage` to exceed the measured compressed size of its own `image:`/`version:` by more than 1 GB. MUST be paired with a `Reason:` on the same comment line naming what occupies the space. Reserved for services whose data volume genuinely dominates the footprint; the default path is to lower the value, because an over-declared budget makes a host look too small and prunes a cache the deploy did not need to lose. | +| `healthcheck-binary` | same or above | [test_healthcheck_binary_on_slim_base.py](../../../../tests/lint/ansible/roles/meta/test_healthcheck_binary_on_slim_base.py) | Exempts a `roles/*/meta/services.yml` healthcheck flavor from the requirement that the role's `files/Dockerfile` install the binary the probe runs, when the service sits on a `-slim` image. MUST name where the binary comes from instead. A `-slim` tag carries the runtime and nothing around it, so `curl` is absent and the probe fails from the first beat; under swarm the task is then reaped with exit 137 and every deployment holding the service in its closure fails on the convergence wait, naming the service rather than the missing binary. | | `galaxy-git-parity` | same or above | [test_galaxy_entries_have_git_source.py](../../../../tests/lint/repository/dependencies/test_galaxy_entries_have_git_source.py) | Exempts a `requirements/requirements.galaxy.yml` collection from the requirement to also carry a `type: git` entry in `requirements/requirements.git.yml`. Reserved for collections that publish no usable git remote; without the git entry a `galaxy.ansible.com` outage fails `make install`, because the fallback in `scripts/install/ansible.sh` cannot resolve the collection either. | +| `loop-var-shadow` | same or above | [test_loop_var_shadowing.py](../../../../tests/lint/ansible/tasks/test_loop_var_shadowing.py) | Allows a `loop_var` to take the name of a variable declared in `group_vars/**` or in a role's `vars`/`defaults`. Inside the loop that name carries the item, so every lookup, template and nested include reading it sees the item instead, and ansible only warns. Reserved for a loop whose body reads the name as the item on purpose, typically because the item IS that value: state which meaning the body relies on. | +| `firewall-fragment-accept` | same or above | [test_firewall_fragment_verdicts.py](../../../../tests/lint/ansible/jinja/test_firewall_fragment_verdicts.py) | Allows a rule in a `roles/*/templates/nftables.conf.j2` fragment to end in an `accept` verdict. Default path: express the intent as a `drop` of what must not pass, or put the rule in the chain carrying the policy it has to overrule, because an `accept` terminates only its own base chain and every other chain at that hook still runs, docker's `drop` policy on forwarded packets included. Reserved for an accept whose only job is to skip the fragment's own later rules. | +| `shell-template` | head (first 30 lines) | [test_no_shell_templates.py](../../../../tests/lint/filesystem/shell/test_no_shell_templates.py) | Allows a `*.sh.j2` to exist. Default path: ship the script as a plain `.sh` under `files/shell/` and pass its values as `ExecStart` arguments or `Environment=` entries, because shellcheck, the repository's `.sh` guards and every test skip a Jinja template, so nothing reads what actually runs on the host. Reserved for a script whose shape is generated, not just its values. Place the marker BELOW the shebang, never above it, and prefer the Jinja comment form so it does not reach the rendered script. | +| `systemd-via-sys-service` | same or above | [test_systemd_units_via_sys_service.py](../../../../tests/lint/ansible/modules/test_systemd_units_via_sys_service.py) | Allows a role task to render a `*.service.j2` itself instead of handing the unit to `sys-service`. Default path: name the template `templates/systemctl.service.j2` and include `sys-service` with `system_service_id`, so the unit also gets the daemon reload, the enable, and the timer, async and flush behaviour. Reserved for units `sys-service` cannot carry, such as a user-scope unit under `$HOME`. | +| `software-identity` | same or above | [test_software_identity_variables.py](../../../../tests/lint/ansible/jinja/test_software_identity_variables.py) | Allows a `.j2` template to write the software's name, domain or URL as a literal. Default path: render `SOFTWARE_NAME`, `SOFTWARE_DOMAIN`, `SOFTWARE_URL` or `ORGANIZATION`, which `group_vars/all/00_general.yml` declares once and derives from each other, so a rename reaches the template too. Reserved for a literal that must survive a rename, such as an identifier a third party issued under the old name. | ## Tool-Native `noqa` Codes 🧹 diff --git a/docs/contributing/artefact/git/branch.md b/docs/contributing/artefact/git/branch.md index b7933d5111..020cded368 100644 --- a/docs/contributing/artefact/git/branch.md +++ b/docs/contributing/artefact/git/branch.md @@ -6,7 +6,7 @@ The branch prefix MUST match the type of change. The [PR workflow](pull-request. | Branch prefix | Scope | Matching files | CI behavior | |---|---|---|---| -| `agent` | Changes to agent instructions or prompts | `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `docs/agents/*` | Skips [orchestrator](../../../../.github/workflows/call-orchestrator.yml), finishes green through the lightweight scope gate. | +| `agent` | Changes to agent instructions or prompts | `AGENTS.md`, `roles//AGENTS.md`, `docs/agents/*` | Skips [orchestrator](../../../../.github/workflows/call-orchestrator.yml), finishes green through the lightweight scope gate. | | `documentation` | Documentation-only changes | `**/*.md`, `**/*.rst` (outside agent paths) | Skips [orchestrator](../../../../.github/workflows/call-orchestrator.yml), finishes green through the lightweight scope gate. | | `feature` | New features or enhancements | `*` | Runs the full [orchestrator](../../../../.github/workflows/call-orchestrator.yml) pipeline. | | `fix` | Bug fixes | `*` | Runs the full [orchestrator](../../../../.github/workflows/call-orchestrator.yml) pipeline. | diff --git a/docs/contributing/artefact/git/pipeline.md b/docs/contributing/artefact/git/pipeline.md index e5d8a4f3d2..3cb37ddba5 100644 --- a/docs/contributing/artefact/git/pipeline.md +++ b/docs/contributing/artefact/git/pipeline.md @@ -80,7 +80,9 @@ The two deploy-test workflows listed in the `Infrastructure tests` table of [wor - **No diff at all** OR **any changed path outside `roles//...`**: no whitelist is set, so the full deploy across the workflow's scope runs. - **All changed paths under `roles//...`**: the changed roles become the seed set, and the whitelist is the transitive closure of those seeds expanded upwards over `run_after`, `dependencies`, and shared services. In other words, every role whose prerequisite set as defined by [combined resolver](../../../../cli/meta/roles/applications/resolution/combined/resolver.py) contains one of the seeds is included, together with the seeds themselves. -The reverse closure is fail-safe: any seed that is not modellable in the resolver (no `application_id` and not referenced by any role's `run_after`) MUST trigger a fall-back to a full deploy, as MUST any resolver runtime error. The closure MUST NOT silently shrink the deploy matrix on partial information. +Each role in that closure is then narrowed to the variants that actually reach a seed: variant `i` of a role is kept only when round `i`'s closure — walked on the same variant-merged services maps [the matrix planner](../../../../cli/administration/deploy/development/inventory/planner.py) builds its rounds from, and therefore variant-aware at every generation of the chain — contains one of the seeds. A role whose variants do not all qualify enters the whitelist as a `role#0,2` selection token, so an image bump in a provider no longer redeploys the variants that switch that provider off. This applies to every auto-derived whitelist — the weekly update PRs, Dependabot, and any other diff-driven run — because they all resolve through this one path. + +The reverse closure is fail-safe: any seed that is not modellable in the resolver (no `application_id` and not referenced by any role's `run_after`) MUST trigger a fall-back to a full deploy, as MUST any resolver runtime error. The closure MUST NOT silently shrink the deploy matrix on partial information. The variant narrowing follows the same rule: a role reached only through a `run_after` edge (which the round closure does not follow) keeps every variant, and so does a seed role itself, whose own files changed. The PR-scope short-circuits in [scope.sh](../../../../scripts/meta/resolve/pr/scope.sh) (documentation-only, agent-only) still apply at the entry layer. They skip the orchestrator entirely and are independent of the diff-driven whitelist resolution above. diff --git a/docs/contributing/artefact/git/pull-request.md b/docs/contributing/artefact/git/pull-request.md index 9b3d37233e..a848bcbf55 100644 --- a/docs/contributing/artefact/git/pull-request.md +++ b/docs/contributing/artefact/git/pull-request.md @@ -10,8 +10,8 @@ You MUST pick the template that matches your change. The table below links the a | Workstation and `desk-*` changes | [workstation.md](../../../../.github/PULL_REQUEST_TEMPLATE/workstation.md) | | Shared system changes such as `sys-*`, `svc-*`, `dev-*`, `drv-*`, `pkgmgr`, `update-*`, or `user-*` | [system.md](../../../../.github/PULL_REQUEST_TEMPLATE/system.md) | | CI/CD and workflow changes | [pipeline.md](../../../../.github/PULL_REQUEST_TEMPLATE/pipeline.md) | -| Documentation-only changes limited to `.md` or `.rst` files outside `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, and `docs/agents/*` | [documentation.md](../../../../.github/PULL_REQUEST_TEMPLATE/documentation.md) | -| Agent-specific changes limited to `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, or `docs/agents/*` | [agents.md](../../../../.github/PULL_REQUEST_TEMPLATE/agents.md) | +| Documentation-only changes limited to `.md` or `.rst` files outside `AGENTS.md` and `docs/agents/*` | [documentation.md](../../../../.github/PULL_REQUEST_TEMPLATE/documentation.md) | +| Agent-specific changes limited to `AGENTS.md` or `docs/agents/*` | [agents.md](../../../../.github/PULL_REQUEST_TEMPLATE/agents.md) | ## Branch Naming 🏷️ diff --git a/docs/contributing/design/role/services/layout.md b/docs/contributing/design/role/services/layout.md index 3ac1b7c16e..809303a385 100644 --- a/docs/contributing/design/role/services/layout.md +++ b/docs/contributing/design/role/services/layout.md @@ -87,6 +87,8 @@ The unified schema supports: - **Nested keys.** Both flat and nested credential keys are accepted, so e.g. `recaptcha.key` and `recaptcha.secret` remain nested. - **`algorithm:` defaults to `plain`** when the field is omitted. +- **`type:` (optional)** is one of `string`, `integer`, `boolean`. A supplied value of another type aborts inventory creation. +- **`regex:` (optional)** is a pattern the supplied value MUST match in full (`re.fullmatch`). Anchors are implicit; `sk-[A-Za-z0-9]{16,}` needs no `^` or `$`. - **`default:` (optional)** is a Jinja string used as the credential's value when the inventory does not provide one. - `default:` is **NOT rendered at inventory creation time.** The literal Jinja string is written verbatim into the inventory so that referenced variables (`CAPTCHA.RECAPTCHA.KEY`, `lookup(...)`, …) resolve only at deploy/runtime when those variables are actually defined. - `default:` values are **NOT validated.** `validation:` only applies to user-provided values, so the schema default is exempt. @@ -108,6 +110,58 @@ credentials: default: "{{ CAPTCHA.RECAPTCHA.SECRET | default('') }}" ``` +### What is checked, and when 🔎 + +`type:` and `regex:` are enforced by `validate_supplied_value()` in +[inventory.py](../../../../../utils/manager/inventory.py) at inventory-creation +time, on every path where a value arrives from outside: + +| Source | Checked | +|---|---| +| `--set applications..secrets.credentials.=` | yes | +| A value already present in the inventory | yes | +| The literal written from `default:` | no | +| A value produced from `algorithm:` | no | + +A generated value is exempt because the algorithm that produced it is named by +the same schema, and `generate_value()` takes no schema argument. + +An empty string means "not configured" and passes every check, which is what +makes an optional provider key expressible: declare the shape, leave the value +empty, and the consumer gates on emptiness. + +`validation.min_length` is declared in many role schemas but enforced nowhere. +Do not read it as a guarantee. + +### Worked Example: an optional external key + +```yaml +# roles/svc-ai-litellm/meta/secrets.yml +credentials: + openai_api_key: + description: "OpenAI API key; empty leaves the openai/* models unpublished" + type: "string" + regex: "\\S{20,}" + default: "{{ API.openai.api_key }}" +``` + +The `default:` points at the central `API` block in +[group_vars/all/18_api.yml](../../../../../group_vars/all/18_api.yml), so an +operator fills the provider in one place while the role keeps the shape the +value must have. + +### Write a pattern for the paste, not for the vendor 🚫 + +`regex:` MUST NOT pin a vendor's key prefix. OpenAI moved issuance from `sk-` to +`sk-proj-` without notice, and Anthropic issues `sk-ant-api03-`; a schema that +encoded either would reject a valid key and abort inventory creation, which is a +hard failure for a value that was correct. + +Write the pattern against the mistakes a paste makes — a trailing newline, an +embedded space, a truncated copy, a `` — and leave the provider's +format alone. A key pasted into the wrong provider's slot is not caught here; +it is caught at deploy time, where the route it publishes answers nothing. + Flat schema entries keep the same shape: ```yaml @@ -159,6 +213,8 @@ All port data lives under `.ports` in `meta/services.yml` (no `ports:` s relay: # for port-ranges (coturn, BBB, nextcloud TURN) start: end: + onion: + : # forward this category over the node onion ``` ### `internal` / `local` / `public` Split 🧭 @@ -168,6 +224,7 @@ All port data lives under `.ports` in `meta/services.yml` (no `ports:` s | `internal` | **Internal container port.** Lives inside the container's network namespace, addressed by other containers on the same role-local network. NOT a host-bound port. Multiple roles MAY legitimately declare the same value (e.g. several nginx-based apps with `internal: { http: 80 }`). | | `local` | **Localhost-bound host port.** Bound on `127.0.0.1` and only reachable through the front-proxy / SSH tunnels. The OS-level binding namespace is shared across all roles, so `local` values MUST be unique across the whole repo. | | `public` | **Public-facing host port.** Bound on `0.0.0.0` and exposed to the public internet (or to whatever the operator's firewall allows). Same uniqueness rule as `local`. | +| `onion` | **Onion opt-in, not a port.** A category-keyed map of booleans naming which of the entity's own categories get a `HiddenServicePort` on the node onion. It declares no numbers: the port is read from `local` when the category is declared there and from `public` otherwise, matching what `container_ports` publishes. Collision detection ignores it. | ### Always Category-Keyed Maps 🗂️ @@ -202,6 +259,34 @@ coturn: end: 39999 ``` +### `onion` Forwarding Opt-In 🧅 + +`ports.onion` names the categories that answer on the node onion. Only the named +categories are forwarded. + +The flag is mandatory, enforced by [test_onion_port_flag.py](../../../../../tests/lint/ansible/services/test_onion_port_flag.py): +every category declared under `local` or `public` needs a `true` or a `false`, +and every `false` needs `# nocheck: onion-flag` plus the reason it is false. +Four groups are settled centrally instead and take no flag: the categories a +hidden service cannot carry (`relay`, `media`, `stun_turn`, `stun_turn_tls`), +the proxy-fronted `http`, `sso` and `websocket`, the implicit-TLS variants +(`smtps`, `imaps`, `pop3s`, `ldaps`), and any entity that already answers the +question through `exposed:`. + +```yaml +mailu: + ports: + public: + smtp: 25 + smtps: 465 + imap: 143 + onion: + smtp: true # 25 gets a HiddenServicePort; 465 and 143 do not +``` + +The forward is per-variant: `meta/variants.yml` may drop the flag, and the role +then loses the `HiddenServicePort` in that variant. + ### Multi-Entity Roles 🎛️ Each entity carries its own `ports` block: diff --git a/docs/contributing/design/role/services/mcp/audit.md b/docs/contributing/design/role/services/mcp/audit.md index 8fd7c84b0f..16d629ee0f 100644 --- a/docs/contributing/design/role/services/mcp/audit.md +++ b/docs/contributing/design/role/services/mcp/audit.md @@ -13,16 +13,16 @@ not the version the MCP surface was verified against. | role | version | source | transport | auth | subject | scope | tools | implementation | consumers | blocker | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `svc-db-qdrant` | v1.19.1 | [link](https://s.infinito.nexus/code) | streamable_http | bearer_token | service_account (`mcp-svc-db-qdrant`) | read-only | 2 | adapter | `web-app-flowise`, `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | -| `web-app-baserow` | 2.3.3 | [link](https://baserow.io/user-docs/mcp-server) | streamable_http | bearer_token | service_account (`mcp-web-app-baserow`) | read-only | 4 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | +| `web-app-baserow` | 2.3.4 | [link](https://baserow.io/user-docs/mcp-server) | streamable_http | bearer_token | service_account (`mcp-web-app-baserow`) | read-only | 4 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | | `web-app-checkmk` | 2.4.0p32 | [link](https://s.infinito.nexus/code) | streamable_http | bearer_token | service_account (`mcp-web-app-checkmk`) | read-only | 3 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | | `web-app-fider` | stable | [link](https://s.infinito.nexus/code) | streamable_http | bearer_token | service_account (`mcp-web-app-fider`) | read-only | 3 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | -| `web-app-flowise` | 24-bookworm-slim | [link](https://github.com/FlowiseAI/Flowise/tree/flowise%403.1.4/packages/server/src/routes/custom-mcp-servers) | — | — | — | read-only | — | native | — | — | +| `web-app-flowise` | 24-slim | [link](https://github.com/FlowiseAI/Flowise/tree/flowise%403.1.4/packages/server/src/routes/custom-mcp-servers) | — | — | — | read-only | — | native | — | — | | `web-app-gitea` | 1.27.3 | [link](https://pkg.go.dev/gitea.com/gitea/gitea-mcp) | streamable_http | bearer_token | service_account (`mcp-web-app-gitea`) | read-only | 24 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | | `web-app-gitlab` | — | [link](https://docs.gitlab.com/user/gitlab_duo/model_context_protocol/mcp_server/) | streamable_http | bearer_token | service_account (`mcp-web-app-gitlab`) | read-only | 14 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | | `web-app-hermes` | v2026.9.7 | [link](https://hermes-agent.nousresearch.com/) | — | — | — | read-only | — | native | — | — | | `web-app-homeassistant` | 2026.7 | [link](https://www.home-assistant.io/integrations/mcp_server/) | streamable_http | bearer_token | service_account (`mcp-web-app-homeassistant`) | read-only | 10 | native | `web-app-flowise`, `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | | `web-app-jellyfin` | 10.11.11 | [link](https://s.infinito.nexus/code) | streamable_http | bearer_token | service_account (`mcp-web-app-jellyfin`) | read-only | 3 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | -| `web-app-jenkins` | lts | [link](https://plugins.jenkins.io/mcp-server/) | streamable_http | bearer_token | service_account (`mcp-web-app-jenkins`) | read-only | 15 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | +| `web-app-jenkins` | 2.568.3-lts | [link](https://plugins.jenkins.io/mcp-server/) | streamable_http | bearer_token | service_account (`mcp-web-app-jenkins`) | read-only | 15 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | | `web-app-listmonk` | v6.2.0 | [link](https://s.infinito.nexus/code) | streamable_http | bearer_token | service_account (`mcp-web-app-listmonk`) | read-only | 3 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | | `web-app-mattermost` | 11.11.0 | [link](https://github.com/mattermost/mattermost-plugin-agents/blob/master/mcpserver/README.md) | streamable_http | bearer_token | service_account (`mcp-web-app-mattermost`) | read-only | 11 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | | `web-app-moodle` | 8.3-fpm | [link](https://moodle.org/plugins/webservice_mcp) | streamable_http | bearer_token | service_account (`mcp-web-app-moodle`) | read-only | 5 | plugin | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | @@ -30,7 +30,7 @@ not the version the MCP surface was verified against. | `web-app-nextcloud` | 34-fpm-alpine | [link](https://docs.nextcloud.com/server/latest/admin_manual/ai/app_context_agent.html) | streamable_http | bearer_token | service_account (`mcp-web-app-nextcloud`) | read-only | 9 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | | `web-app-openclaw` | 2026.7.1 | [link](https://openclaw.ai/) | — | — | — | read-only | — | native | — | — | | `web-app-openwebui` | 0.11.0 | [link](https://docs.openwebui.com/features/extensibility/mcp/) | — | — | — | read-only | — | native | — | — | -| `web-app-pretix` | stable | [link](https://s.infinito.nexus/code) | streamable_http | bearer_token | service_account (`mcp-web-app-pretix`) | read-only | 3 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | +| `web-app-pretix` | 2026.7.0 | [link](https://s.infinito.nexus/code) | streamable_http | bearer_token | service_account (`mcp-web-app-pretix`) | read-only | 3 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | | `web-app-prometheus` | v3.14.0 | [link](https://s.infinito.nexus/code) | streamable_http | bearer_token | service_account (`mcp-web-app-prometheus`) | read-only | 3 | adapter | `web-app-flowise`, `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | | `web-app-snipe-it` | — | [link](https://s.infinito.nexus/code) | streamable_http | bearer_token | service_account (`mcp-web-app-snipe-it`) | read-only | 3 | adapter | `web-app-hermes`, `web-app-openclaw`, `web-app-openwebui` | — | | `web-app-wordpress` | 7.1.0 | [link](https://github.com/WordPress/mcp-adapter/tree/v0.5.0) | streamable_http | basic_auth | service_account (`mcp-web-app-wordpress`) | read-only | 3 | plugin | `web-app-hermes`, `web-app-openclaw` | — | diff --git a/docs/contributing/tools/agents/claude/security.md b/docs/contributing/tools/agents/claude/security.md index ddbe8cfa55..f099cdb17c 100644 --- a/docs/contributing/tools/agents/claude/security.md +++ b/docs/contributing/tools/agents/claude/security.md @@ -72,7 +72,7 @@ The `Bash(*)` allow lets the agent run any Makefile target without prompting. Th ## Assumption 8: Shell control-flow loops are never necessary 🔁 -[CLAUDE.md](../../../../../CLAUDE.md) forbids `for` / `while` / `until` loops in Bash invocations, and `Bash(for *)` / `Bash(while *)` / `Bash(until *)` are in `deny` to enforce the rule. Every task has a flat-form equivalent: a single `grep` with multiple file arguments, `xargs`, a recursive glob, or a dedicated tool. +[AGENTS.md](../../../../../AGENTS.md) forbids `for` / `while` / `until` loops in Bash invocations, and `Bash(for *)` / `Bash(while *)` / `Bash(until *)` are in `deny` to enforce the rule. Every task has a flat-form equivalent: a single `grep` with multiple file arguments, `xargs`, a recursive glob, or a dedicated tool. **Why this matters:** Loops can hide destructive operations (for example `for f in $(ls); do rm "$f"; done`) behind a single approval step. The deny rule converts a rule violation into a hard failure, so the agent receives concrete feedback and refactors to the flat form. diff --git a/docs/contributing/tools/agents/claude/settings.md b/docs/contributing/tools/agents/claude/settings.md index 9b3668d16b..3faedd79fb 100644 --- a/docs/contributing/tools/agents/claude/settings.md +++ b/docs/contributing/tools/agents/claude/settings.md @@ -97,7 +97,7 @@ These operations are unconditionally blocked, regardless of any `allow` entry or | Permission | Reason | |---|---| -| `for *` / `while *` / `until *` | Shell control-flow loops (`for f in ...; do ...; done`, `while read ...`, `until cond; do ...`). See Assumption 8 in [security.md](security.md) for why loops are banned at the policy layer rather than in the textual [CLAUDE.md](../../../../../CLAUDE.md) rule alone. The sandbox auto-allow heuristic does not recognise loop keywords, so this deny has no cost versus any alternative (allow would also require explicit entries; ask would deadlock). | +| `for *` / `while *` / `until *` | Shell control-flow loops (`for f in ...; do ...; done`, `while read ...`, `until cond; do ...`). See Assumption 8 in [security.md](security.md) for why loops are banned at the policy layer rather than in the textual [AGENTS.md](../../../../../AGENTS.md) rule alone. The sandbox auto-allow heuristic does not recognise loop keywords, so this deny has no cost versus any alternative (allow would also require explicit entries; ask would deadlock). | | `gh api * -X DELETE *` / `gh api * --method DELETE *` | DELETE is irreversible from the API surface: deleting a release, branch ref, deploy key, or webhook destroys data with no undo. Both `-X` and `--method` spellings are blocked so neither shorthand slips through. | | `gh api * -X PUT *` / `gh api * --method PUT *` | PUT replaces a resource wholesale. On endpoints like branch protection or rulesets, a PUT silently overwrites the entire policy with whatever payload is sent; partial JSON drops every unspecified field. PATCH (the merge variant) still goes through `ask`. | | `gh api * /actions/secrets*:*` | Touches GitHub Actions secrets: reading metadata, creating, updating, or deleting. Even GETs leak secret names; mutations rotate credentials CI depends on. Out-of-band secret management only. | diff --git a/docs/contributing/tools/agents/common.md b/docs/contributing/tools/agents/common.md index 9144b9517e..9804b77522 100644 --- a/docs/contributing/tools/agents/common.md +++ b/docs/contributing/tools/agents/common.md @@ -2,14 +2,13 @@ AI assistants are welcome, but they MUST follow the same workflow as humans. -- You MUST treat [AGENTS.md](../../../../AGENTS.md) as the entry point for all agent-specific execution instructions. Tool-specific files such as `CLAUDE.md` and `GEMINI.md` extend `AGENTS.md` and MUST NOT contradict it. +- You MUST treat [AGENTS.md](../../../../AGENTS.md) as the single, runtime-agnostic source of agent-specific execution instructions. There are no tool-specific extension files. - You MUST NOT invent commands, workflows, or files that do not exist. - You MUST NEVER expose secrets in prompts, code, screenshots, or logs. - You MUST treat AI output as a draft that must be reviewed for wrong assumptions, duplicate logic, and security mistakes. - You MUST keep explanations simple and explicit. - You MUST state assumptions clearly. -For tool-specific configuration and additional instructions, see [CLAUDE.md](../../../../CLAUDE.md) and [GEMINI.md](../../../../GEMINI.md). For a full reference of permitted Claude Code operations and their security rationale, see [claude/](claude/README.md). For further support, visit the AI forum at [s.infinito.nexus/aihub](https://s.infinito.nexus/aihub) or join the Matrix group #ai:infinito.nexus. diff --git a/docs/contributing/tools/github/actions/configuration.md b/docs/contributing/tools/github/actions/configuration.md index e6d8cd29e8..f069964ae8 100644 --- a/docs/contributing/tools/github/actions/configuration.md +++ b/docs/contributing/tools/github/actions/configuration.md @@ -11,7 +11,32 @@ Repository variables are set under **Settings → Secrets and variables → Acti | `CI_SYNC_MAIN_SOURCE_REPOSITORY` | [entry-push-latest.yml](../../../../../.github/workflows/entry-push-latest.yml) | Syncs `main` from `infinito-nexus/core` before CI scope discovery | `/` to use another source, or `false`, empty, or the current repository to skip | | `CI_RUN_ON_MAIN` | [entry-push-latest.yml](../../../../../.github/workflows/entry-push-latest.yml) | Pushes to `main` skip CI | `true` to run CI on `main` pushes too | | `CI_ENABLE_AUTO_UPDATES` | [cron-update.yml](../../../../../.github/workflows/cron-update.yml), [entry-pr-open-dependabot-close.yml](../../../../../.github/workflows/entry-pr-open-dependabot-close.yml) | Update jobs skipped; Dependabot PRs auto-closed | `true` to allow update PRs (workflow-driven and Dependabot) | -| `INFINITO_PLAYWRIGHT_KEEP` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | Playwright keeps trace, screenshot and video only when a test fails | `true` to keep them for every test (passing runs included) | +| `PLAYWRIGHT_KEEP_ALL` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | Playwright keeps trace, screenshot and video only when a test fails | `true` to keep them for every test (passing runs included); the workflow forwards it as `INFINITO_PLAYWRIGHT_KEEP` | +| `API_OPENAI_KEY` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | The gateway publishes no `openai/*` model, and the CLI test asserts their absence | An OpenAI key, to deploy and test the route | +| `API_ANTHROPIC_KEY` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | The gateway publishes no `anthropic/*` model | An Anthropic key | +| `API_OPENROUTER_KEY` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | The gateway publishes no `openrouter/auto` model | An OpenRouter key | + +## Provider keys: variable or secret? 🔑 + +The three `API_*_KEY` entries are read as `${{ secrets.X || vars.X }}`, so a +secret of the same name wins and the variable is the fallback. Prefer the +secret: a repository **variable is not masked**, and the development inventory +sets `MASK_CREDENTIALS_IN_LOGS: false`, so a key supplied as a variable can be +printed by a task that echoes its own arguments. + +An unset key is not an error. The provider's route stays unpublished, the +gateway serves whatever local backend the round deploys, and +[the litellm CLI test](../../../../../roles/svc-ai-litellm/files/test/probe.py) +asserts the absence rather than skipping. + +They travel as `INFINITO_API_OPENAI_KEY`, `INFINITO_API_ANTHROPIC_KEY` and +`INFINITO_API_OPENROUTER_KEY` through the workflow `env:` block, the passthrough +allowlist in [passthrough.py](../../../../../utils/env/handlers/passthrough.py), +the `infinito` service environment in +[compose.yml](../../../../../compose.yml), and land in `API..api_key` +in the development inventory, whose path is the SPOT behind +`INFINITO_INVENTORY_VARS_FILE`. Skipping the compose forwarding makes them +resolve to empty inside the container without any error. ## Cancelling in-progress runs 🛑 diff --git a/docs/contributing/tools/github/actions/secrets.md b/docs/contributing/tools/github/actions/secrets.md index 6679053894..54cefb8974 100644 --- a/docs/contributing/tools/github/actions/secrets.md +++ b/docs/contributing/tools/github/actions/secrets.md @@ -10,6 +10,31 @@ Repository secrets MUST be set under **Settings → Secrets and variables → Ac |---|---|---|---| | `BOT_APP_CLIENT_ID` | [cron-update.yml](../../../../../.github/workflows/cron-update.yml) | GitHub App Client ID (OAuth-style `Iv…` identifier shown on the App's General page) used to mint a short-lived installation token for the update PR. | `CI_ENABLE_AUTO_UPDATES == 'true'` | | `BOT_APP_PRIVATE_KEY` | [cron-update.yml](../../../../../.github/workflows/cron-update.yml) | PEM-encoded private key of the same GitHub App. Used to sign the JWT that exchanges for the installation token. | `CI_ENABLE_AUTO_UPDATES == 'true'` | +| `API_OPENAI_KEY` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | Publishes the `openai/*` models on the LiteLLM gateway and lets the CLI test exercise the route. | Never. Unset leaves the route unpublished. | +| `API_ANTHROPIC_KEY` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | Publishes the `anthropic/*` models. | Never. | +| `API_OPENROUTER_KEY` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | Publishes `openrouter/auto`. | Never. | +| `DOCKERHUB_USERNAME` | [cron-images-mirror-all.yml](../../../../../.github/workflows/cron-images-mirror-all.yml), [call-images-mirror-missing.yml](../../../../../.github/workflows/call-images-mirror-missing.yml) | Authenticates the mirror job against Docker Hub, which raises the anonymous pull-rate limit the mirror would otherwise hit. | Never. Both workflows declare the secret `required: false` and gate the login step on both values being non-empty. | +| `DOCKERHUB_TOKEN` | [cron-images-mirror-all.yml](../../../../../.github/workflows/cron-images-mirror-all.yml), [call-images-mirror-missing.yml](../../../../../.github/workflows/call-images-mirror-missing.yml) | The access token paired with `DOCKERHUB_USERNAME`. | Never, and only together with the username. | + +## `API_OPENAI_KEY`, `API_ANTHROPIC_KEY`, `API_OPENROUTER_KEY` 🤖 + +These are optional and fail soft: unset means the provider's route is never +published, and the deployment falls back to whatever local backend the round +deploys. The workflow reads each as `${{ secrets.X || vars.X }}`, so the same +name works as a repository variable; see +[configuration.md](configuration.md#provider-keys-variable-or-secret-) for why +the secret is the better half of that pair. + +Set them the same way as the App credentials: **Settings → Secrets and variables +→ Actions → Secrets → New repository secret**, one per provider, the value being +the provider's key verbatim. + +The shape each key must have is declared per role rather than here, in +[roles/svc-ai-litellm/meta/secrets.yml](../../../../../roles/svc-ai-litellm/meta/secrets.yml) +with a `type:` and a `regex:`. The pattern rejects a truncated or +whitespace-carrying paste and deliberately accepts any vendor prefix, so a +provider changing its issuance format cannot turn a valid key into a failed +inventory run. ## `BOT_APP_CLIENT_ID` and `BOT_APP_PRIVATE_KEY` 🤖 diff --git a/docs/requirements/014-svc-runner-ci.md b/docs/requirements/014-svc-runner-ci.md index 8791fa1106..8ad7faeec0 100644 --- a/docs/requirements/014-svc-runner-ci.md +++ b/docs/requirements/014-svc-runner-ci.md @@ -44,4 +44,4 @@ The implementation of this requirement MUST be executed autonomously by the agen - [x] **Iteration loop.** The agent MUST follow the [Workflow Loop](../agents/action/iteration/workflow.md) for every change to GitHub Actions workflows the implementation touches, and the [Compose Loop](../agents/action/iteration/compose.md) for every change inside `roles/svc-runner/`. The agent MUST NOT skip the loop's debug-locally step in favour of remote CI reruns. - [x] **No `ask` prompts mid-run.** The agent MUST NOT trigger any tool call that routes through `permissions.ask` in [.claude/settings.json](../../.claude/settings.json) during implementation. Where a tool would otherwise route through `ask`, the agent MUST select an equivalent already covered by `permissions.allow`, or rephrase the operation to fit the sandbox. The single permitted exception is the final commit at the end of the run. - [x] **No interruptions.** Bug fixes, deploy failures, lint failures, `make test` failures, healthcheck flaps, and similar issues MUST be resolved at their root inside this same iteration without prompting the operator. Workarounds, ad-hoc skips, retry-until-green loops, or "track in a follow-up" deferrals MUST NOT be used. -- [x] **One commit at the end.** The agent MUST NOT create any intermediate commit. ALL changes (role, CLI, tests, documentation, and the ticked checkboxes in this document) MUST be combined into ONE commit, created only after every Acceptance Criterion above is checked off (`- [x]`) and `make test` is green. Per-step commits, checkpoint commits, and partial commits MUST NOT be created. The agent MUST NOT push; the operator runs `git-sign-push` outside the sandbox per [CLAUDE.md](../../CLAUDE.md). +- [x] **One commit at the end.** The agent MUST NOT create any intermediate commit. ALL changes (role, CLI, tests, documentation, and the ticked checkboxes in this document) MUST be combined into ONE commit, created only after every Acceptance Criterion above is checked off (`- [x]`) and `make test` is green. Per-step commits, checkpoint commits, and partial commits MUST NOT be created. The agent MUST NOT push; the operator runs `git-sign-push` outside the sandbox per [AGENTS.md](../../AGENTS.md). diff --git a/docs/requirements/023-docker-swarm-nfs.md b/docs/requirements/023-docker-swarm-nfs.md index 8403680527..3cb0989d1e 100644 --- a/docs/requirements/023-docker-swarm-nfs.md +++ b/docs/requirements/023-docker-swarm-nfs.md @@ -202,7 +202,7 @@ The implementation of this requirement MUST be executed autonomously by the agen - [ ] **Iteration loop.** The agent MUST follow the [Compose Loop](../agents/action/iteration/compose.md) for every change inside `roles/svc-swarm-node/`, `roles/svc-prx-openresty/` (swarm-mode adaptations), `roles/svc-storage-nfs-server/`, `roles/svc-storage-nfs-client/`, and `roles/web-app-mediawiki/` (pilot). The agent MUST follow the [Workflow Loop](../agents/action/iteration/workflow.md) for every change to `.github/workflows/test-deploy-swarm-nfs.yml`. The agent MUST NOT skip the loop's debug-locally step in favour of remote CI reruns. - [ ] **No `ask` prompts mid-run.** The agent MUST NOT trigger any tool call that routes through `permissions.ask` in [.claude/settings.json](../../.claude/settings.json) during implementation. Where a tool would otherwise route through `ask`, the agent MUST select an equivalent already covered by `permissions.allow`, or rephrase the operation to fit the sandbox. The single permitted exception is the final commit at the end of the run. - [ ] **No interruptions.** Bug fixes, deploy failures, lint failures, `make test` failures, healthcheck flaps, swarm-init flakes, NFS mount races, workflow reruns until green — every issue MUST be resolved at its root inside this same iteration without prompting the operator. Workarounds, ad-hoc skips, retry-until-green loops, or "track in a follow-up" deferrals MUST NOT be used. -- [ ] **One commit at the end.** The agent MUST NOT create any intermediate commit. ALL changes (the three new roles, the openresty swarm-mode adaptations, the pilot role updates, the CI workflow, schema entries, group_vars defaults, documentation, and the ticked checkboxes in this document) MUST be combined into ONE commit, created only after every Acceptance Criterion above is checked off (`- [x]`), `make test` is green, and `.github/workflows/test-deploy-swarm-nfs.yml` is green on its first scheduled run. Per-step commits, checkpoint commits, and partial commits MUST NOT be created. The agent MUST NOT push; the operator runs `git-sign-push` outside the sandbox per [CLAUDE.md](../../CLAUDE.md). +- [ ] **One commit at the end.** The agent MUST NOT create any intermediate commit. ALL changes (the three new roles, the openresty swarm-mode adaptations, the pilot role updates, the CI workflow, schema entries, group_vars defaults, documentation, and the ticked checkboxes in this document) MUST be combined into ONE commit, created only after every Acceptance Criterion above is checked off (`- [x]`), `make test` is green, and `.github/workflows/test-deploy-swarm-nfs.yml` is green on its first scheduled run. Per-step commits, checkpoint commits, and partial commits MUST NOT be created. The agent MUST NOT push; the operator runs `git-sign-push` outside the sandbox per [AGENTS.md](../../AGENTS.md). ## See Also diff --git a/docs/requirements/023-setup-penpot.md b/docs/requirements/023-setup-penpot.md index b708d6c124..cca1c9cfd5 100644 --- a/docs/requirements/023-setup-penpot.md +++ b/docs/requirements/023-setup-penpot.md @@ -90,7 +90,7 @@ The implementation of this requirement MUST be executed autonomously by the agen - [x] **Iteration loops.** The agent MUST follow the [Compose Loop](../agents/action/iteration/compose.md) for every change inside `roles/web-app-penpot/`, and the [Playwright Loop](../agents/action/iteration/playwright.md) for every change to the role's Playwright spec. The debug-locally step MUST NOT be skipped in favour of remote CI reruns. - [x] **No `ask` prompts mid-run.** The agent MUST NOT trigger any tool call that routes through `permissions.ask` in [.claude/settings.json](../../.claude/settings.json) during implementation. Where a tool would otherwise route through `ask`, the agent MUST select an equivalent covered by `permissions.allow`, or rephrase the operation to fit the sandbox. The single permitted exception is the final commit. - [x] **No interruptions.** Bug fixes, deploy failures, lint failures, `make test` failures, and healthcheck flaps MUST be resolved at their root inside this same iteration without prompting the operator. Workarounds, ad-hoc skips, retry-until-green loops, or "track in a follow-up" deferrals MUST NOT be used. -- [ ] **One commit at the end.** ALL changes (role, meta, Playwright spec, documentation, marketplace entry, and the ticked checkboxes in this document) MUST be combined into ONE commit, created only after every Acceptance Criterion above is checked off (`- [x]`) and `make test` is green. The agent MUST NOT push; the operator runs `git-sign-push` outside the sandbox per [CLAUDE.md](../../CLAUDE.md). +- [ ] **One commit at the end.** ALL changes (role, meta, Playwright spec, documentation, marketplace entry, and the ticked checkboxes in this document) MUST be combined into ONE commit, created only after every Acceptance Criterion above is checked off (`- [x]`) and `make test` is green. The agent MUST NOT push; the operator runs `git-sign-push` outside the sandbox per [AGENTS.md](../../AGENTS.md). ## See Also diff --git a/docs/requirements/024-web-app-erpnext.md b/docs/requirements/024-web-app-erpnext.md index d0adeb2676..239e87b4a5 100644 --- a/docs/requirements/024-web-app-erpnext.md +++ b/docs/requirements/024-web-app-erpnext.md @@ -301,7 +301,7 @@ The agent MUST execute this requirement **autonomously**. Open clarifications on - The agent MUST NOT create any git commit until every Acceptance Criterion in this document is checked off (`- [x]`). - A single commit (or a tight, related sequence) lands the whole role addition; no half-scaffolded intermediate commits. -- When all ACs are met, `make test` is green, and the three variants deploy cleanly, the agent instructs the operator to run `git-sign-push` outside the sandbox (per [CLAUDE.md](../../CLAUDE.md)). The agent MUST NOT push. +- When all ACs are met, `make test` is green, and the three variants deploy cleanly, the agent instructs the operator to run `git-sign-push` outside the sandbox (per [AGENTS.md](../../AGENTS.md)). The agent MUST NOT push. ## Context diff --git a/docs/requirements/025-web-app-matrix-ansible-flavor.md b/docs/requirements/025-web-app-matrix-ansible-flavor.md index 4e78f783f2..6b483b4770 100644 --- a/docs/requirements/025-web-app-matrix-ansible-flavor.md +++ b/docs/requirements/025-web-app-matrix-ansible-flavor.md @@ -230,7 +230,7 @@ The agent MUST execute this requirement **autonomously** once Proposed Decisions ## Commit Policy - Single commit (or a tight, related sequence) lands the whole ansible-flavor addition; no half-scaffolded intermediate commits. -- When all ACs are checked off, `make test` is green, and the four variants deploy cleanly, the agent instructs the operator to run `git-sign-push` outside the sandbox (per [CLAUDE.md](../../CLAUDE.md)). The agent MUST NOT push. +- When all ACs are checked off, `make test` is green, and the four variants deploy cleanly, the agent instructs the operator to run `git-sign-push` outside the sandbox (per [AGENTS.md](../../AGENTS.md)). The agent MUST NOT push. ## Context diff --git a/docs/requirements/028-web-app-semaphore.md b/docs/requirements/028-web-app-semaphore.md index 1bc579617f..fc04173124 100644 --- a/docs/requirements/028-web-app-semaphore.md +++ b/docs/requirements/028-web-app-semaphore.md @@ -209,7 +209,7 @@ Execute autonomously; open a clarification only when a decision is genuinely amb - No git commit until every Acceptance Criterion is checked off (`- [x]`). - A single commit (or a tight related sequence) lands the whole role addition. -- When all ACs are met and the variants deploy cleanly, instruct the operator to run `git-sign-push` outside the sandbox (per [CLAUDE.md](../../CLAUDE.md)). The agent MUST NOT push. +- When all ACs are met and the variants deploy cleanly, instruct the operator to run `git-sign-push` outside the sandbox (per [AGENTS.md](../../AGENTS.md)). The agent MUST NOT push. ## Context diff --git a/docs/requirements/035-mcp-proxy-expansion.md b/docs/requirements/035-mcp-proxy-expansion.md index 6b13cbd419..ec7d6438f6 100644 --- a/docs/requirements/035-mcp-proxy-expansion.md +++ b/docs/requirements/035-mcp-proxy-expansion.md @@ -488,7 +488,7 @@ A future exception requires a new requirement with a fixed operation list, dedic **`host_execution_boundary` (39):** `desk-bluray-player`, `desk-chromium`, `desk-copyq`, `desk-docker`, `desk-dotlinker`, `desk-firefox`, `desk-git`, `desk-gnome`, `desk-gnome-caffeine`, `desk-gnome-extensions`, `desk-gnome-terminal`, `desk-gnucash`, `desk-jrnl`, `desk-keepassxc`, `desk-libreoffice`, `desk-micro`, `desk-neovim`, `desk-nextcloud`, `desk-obs`, `desk-qbittorrent`, `desk-retroarch`, `desk-spotify`, `desk-ssh`, `desk-torbrowser`, `desk-virtualbox`, `desk-zoom`, `dev-arduino`, `dev-core`, `dev-java`, `dev-locales`, `dev-make`, `dev-nix`, `dev-nodejs`, `dev-python`, `drv-epson-multiprinter`, `drv-intel`, `drv-lid-switch`, `drv-non-free`, `gen-hunspell`. These roles operate a workstation, developer toolchain, device, or host package; bridging them would amount to shared shell, filesystem, browser-session, device, or host execution without an application-specific remote identity. -**`privileged_control_plane` (22):** `svc-bkp-local-2-device`, `svc-bkp-nfs-2-local`, `svc-bkp-remote-2-local`, `svc-bkp-secrets-2-local`, `svc-bkp-volume-2-local`, `svc-dns-unbound`, `svc-net-tor`, `svc-net-wireguard`, `svc-opt-keyboard-color`, `svc-opt-ssd-hdd`, `svc-opt-swapfile`, `svc-prx-openresty`, `svc-registry-cache`, `svc-registry-docker`, `svc-runner`, `svc-storage-nfs-client`, `svc-storage-nfs-server`, `svc-swarm-manager`, `svc-swarm-node`, `svc-virt-kata`, `update`, `web-app-openbao`. +**`privileged_control_plane` (23):** `svc-bkp-local-2-device`, `svc-bkp-nfs-2-local`, `svc-bkp-remote-2-local`, `svc-bkp-secrets-2-local`, `svc-bkp-volume-2-local`, `svc-dns-unbound`, `svc-net-firewall`, `svc-net-tor`, `svc-net-wireguard`, `svc-opt-keyboard-color`, `svc-opt-ssd-hdd`, `svc-opt-swapfile`, `svc-prx-openresty`, `svc-registry-cache`, `svc-registry-docker`, `svc-runner`, `svc-storage-nfs-client`, `svc-storage-nfs-server`, `svc-swarm-manager`, `svc-swarm-node`, `svc-virt-kata`, `update`, `web-app-openbao`. These roles can recover secrets, alter routing or host state, run code, change deployment state, or reach storage/control-plane sockets; they require a separate audited operations gateway and human approval rather than general-purpose application MCP. **`shared_engine_isolation` (4):** `svc-db-memcached`, `svc-db-openldap`, `svc-db-postgres`, `svc-db-rabbitmq`. diff --git a/group_vars/all/05_paths.yml b/group_vars/all/05_paths.yml index 270384ce19..d0a2ab7734 100644 --- a/group_vars/all/05_paths.yml +++ b/group_vars/all/05_paths.yml @@ -5,6 +5,7 @@ DIR_SCRIPTS_ROOT: "/opt/scripts/" DIR_SCRIPTS_SYSTEMCTL: "{{ [ DIR_SCRIPTS_ROOT, 'systemctl' ] | path_join }}" DIR_COMPOSITIONS: "/opt/compose/" DIR_SYSTEM_SERVICE: "/etc/systemd/system" +DIR_SYSTEM_SERVICE_USER: "$HOME/.config/systemd/user" DIR_COMPOSE_PULL_LOCK: "/run/ansible/compose-pull/" DIR_BIN: "/usr/bin" diff --git a/group_vars/all/08_networks.yml b/group_vars/all/08_networks.yml index fa1920561c..f757e83a3e 100644 --- a/group_vars/all/08_networks.yml +++ b/group_vars/all/08_networks.yml @@ -5,16 +5,23 @@ NETWORK_PUBLIC_DNS_RESOLVERS: - 8.8.8.8 # nocheck: hardcoded-dns-resolver - 1.0.0.1 # nocheck: hardcoded-dns-resolver NETWORK_DOCKER_EMBEDDED_DNS_RESOLVER: "127.0.0.11" # nocheck: hardcoded-dns-resolver +NETWORK_DNS_PORT: 53 # Well-known resolver port a host DNS listener binds NETWORK_DOCKER_DNS_NGINX_DIRECTIVE: "resolver {{ NETWORK_DOCKER_EMBEDDED_DNS_RESOLVER }} valid=30s ipv6={{ NETWORK_IPV6_ENABLED | on_off }};" -NETWORK_TRUSTED_PROXY_CIDRS: - - 127.0.0.1 - - "::1" - - 10.0.0.0/8 - - 172.16.0.0/12 - - 192.168.0.0/16 +NETWORK_LOOPBACK_CIDR: "127.0.0.0/8" +NETWORK_PRIVATE_CIDRS: + class_a: "10.0.0.0/8" + class_b: "172.16.0.0/12" + class_c: "192.168.0.0/16" +NETWORK_INTERNAL_CIDRS: "{{ [NETWORK_LOOPBACK_CIDR] + (NETWORK_PRIVATE_CIDRS.values() | list) }}" +NETWORK_ROLE_SUBNET_POOL: "{{ NETWORK_PRIVATE_CIDRS.class_c }}" +NETWORK_ROLE_SUBNET_EXCEPTION_ROLES: + - web-app-bigbluebutton +NETWORK_SWARM_GWBRIDGE_POOL: "{{ NETWORK_PRIVATE_CIDRS.class_b }}" +NETWORK_TRUSTED_PROXY_CIDRS: "{{ ['127.0.0.1', '::1'] + (NETWORK_PRIVATE_CIDRS.values() | list) }}" NETWORK_DOCKER_ADDRESS_POOLS: - base: "10.208.0.0/12" size: 24 +NETWORK_TOR_VIRTUAL_CIDR: "10.240.0.0/12" # Range tor answers for .onion instead of routing; must not overlap the pool above NETWORK_CONTAINER_BRIDGE_RESOLVER: false NETWORK_NESTED_DOCKER_BIP: "192.168.67.1/24" NETWORK_NESTED_DOCKER_ADDRESS_POOLS: @@ -82,6 +89,12 @@ PORT_BANDS: # nocheck: unused-var # consumed by the port-allocation plugin outs start: 9053 end: 9053 public: + http: + start: 80 + end: 80 + https: + start: 443 + end: 443 ssh: start: 2201 end: 2299 diff --git a/group_vars/all/16_ai.yml b/group_vars/all/16_ai.yml index 6f08d29595..c79faa45c5 100644 --- a/group_vars/all/16_ai.yml +++ b/group_vars/all/16_ai.yml @@ -13,17 +13,43 @@ LITELLM_OPENAI_BASE_LOCAL_URL: "{{ LITELLM_BASE_LOCAL_URL }}/v1" LITELLM_BACKEND_ROLES: "{{ lookup('deployment').inventory }}" -LITELLM_CHAT_MODEL: "{{ (lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') | reject('search', 'embed') | list | first) if 'svc-ai-ollama' in LITELLM_BACKEND_ROLES - else ('lmstudio/default' if 'svc-ai-lmstudio' in LITELLM_BACKEND_ROLES else ('openrouter/auto' if ((OPENROUTER_API_KEY | string) | length > 0) else '')) }}" +LITELLM_OLLAMA_BACKEND: >- + {{ (lookup('config', 'svc-ai-litellm', 'services.ollama.enabled') | bool) + and (lookup('config', 'svc-ai-litellm', 'services.ollama.shared') | bool) }} + +LITELLM_LMSTUDIO_BACKEND: >- + {{ (lookup('config', 'svc-ai-litellm', 'services.lmstudio.enabled') | bool) + and (lookup('config', 'svc-ai-litellm', 'services.lmstudio.shared') | bool) }} + +AI_LOCAL_MODELS: + - alias: qwen2.5:0.5b + name: qwen2.5-0.5b-instruct + repo: lmstudio-community/Qwen2.5-0.5B-Instruct-GGUF + url: https://huggingface.co/lmstudio-community/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/Qwen2.5-0.5B-Instruct-Q8_0.gguf + source: https://huggingface.co/lmstudio-community/Qwen2.5-0.5B-Instruct-GGUF + file: Qwen2.5-0.5B-Instruct-Q8_0.gguf + sha256: "1d96e30253a76970864323987e3e77eeaf170312952fd61d18d343c25cfd6e52" + bytes: 531068224 + +AI_REMOTE_ALIASES: >- + {{ ['openai/gpt-4o-mini'] * (lookup('api_enabled', 'openai') | int) + + ['anthropic/claude-sonnet-4-5'] * (lookup('api_enabled', 'anthropic') | int) + + ['openrouter/auto'] * (lookup('api_enabled', 'openrouter') | int) }} + +LITELLM_CHAT_MODEL: "{{ (lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') | map(attribute='alias') | reject('search', 'embed') | list | first | + default('')) if 'svc-ai-ollama' in LITELLM_BACKEND_ROLES else ((lookup('config', 'svc-ai-lmstudio', 'services.lmstudio.preload_models') | map(attribute='alias') + | reject('search', 'embed') | list | first | default('')) if 'svc-ai-lmstudio' in LITELLM_BACKEND_ROLES else (AI_REMOTE_ALIASES | first | default(''))) }}" LITELLM_CHAT_MODEL_SERVED: >- - {{ (('svc-ai-ollama' in LITELLM_BACKEND_ROLES) + {{ ((LITELLM_OLLAMA_BACKEND | bool) and ((lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') - | reject('search', 'embed') | list) | length > 0)) - or ('svc-ai-lmstudio' in LITELLM_BACKEND_ROLES) - or ((OPENROUTER_API_KEY | string) | length > 0) }} + | map(attribute='alias') | reject('search', 'embed') | list) | length > 0)) + or ((LITELLM_LMSTUDIO_BACKEND | bool) + and ((lookup('config', 'svc-ai-lmstudio', 'services.lmstudio.preload_models') + | map(attribute='alias') | reject('search', 'embed') | list) | length > 0)) + or ((AI_REMOTE_ALIASES | length) > 0) }} -OPENROUTER_API_KEY: "" +LITELLM_USABLE: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}" MCP_DISCOVERY: "{{ lookup('mcp_servers') }}" MCP_DISCOVERED_SERVERS: "{{ MCP_DISCOVERY.selected }}" diff --git a/group_vars/all/18_api.yml b/group_vars/all/18_api.yml index 7b05738278..5dd167098a 100644 --- a/group_vars/all/18_api.yml +++ b/group_vars/all/18_api.yml @@ -1,13 +1,18 @@ --- # Central SPOT for proprietary / external (non-self-hosted) API credentials. -# Each entry is empty by default; an integration that depends on a provider is -# only activated when its credentials are present (see the addons that gate -# their `enabled` on `API.`). Override per provider in the (vault- -# encrypted) inventory; access ONLY via lookup('api', '.'). -# nocheck: unused-var: consumed by the api lookup plugin (plugins/lookup/api.py) via lookup('api', ...), not referenced directly in YAML/Jinja. +# Each entry is empty by default. Override per provider in the (vault- +# encrypted) inventory; read ONLY via lookup('api', '.') and +# gate ONLY on lookup('api_enabled', '') (see utils/api.py). +# nocheck: unused-var: consumed by the api lookup plugins (plugins/lookup/api.py, plugins/lookup/api_enabled.py) via lookup('api', ...), not referenced directly in YAML/Jinja. API: + anthropic: + api_key: "" cloudflare: api_token: "" + openai: + api_key: "" + openrouter: + api_key: "" telegram: bot_token: "" api_id: "" diff --git a/group_vars/all/19_tor.yml b/group_vars/all/19_tor.yml deleted file mode 100644 index 691a0e1a99..0000000000 --- a/group_vars/all/19_tor.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -TOR_ONION_SSH_ENABLED: true - -TOR_EGRESS_ROUTER_ENABLED: true -TOR_EGRESS_VIRTUAL_NET_IPV4: "10.240.0.0/12" - -TOR_ONION_EXTRA_PORTS: "{{ ([{'onion_port': 22, 'target': '127.0.0.1:22'}] if (TOR_ONION_SSH_ENABLED | bool) else []) + lookup('tor_ports') }}" diff --git a/group_vars/all/README.md b/group_vars/all/README.md index 2def1b4732..233e6be0f0 100644 --- a/group_vars/all/README.md +++ b/group_vars/all/README.md @@ -6,7 +6,10 @@ This directory contains the [Ansible](https://docs.ansible.com/ansible/latest/pl Variables in this directory MUST apply across all hosts and all roles. Typical examples are the software identity, deployment toggles, domain defaults, the central network and port registries, OIDC and LDAP endpoints, design tokens, and resource defaults. -Role-specific configuration MUST NOT live here. Such values belong in the corresponding role under `roles//config/main.yml` or `roles//vars/main.yml`. +Role-specific configuration MUST NOT live here, even when only one role reads it today. Such values belong in the corresponding role: + +- `roles//meta/services.yml` for anything an operator may override, because the inventory's `applications.` block is deep-merged on top of it and `lookup('config', application_id, '')` reads the result. +- `roles//vars/main.yml` for values derived from that configuration, and for those the role fixes. Role vars outrank inventory variables, so a value placed here cannot be overridden per host. ## File naming and load order 🔢 diff --git a/inventories/development/default.yml b/inventories/development/default.yml index 61421409f4..e498260df5 100644 --- a/inventories/development/default.yml +++ b/inventories/development/default.yml @@ -35,6 +35,14 @@ design: colors: base: "#FFA500" +API: + openai: + api_key: "{{ lookup('env', 'INFINITO_API_OPENAI_KEY') }}" + anthropic: + api_key: "{{ lookup('env', 'INFINITO_API_ANTHROPIC_KEY') }}" + openrouter: + api_key: "{{ lookup('env', 'INFINITO_API_OPENROUTER_KEY') }}" + OIDC_OVERRIDE: CLIENT: SECRET: VERYUNSAFESAMPLEPASSWORD1234 diff --git a/meta/distros.yml b/meta/distros.yml index 0d6d719b38..977c0d8c72 100644 --- a/meta/distros.yml +++ b/meta/distros.yml @@ -46,7 +46,7 @@ distros: debian: slug: debian family: Debian - dev_runtime_image: debian:bookworm + dev_runtime_image: debian:13 galaxy_platform: Debian ubuntu: slug: ubuntu diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 16e17e6341..08a16cc965 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Kevin Veen-Birkenbach pkgname=infinito-nexus -pkgver=13.0.0 +pkgver=14.2.0 pkgrel=1 pkgdesc="Meta package for Infinito.Nexus host dependencies (dev + CI)." arch=('any') diff --git a/packaging/debian/changelog b/packaging/debian/changelog index f42e064389..86c653a99b 100644 --- a/packaging/debian/changelog +++ b/packaging/debian/changelog @@ -1,3 +1,255 @@ +infinito-nexus (14.2.0-1) unstable; urgency=medium + + **For Users** + + * **Secrets management with OpenBao.** The new *web-app-openbao* role deploys OpenBao at + *openbao.*. It stores application credentials, API keys and machine identities. + People sign in through Keycloak, or through LDAP when OpenLDAP is deployed. Their + *administrator*, *operator* or *reader* group decides what they can reach, and signing in + without one of those groups grants only the default policy. Services and automation use + their own AppRole identities, whose credentials change on every deploy, and no root token + is kept after the first deploy. A recovery key kept in the token store lets a later deploy + get back in if those credentials are lost. A static seal unseals the node on its own after + every restart. An internal PKI can be switched on, and Prometheus alerts when the node is + sealed or unreachable. The seal key lives only in the encrypted inventory, and a backup of + the store cannot be read without it, so back up the inventory together with the volume. + See [web-app-openbao](roles/web-app-openbao/README.md). + + * **LDAP import, export and Multi edit finish again in LAM.** The Content Security Policy + blocked the inline script that checks on LAM's background jobs, so these tools stayed + "in progress" forever. That script is allowed again. + + * **Swarm deploys no longer abort on a network that already exists.** Some networks are + created before their application's stack, as Prometheus does for every application it + scrapes. Those networks now carry the stack label, so the application's own deploy uses + the existing network instead of failing with "network with name already exists". + + **For Developers** + + * **Alert rules live in the role that owns the metric.** A role ships its Prometheus rules + in *templates/prometheus/alert_rules.yml.j2*. The new *alert_rule_roles* lookup includes + them only for roles that Prometheus actually scrapes, so a rules file needs no activation + check of its own. It must start at the *groups:* item level. OpenBao's seal alert is the + first rule to use it. + + * **Video for hand-built Playwright contexts.** A context created with + *browser.newContext()* ignored the project's video setting, so its artefact had a trace + but no video. Spread *recordVideoOptions(testInfo)* from *personas/utils/env.js* into the + context options to record one. + + * Image and dependency version jumps (net since 14.1.0): + * *web-app-pgadmin*: 9.17 to 9.18 + + **Contributors** + + * [Evangelos Tsakoudis](https://evangelostsak.com): the OpenBao role with its seal, + credential rotation, recovery and RBAC end-to-end suite, role-local Prometheus alert + rules, and the LAM and swarm network fixes that work uncovered + * [Alejandro Roman Ibanez](https://github.com/AlejandroRomanIbanez): video recording for + hand-built Playwright contexts + * [Kevin Veen-Birkenbach](https://veen.world): review and version maintenance + + -- Kevin Veen-Birkenbach Tue, 22 Sep 2026 15:22:09 +0200 + +infinito-nexus (14.1.0-1) unstable; urgency=medium + + **[14.1.0] - 2026-09-21** + + **For Users** + + * **Local models reach the gateway again.** Upstream re-pushed the *svc-ai-lmstudio* CPU + image with a server that listens on loopback only, so the role stayed green while litellm + could not connect and the AI features of Mattermost, Matrix, xWiki, Moodle and Zammad + failed their warm-up. The server binds the container network again and stays unpublished. + The model preload now waits until the daemon has finished starting, so a slow host no + longer ends up with a daemon that fails every later model load, and the readiness probe + is capped per attempt so a silent peer cannot hang it. See + [svc-ai-lmstudio](roles/svc-ai-lmstudio/README.md). + + **For Developers** + + * **CI deploys only the variants a change reaches.** An image bump in a provider no longer + redeploys the variants of its consumers that switch that provider off. The diff-derived + whitelist, used by the weekly update PRs, Dependabot and every other diff-driven run, + narrows each affected role to the variants whose round closure still reaches a changed + role, emitted as *role#0,2* selection tokens. A changed role and a role reached only + through *run_after* keep every variant. A pinned token naming a role that discovery does + not return now warns instead of aborting the run; role names are still checked against + *roles/* before the run starts. + + * **One instruction file for every agent.** *AGENTS.md* now holds every agent rule, and + *CLAUDE.md* and *GEMINI.md* are gone. The rules they carried bind every runtime anyway, + and the PR templates, scope tables, requirement documents and docs that named the old + files now name *AGENTS.md*. + + * Image and dependency version jumps (net since 14.0.0): + * *svc-ai-ollama*: 0.34.0 to 0.34.2 + * *svc-net-tor* (Debian base): 13.6-slim to 13.7-slim + * *web-app-baserow*: 2.3.3 to 2.3.4 + * *web-app-erpnext*: v16.34.2 to v16.35.0 + * *web-app-funkwhale* (front, api): 2.0.10 to 2.0.11 + * *web-app-jitsi* (web, prosody, jicofo, jvb): stable-9646 to stable-11031 + * *web-app-keycloak*: 26.7.3 to 26.7.4 + * *web-app-opentalk* (LiveKit): v1.13.6 to v1.13.7 + * *web-app-opentalk* (RabbitMQ): 4.3.5 to 4.3.6 + * *web-app-peertube*: v8.2.4 to v8.3.0 + * *web-app-pihole*: 2026.07.2 to 2026.09.0 + * *web-app-prometheus* (Alertmanager): v0.34.0 to v0.34.1 + * Ansible collection *hetzner.hcloud*: 7.0.1 to 7.1.0 + * *ruff* (dev): 0.16.6 to 0.16.7 + + **Contributors** + + * [Kevin Veen-Birkenbach](https://veen.world): LM Studio reachability and startup, + variant-narrowed CI selection, the single agent instruction file, and version maintenance + + -- Kevin Veen-Birkenbach Mon, 21 Sep 2026 23:12:35 +0200 + +infinito-nexus (14.0.0-1) unstable; urgency=medium + + **For Users** + + * **AI assistance inside the applications you already run.** A gateway role, + *svc-ai-litellm*, fronts OpenAI, Anthropic and OpenRouter from declared keys, with + *svc-ai-ollama* and the new *svc-ai-lmstudio* serving local models from one shared + catalogue, so inference can stay inside the deployment. Sixteen applications use it for + their own built-in AI features instead of calling a vendor directly: WordPress AI Engine, + MediaWiki AIEditingAssistant, Moodle's AI subsystem, Zammad Smart Assist, the xWiki LLM + extension, Discourse AI, the Matrix ChatGPT bridge, Nextcloud, Mattermost Agents, n8n, + Open WebUI and Flowise. The gateway's admin UI signs in through Keycloak. See + [svc-ai-litellm](roles/svc-ai-litellm/README.md) and + [svc-ai-lmstudio](roles/svc-ai-lmstudio/README.md). + + * **Your tools reachable from your assistants.** About twenty applications now speak MCP. + Gitea, GitLab, Mattermost, Nextcloud, n8n, Baserow, Jenkins, Home Assistant, Moodle, + Snipe-IT and WordPress serve their own endpoints; ten more get one through a locked-down + sidecar. Open WebUI, Flowise and the agents discover what the deployment offers and + register it automatically. Access is bounded rather than assumed: blocked paths are + declared, every served path is gated, tools a provider calls mutating are withheld, + reader and writer roles are separate, and each provider authenticates callers as its own + service account. See [svc-ai-mcp-adapter](roles/svc-ai-mcp-adapter/README.md). + + * **Agents as employees, with a kernel between them and the host.** *web-app-hermes* and + *web-app-openclaw* deploy agents behind the single sign-on gate, wired to the gateway's + models and to the MCP servers discovery hands them, each proving on deploy that it can + actually reach them. *svc-virt-kata* runs that tier under a kernel-isolating container + runtime, and *svc-ai-robot* embodies an agent on a dedicated device. Every sandboxed + agent acts as its own platform account rather than borrowing yours. See + [web-app-hermes](roles/web-app-hermes/README.md), + [web-app-openclaw](roles/web-app-openclaw/README.md) and + [svc-virt-kata](roles/svc-virt-kata/README.md). + + * **Home automation joins the platform.** *web-app-homeassistant* deploys the automation + hub, exposes it as an MCP server so agents can drive it, persists the token it mints, and + is reachable over tor. See [web-app-homeassistant](roles/web-app-homeassistant/README.md). + + * **Changing a password changes it everywhere.** A user declares how its password must be + generated, the inventory pins one for every declared user, a new generator produces + API-key shapes, the deploy rotates credentials between its two passes, and a reset command + exists. Applications that persist an administrator password at install now realign it when + the declared one changes: Nextcloud, Moodle, EspoCRM, Jellyfin, Listmonk, Discourse, + Checkmk, xWiki, n8n, Matrix and the dedicated MariaDB and Postgres engines. Checkmk keeps + a 401 fatal so a wrong password cannot lock the account out. + + * **Onions, firewall and name resolution.** *svc-net-firewall* owns the packet filter as one + nftables table per role. Tor carries mail to a .onion in both deploy modes, rejects forged + sources on the guarded ports, and binds its dnsmasq listeners statically so a failed + re-enumeration can no longer drop every one of them. The deployment declares its own + address space once, the egress range moved out of the Docker pool, and the container + resolver follows the live bridge instead of a guessed address. Every host-bound port + requires an explicit onion decision. See + [svc-net-firewall](roles/svc-net-firewall/README.md). + + * **Application fixes you will notice.** Nextcloud installs an OnlyOffice release upstream + actually published, keeps social login working over tor, and makes its OIDC account an + administrator. Element shows invites again through the new room list. Moodle survives the + container roll during installation. Mattermost keeps its configuration in Postgres instead + of on a shared volume, and its plugin tree is per node. GitLab creates the default + organization a headless install never gets. Jellyfin waits for health before driving the + setup wizard. Keycloak registers redirect URIs only for the apps a play deploys, keeps the + allowed-origins claim out of the access token, and no longer caches LDAP federation past a + fresh grant. The platform's own health mail no longer eats the sender quota. + + * **Operating a deployment.** New make targets reach a single deployed application: + *compose-app-exec*, *compose-app-logs* and *compose-app-restart*. Diagnostics collect the + logs an application writes to a file, including from busybox containers. Health probes use + a fast start cadence with a window that outlasts a real boot, so a slow starter is no + longer reaped. The CSP health check accepts a status code a vhost declares instead of + dropping the vhost, and gives onion vhosts a navigation budget Tor can meet. + + **For Developers** + + * **MCP is declared, not wired.** A role states an *mcp* block in *meta/services.yml*, and + the vocabulary, discovery and rendering layer derives endpoints, clients, credentials and + network edges from it. A client-provider pairing is declared once, on the provider; + *sys-svc-mcp-reconcile* converges clients from the complete provider set; a refused pairing + is expressed as off rather than gated at the call site; and *svc-ai-mcp-adapter* is a + reusable per-provider sidecar rather than a standalone application. The contract is proven + on deploy, including refusal on *initialize* and the tool inventory itself. The design, + its delegation rules and a machine-checkable audit live under + [docs/contributing/design/role/services/mcp.md](docs/contributing/design/role/services/mcp.md). + + * **Registry and variant changes.** A variant can dictate the configuration of the providers + it pulls in, and an MCP round decides for itself what it carries, which is the breaking + change in this release. Fifteen moving pins gained names the registry can order, every MCP + provider is registered as a shared service, *meta/schema.yml* finished its rename to + *meta/secrets.yml*, role scripts are sorted into per-language folders, and *meta/addons* is + now an accepted declaration site for pins and plugins. + + * **Lints that fail closed.** Five properties that used to pass silently now fail. The PHP + and Ruby that roles ship are syntax-checked, and so are shell scripts that ship as Jinja + templates. *sys-service* owns every systemd unit a role installs. Healthcheck timings are + judged against what the base image was measured to hold, a variable-free healthcheck + belongs in *meta/services.yml*, an unenforceable NFS state layout fails at author time, a + sandboxed role cannot be pinned to the manager, credentials are kept out of volumes + declared non-secret, *no_log* is required on every task carrying one and forbidden in the + test roles, and a JavaScript default for an unset environment variable is rejected. + + * **Pins that cannot age unnoticed.** Image pins are classified and pull references built + from the class, a moving tag declares itself with the marker it already carries, and a + role's images move as one release or not at all. The updater reads addon declarations, + orders vendor patch counters and five-component versions, reads a release line written + before the number as a family, and now also watches pinned pip requirements and Ansible + collections. Seven roles moved off the Debian codename base onto slim, and one Ubuntu + mirror list with failover, a short timeout and a health test serves both the package cache + and the image builds. *js-yaml* was lifted to 4.3.2 for GHSA-2883-xcg3-v3hh. + + * **CI selects, resumes and proves.** A fourth deploy chunk stops a short priority chunk + stranding budget, a manual run can set the chunk size the formula would derive, and a + retrigger can override the chunk gate. Retriggering works by role and by what never got a + verdict, resumes behind the green window rather than the deployed one, retires suites the + source run already passed, and cancels the run before retriggering its failed roles. Each + role's README instructions are replayed on its smallest deploy row, in the mode its + *services.yml* declares. Distro images build concurrently on one runner, the matrix job + folds into the image job, and the deploy chunks no longer wait on the DNS test. + + * **Swarm, measured rather than assumed.** The deploy waits for its own update instead of + guessing after it, refuses to call a stack converged while it is still updating, survives a + task that failed and came back, reports what converged rather than only what did not, and + says why the convergence probe refused. Placement is constrained to a role's declared + platform arch, redis and memcached sidecars get a deploy block, and a run-once service no + longer strands the stack deploy waiting for a replica it never has. The rescue dump + captures the contents of the state volumes. In the test lab, workers are labelled + kata-capable only when the app is sandboxed, NFS detaches before the node containers die, + and the sandbox tier gets a second node. + + * **Faster gates.** Per-host setup runs once per host instead of once per application, a + role's volumes are answered from its own file instead of a whole-tree build, the Keycloak + RBAC root group resolves once rather than once per app, the Playwright fixture copy the + tree copy already made is dropped, logout helpers wait for a control instead of sleeping, + and the OIDC login helper stops waiting out probes that cannot match. Onion timeouts scale + by tor flavor instead of a flat multiplier, and every Playwright timeout has one source. + + * **Documentation and agent instructions.** Six new requirement documents cover the LLM + gateway and model backends, agent employees, Home Assistant, the robot role, the MCP proxy + expansion and native AI gateway integration. The MCP design docs are nested under their own + folder with an audit the lints can check. The role README template asks the reader for a + domain the deployment can resolve, enforces consistent Credits attribution, and drops the em + dash. Documentation generators are gathered under *cli/build/docs*, and a new CLI edits role + bonds in a matrix instead of hunting through files. + + -- Kevin Veen-Birkenbach Sat, 19 Sep 2026 16:35:48 +0200 + infinito-nexus (13.0.0-1) unstable; urgency=medium * **Tor as a first-class deployment axis.** The new *svc-net-tor* role routes onion diff --git a/packaging/fedora/infinito-nexus.spec b/packaging/fedora/infinito-nexus.spec index 51e99a8f89..9c97657c75 100644 --- a/packaging/fedora/infinito-nexus.spec +++ b/packaging/fedora/infinito-nexus.spec @@ -1,5 +1,5 @@ Name: infinito-nexus -Version: 13.0.0 +Version: 14.2.0 Release: 1%{?dist} Summary: Meta package for Infinito.Nexus host dependencies @@ -58,6 +58,249 @@ install -d %{buildroot}%{_docdir}/%{name} %doc %{_docdir}/%{name}/DEPENDENCIES %changelog +* Tue Sep 22 2026 Kevin Veen-Birkenbach - 14.2.0-1 +- **For Users** + +- * **Secrets management with OpenBao.** The new *web-app-openbao* role deploys OpenBao at +- *openbao.*. It stores application credentials, API keys and machine identities. +- People sign in through Keycloak, or through LDAP when OpenLDAP is deployed. Their +- *administrator*, *operator* or *reader* group decides what they can reach, and signing in +- without one of those groups grants only the default policy. Services and automation use +- their own AppRole identities, whose credentials change on every deploy, and no root token +- is kept after the first deploy. A recovery key kept in the token store lets a later deploy +- get back in if those credentials are lost. A static seal unseals the node on its own after +- every restart. An internal PKI can be switched on, and Prometheus alerts when the node is +- sealed or unreachable. The seal key lives only in the encrypted inventory, and a backup of +- the store cannot be read without it, so back up the inventory together with the volume. +- See [web-app-openbao](roles/web-app-openbao/README.md). + +- * **LDAP import, export and Multi edit finish again in LAM.** The Content Security Policy +- blocked the inline script that checks on LAM's background jobs, so these tools stayed +- "in progress" forever. That script is allowed again. + +- * **Swarm deploys no longer abort on a network that already exists.** Some networks are +- created before their application's stack, as Prometheus does for every application it +- scrapes. Those networks now carry the stack label, so the application's own deploy uses +- the existing network instead of failing with "network with name already exists". + +- **For Developers** + +- * **Alert rules live in the role that owns the metric.** A role ships its Prometheus rules +- in *templates/prometheus/alert_rules.yml.j2*. The new *alert_rule_roles* lookup includes +- them only for roles that Prometheus actually scrapes, so a rules file needs no activation +- check of its own. It must start at the *groups:* item level. OpenBao's seal alert is the +- first rule to use it. + +- * **Video for hand-built Playwright contexts.** A context created with +- *browser.newContext()* ignored the project's video setting, so its artefact had a trace +- but no video. Spread *recordVideoOptions(testInfo)* from *personas/utils/env.js* into the +- context options to record one. + +- * Image and dependency version jumps (net since 14.1.0): +- * *web-app-pgadmin*: 9.17 to 9.18 + +- **Contributors** + +- * [Evangelos Tsakoudis](https://evangelostsak.com): the OpenBao role with its seal, +- credential rotation, recovery and RBAC end-to-end suite, role-local Prometheus alert +- rules, and the LAM and swarm network fixes that work uncovered +- * [Alejandro Roman Ibanez](https://github.com/AlejandroRomanIbanez): video recording for +- hand-built Playwright contexts +- * [Kevin Veen-Birkenbach](https://veen.world): review and version maintenance + +* Mon Sep 21 2026 Kevin Veen-Birkenbach - 14.1.0-1 +- **[14.1.0] - 2026-09-21** + +- **For Users** + +- * **Local models reach the gateway again.** Upstream re-pushed the *svc-ai-lmstudio* CPU +- image with a server that listens on loopback only, so the role stayed green while litellm +- could not connect and the AI features of Mattermost, Matrix, xWiki, Moodle and Zammad +- failed their warm-up. The server binds the container network again and stays unpublished. +- The model preload now waits until the daemon has finished starting, so a slow host no +- longer ends up with a daemon that fails every later model load, and the readiness probe +- is capped per attempt so a silent peer cannot hang it. See +- [svc-ai-lmstudio](roles/svc-ai-lmstudio/README.md). + +- **For Developers** + +- * **CI deploys only the variants a change reaches.** An image bump in a provider no longer +- redeploys the variants of its consumers that switch that provider off. The diff-derived +- whitelist, used by the weekly update PRs, Dependabot and every other diff-driven run, +- narrows each affected role to the variants whose round closure still reaches a changed +- role, emitted as *role#0,2* selection tokens. A changed role and a role reached only +- through *run_after* keep every variant. A pinned token naming a role that discovery does +- not return now warns instead of aborting the run; role names are still checked against +- *roles/* before the run starts. + +- * **One instruction file for every agent.** *AGENTS.md* now holds every agent rule, and +- *CLAUDE.md* and *GEMINI.md* are gone. The rules they carried bind every runtime anyway, +- and the PR templates, scope tables, requirement documents and docs that named the old +- files now name *AGENTS.md*. + +- * Image and dependency version jumps (net since 14.0.0): +- * *svc-ai-ollama*: 0.34.0 to 0.34.2 +- * *svc-net-tor* (Debian base): 13.6-slim to 13.7-slim +- * *web-app-baserow*: 2.3.3 to 2.3.4 +- * *web-app-erpnext*: v16.34.2 to v16.35.0 +- * *web-app-funkwhale* (front, api): 2.0.10 to 2.0.11 +- * *web-app-jitsi* (web, prosody, jicofo, jvb): stable-9646 to stable-11031 +- * *web-app-keycloak*: 26.7.3 to 26.7.4 +- * *web-app-opentalk* (LiveKit): v1.13.6 to v1.13.7 +- * *web-app-opentalk* (RabbitMQ): 4.3.5 to 4.3.6 +- * *web-app-peertube*: v8.2.4 to v8.3.0 +- * *web-app-pihole*: 2026.07.2 to 2026.09.0 +- * *web-app-prometheus* (Alertmanager): v0.34.0 to v0.34.1 +- * Ansible collection *hetzner.hcloud*: 7.0.1 to 7.1.0 +- * *ruff* (dev): 0.16.6 to 0.16.7 + +- **Contributors** + +- * [Kevin Veen-Birkenbach](https://veen.world): LM Studio reachability and startup, +- variant-narrowed CI selection, the single agent instruction file, and version maintenance + +* Sat Sep 19 2026 Kevin Veen-Birkenbach - 14.0.0-1 +- **For Users** + +- * **AI assistance inside the applications you already run.** A gateway role, +- *svc-ai-litellm*, fronts OpenAI, Anthropic and OpenRouter from declared keys, with +- *svc-ai-ollama* and the new *svc-ai-lmstudio* serving local models from one shared +- catalogue, so inference can stay inside the deployment. Sixteen applications use it for +- their own built-in AI features instead of calling a vendor directly: WordPress AI Engine, +- MediaWiki AIEditingAssistant, Moodle's AI subsystem, Zammad Smart Assist, the xWiki LLM +- extension, Discourse AI, the Matrix ChatGPT bridge, Nextcloud, Mattermost Agents, n8n, +- Open WebUI and Flowise. The gateway's admin UI signs in through Keycloak. See +- [svc-ai-litellm](roles/svc-ai-litellm/README.md) and +- [svc-ai-lmstudio](roles/svc-ai-lmstudio/README.md). + +- * **Your tools reachable from your assistants.** About twenty applications now speak MCP. +- Gitea, GitLab, Mattermost, Nextcloud, n8n, Baserow, Jenkins, Home Assistant, Moodle, +- Snipe-IT and WordPress serve their own endpoints; ten more get one through a locked-down +- sidecar. Open WebUI, Flowise and the agents discover what the deployment offers and +- register it automatically. Access is bounded rather than assumed: blocked paths are +- declared, every served path is gated, tools a provider calls mutating are withheld, +- reader and writer roles are separate, and each provider authenticates callers as its own +- service account. See [svc-ai-mcp-adapter](roles/svc-ai-mcp-adapter/README.md). + +- * **Agents as employees, with a kernel between them and the host.** *web-app-hermes* and +- *web-app-openclaw* deploy agents behind the single sign-on gate, wired to the gateway's +- models and to the MCP servers discovery hands them, each proving on deploy that it can +- actually reach them. *svc-virt-kata* runs that tier under a kernel-isolating container +- runtime, and *svc-ai-robot* embodies an agent on a dedicated device. Every sandboxed +- agent acts as its own platform account rather than borrowing yours. See +- [web-app-hermes](roles/web-app-hermes/README.md), +- [web-app-openclaw](roles/web-app-openclaw/README.md) and +- [svc-virt-kata](roles/svc-virt-kata/README.md). + +- * **Home automation joins the platform.** *web-app-homeassistant* deploys the automation +- hub, exposes it as an MCP server so agents can drive it, persists the token it mints, and +- is reachable over tor. See [web-app-homeassistant](roles/web-app-homeassistant/README.md). + +- * **Changing a password changes it everywhere.** A user declares how its password must be +- generated, the inventory pins one for every declared user, a new generator produces +- API-key shapes, the deploy rotates credentials between its two passes, and a reset command +- exists. Applications that persist an administrator password at install now realign it when +- the declared one changes: Nextcloud, Moodle, EspoCRM, Jellyfin, Listmonk, Discourse, +- Checkmk, xWiki, n8n, Matrix and the dedicated MariaDB and Postgres engines. Checkmk keeps +- a 401 fatal so a wrong password cannot lock the account out. + +- * **Onions, firewall and name resolution.** *svc-net-firewall* owns the packet filter as one +- nftables table per role. Tor carries mail to a .onion in both deploy modes, rejects forged +- sources on the guarded ports, and binds its dnsmasq listeners statically so a failed +- re-enumeration can no longer drop every one of them. The deployment declares its own +- address space once, the egress range moved out of the Docker pool, and the container +- resolver follows the live bridge instead of a guessed address. Every host-bound port +- requires an explicit onion decision. See +- [svc-net-firewall](roles/svc-net-firewall/README.md). + +- * **Application fixes you will notice.** Nextcloud installs an OnlyOffice release upstream +- actually published, keeps social login working over tor, and makes its OIDC account an +- administrator. Element shows invites again through the new room list. Moodle survives the +- container roll during installation. Mattermost keeps its configuration in Postgres instead +- of on a shared volume, and its plugin tree is per node. GitLab creates the default +- organization a headless install never gets. Jellyfin waits for health before driving the +- setup wizard. Keycloak registers redirect URIs only for the apps a play deploys, keeps the +- allowed-origins claim out of the access token, and no longer caches LDAP federation past a +- fresh grant. The platform's own health mail no longer eats the sender quota. + +- * **Operating a deployment.** New make targets reach a single deployed application: +- *compose-app-exec*, *compose-app-logs* and *compose-app-restart*. Diagnostics collect the +- logs an application writes to a file, including from busybox containers. Health probes use +- a fast start cadence with a window that outlasts a real boot, so a slow starter is no +- longer reaped. The CSP health check accepts a status code a vhost declares instead of +- dropping the vhost, and gives onion vhosts a navigation budget Tor can meet. + +- **For Developers** + +- * **MCP is declared, not wired.** A role states an *mcp* block in *meta/services.yml*, and +- the vocabulary, discovery and rendering layer derives endpoints, clients, credentials and +- network edges from it. A client-provider pairing is declared once, on the provider; +- *sys-svc-mcp-reconcile* converges clients from the complete provider set; a refused pairing +- is expressed as off rather than gated at the call site; and *svc-ai-mcp-adapter* is a +- reusable per-provider sidecar rather than a standalone application. The contract is proven +- on deploy, including refusal on *initialize* and the tool inventory itself. The design, +- its delegation rules and a machine-checkable audit live under +- [docs/contributing/design/role/services/mcp.md](docs/contributing/design/role/services/mcp.md). + +- * **Registry and variant changes.** A variant can dictate the configuration of the providers +- it pulls in, and an MCP round decides for itself what it carries, which is the breaking +- change in this release. Fifteen moving pins gained names the registry can order, every MCP +- provider is registered as a shared service, *meta/schema.yml* finished its rename to +- *meta/secrets.yml*, role scripts are sorted into per-language folders, and *meta/addons* is +- now an accepted declaration site for pins and plugins. + +- * **Lints that fail closed.** Five properties that used to pass silently now fail. The PHP +- and Ruby that roles ship are syntax-checked, and so are shell scripts that ship as Jinja +- templates. *sys-service* owns every systemd unit a role installs. Healthcheck timings are +- judged against what the base image was measured to hold, a variable-free healthcheck +- belongs in *meta/services.yml*, an unenforceable NFS state layout fails at author time, a +- sandboxed role cannot be pinned to the manager, credentials are kept out of volumes +- declared non-secret, *no_log* is required on every task carrying one and forbidden in the +- test roles, and a JavaScript default for an unset environment variable is rejected. + +- * **Pins that cannot age unnoticed.** Image pins are classified and pull references built +- from the class, a moving tag declares itself with the marker it already carries, and a +- role's images move as one release or not at all. The updater reads addon declarations, +- orders vendor patch counters and five-component versions, reads a release line written +- before the number as a family, and now also watches pinned pip requirements and Ansible +- collections. Seven roles moved off the Debian codename base onto slim, and one Ubuntu +- mirror list with failover, a short timeout and a health test serves both the package cache +- and the image builds. *js-yaml* was lifted to 4.3.2 for GHSA-2883-xcg3-v3hh. + +- * **CI selects, resumes and proves.** A fourth deploy chunk stops a short priority chunk +- stranding budget, a manual run can set the chunk size the formula would derive, and a +- retrigger can override the chunk gate. Retriggering works by role and by what never got a +- verdict, resumes behind the green window rather than the deployed one, retires suites the +- source run already passed, and cancels the run before retriggering its failed roles. Each +- role's README instructions are replayed on its smallest deploy row, in the mode its +- *services.yml* declares. Distro images build concurrently on one runner, the matrix job +- folds into the image job, and the deploy chunks no longer wait on the DNS test. + +- * **Swarm, measured rather than assumed.** The deploy waits for its own update instead of +- guessing after it, refuses to call a stack converged while it is still updating, survives a +- task that failed and came back, reports what converged rather than only what did not, and +- says why the convergence probe refused. Placement is constrained to a role's declared +- platform arch, redis and memcached sidecars get a deploy block, and a run-once service no +- longer strands the stack deploy waiting for a replica it never has. The rescue dump +- captures the contents of the state volumes. In the test lab, workers are labelled +- kata-capable only when the app is sandboxed, NFS detaches before the node containers die, +- and the sandbox tier gets a second node. + +- * **Faster gates.** Per-host setup runs once per host instead of once per application, a +- role's volumes are answered from its own file instead of a whole-tree build, the Keycloak +- RBAC root group resolves once rather than once per app, the Playwright fixture copy the +- tree copy already made is dropped, logout helpers wait for a control instead of sleeping, +- and the OIDC login helper stops waiting out probes that cannot match. Onion timeouts scale +- by tor flavor instead of a flat multiplier, and every Playwright timeout has one source. + +- * **Documentation and agent instructions.** Six new requirement documents cover the LLM +- gateway and model backends, agent employees, Home Assistant, the robot role, the MCP proxy +- expansion and native AI gateway integration. The MCP design docs are nested under their own +- folder with an audit the lints can check. The role README template asks the reader for a +- domain the deployment can resolve, enforces consistent Credits attribution, and drops the em +- dash. Documentation generators are gathered under *cli/build/docs*, and a new CLI edits role +- bonds in a matrix instead of hunting through files. + * Wed Aug 26 2026 Kevin Veen-Birkenbach - 13.0.0-1 - * **Tor as a first-class deployment axis.** The new *svc-net-tor* role routes onion - names through Tor via DNS instead of per-app proxies, offers a chutney flavor for diff --git a/plugins/action/applications_carrier.py b/plugins/action/applications_carrier.py index 5991ff5cd8..f7711ed295 100644 --- a/plugins/action/applications_carrier.py +++ b/plugins/action/applications_carrier.py @@ -5,19 +5,31 @@ Ansible forks a fresh worker per task, so the in-process render cache of a place all later workers inherit. This action renders in the worker of one constructor task and returns the payload as a host fact; every later worker inherits that fact at fork time and ``get_merged_applications`` serves it -instead of rendering. Sits after the token store in -``tasks/stages/01_constructor.yml`` so the embedded user records already -carry the loaded tokens:: +instead of rendering:: - name: 🧊 Render the applications SPOT once and carry it into every task's worker applications_carrier: {} +``tasks/stages/01_constructor.yml`` parks two of them. The stage-wide one +sits after the token store so the embedded user records already carry the +loaded tokens, and after the ``add_host`` tasks because 114 roles gate +services on ``group_names`` and the payload bakes those in. The +merge block opens an earlier, block-scoped one and drops it again at the +block's end, because ``merged_applications_cache_key`` reads neither +``group_names`` nor the token file: a carrier left standing across either +would be served unchanged where a fresh render belongs:: + + - name: 🧊 Drop the merge-block carrier so later stages re-render + applications_carrier: + clear: true + The result is masked by the action itself: the fact is the whole rendered tree, about 0.4 MB with every application's credentials, and printing it at ``-v`` is neither credential debugging nor readable. Args: - none. Any argument is an error. + clear: drop the carrier fact instead of rendering. Defaults to false. + Any other argument is an error. """ from __future__ import annotations @@ -25,6 +37,7 @@ from __future__ import annotations from typing import Any from ansible.errors import AnsibleActionFail +from ansible.module_utils.parsing.convert_bool import boolean from ansible.plugins.action import ActionBase from ansible.plugins.loader import lookup_loader @@ -39,8 +52,19 @@ class ActionModule(ActionBase): ) -> dict[str, Any]: result = super().run(tmp, task_vars) task_vars = task_vars or {} - if self._task.args: - raise AnsibleActionFail("applications_carrier takes no arguments") + args = self._task.args or {} + unexpected = sorted(set(args) - {"clear"}) + if unexpected: + raise AnsibleActionFail( + f"applications_carrier: unknown argument(s) {', '.join(unexpected)}; " + "the only argument is 'clear'" + ) + + if boolean(args.get("clear", False)): + result.update( + changed=False, ansible_facts={APPLICATIONS_RENDERED_FACT: None} + ) + return result lookup = lookup_loader.get( "applications", loader=self._loader, templar=self._templar diff --git a/plugins/filter/README.md b/plugins/filter/README.md index 8ad96bd0ab..7312a1d39a 100644 --- a/plugins/filter/README.md +++ b/plugins/filter/README.md @@ -21,6 +21,39 @@ This directory contains custom **Ansible filter plugins** used within the Infini - If you need to **load data from an external source** (e.g., file, environment, API), use a lookup plugin instead. - If your logic requires **access to inventory, facts, or host-level information** that is not passed as a parameter. +## Schema 🗺️ + +How `csp_filters.py` assembles one application's Content-Security-Policy. Scope is the header build, not the other filters in this directory. + +```mermaid +flowchart TD + decl["meta/csp.yml
whitelist, flags, hashes"] + svc["meta/services.yml
services.logout.enabled"] + flags["csp_filters.py:136
get_csp_flags"] + grant["csp_filters.py:354
logout grant"] + wl["csp_filters.py:123
get_csp_whitelist"] + inj["sys-front-inj-dashboard,
-javascript, -matomo, -logout
add_csp_hash"] + gate["csp_filters.py:365
hash gate"] + build["csp_filters.py:203
build_csp_header"] + header["set $csp
nginx vhost"] + + decl -->|"1. flags read by"| flags + flags -->|"2. tokens"| build + svc -->|"3. read by"| grant + grant -->|"4. adds 'unsafe-inline'
to script-src-elem and -attr"| build + decl -->|"5. whitelist read by"| wl + wl -->|"6. tokens"| build + inj -->|"7. snippets"| gate + gate -->|"8. hashes, only when
'unsafe-inline' is absent"| build + build -->|"9. joined string"| header + + grant -.->|"TRIP-WIRE: the token it adds
closes the gate below, so every
registered hash is discarded"| gate + svc -.->|"TRIP-WIRE: the flag derives from
group_names, so the script policy
follows the round, not the app"| grant + grant -.->|"TRIP-WIRE: absent from the
smart-defaults docstring, which
names logout only for frame-ancestors"| build +``` + +Styles carry `'unsafe-inline'` by default; scripts do not. The logout grant is the only path that puts it into a script directive. + ## Further Reading - [Ansible Filter Plugins Documentation](https://docs.ansible.com/ansible/latest/plugins/filter.html) diff --git a/plugins/filter/redirect_sources.py b/plugins/filter/redirect_sources.py new file mode 100644 index 0000000000..f826647bb7 --- /dev/null +++ b/plugins/filter/redirect_sources.py @@ -0,0 +1,28 @@ +from ansible.errors import AnsibleFilterError + + +def redirect_sources(host: str) -> list[str]: + """Ordered match prefixes for a redirect that targets ``host``. + + Args: + host: the upstream host, as it appears in the proxied URL. + + Returns: + The two host-qualified forms, then the bare ``/`` for a target the + upstream expressed as an absolute path. + """ + if not isinstance(host, str) or not host.strip(): + raise AnsibleFilterError("redirect_sources: host must be a non-empty string") + stripped = host.strip() + return [ + f"https://{stripped}/", + f"http://{stripped}/", + "/", + ] + + +class FilterModule: + def filters(self): + return { + "redirect_sources": redirect_sources, + } diff --git a/plugins/lookup/alert_rule_roles.py b/plugins/lookup/alert_rule_roles.py index 05096c9ca5..dd4e139c8e 100644 --- a/plugins/lookup/alert_rule_roles.py +++ b/plugins/lookup/alert_rule_roles.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Any +from ansible.plugins.loader import lookup_loader from ansible.plugins.lookup import LookupBase from utils.cache import ROLES_DIR @@ -11,14 +12,19 @@ ALERT_RULES_TEMPLATE = ("templates", "prometheus", "alert_rules.yml.j2") class LookupModule(LookupBase): """ - Return a sorted list of deployed role IDs that ship their own Prometheus - alert rules, i.e. every role in ``group_names`` that has a template at - ``roles//templates/prometheus/alert_rules.yml.j2``. + Return a sorted list of role IDs whose Prometheus alert rules belong in + this deploy: every role that ships a template at + ``roles//templates/prometheus/alert_rules.yml.j2`` and is actually + scraped, i.e. appears in ``native_metrics_apps``. Keeps alert rules at the role that owns the metric instead of collecting them in web-app-prometheus: a role declares what "unhealthy" means for itself, and the Prometheus role only concatenates what the play enabled. + Filtering on activation here rather than inside each fragment is what keeps + the fragments free of boilerplate. A rule that fires on a series nothing + scrapes is worse than no rule: it alerts on ``absent()`` forever. + Usage in a template: {% for role_id in lookup('alert_rule_roles') %} {% include 'roles/' + role_id + '/templates/prometheus/alert_rules.yml.j2' %} @@ -39,11 +45,16 @@ class LookupModule(LookupBase): raise ValueError("alert_rule_roles lookup takes no positional terms") vars_ = variables or getattr(self._templar, "available_variables", {}) or {} - group_names: list[str] = vars_.get("group_names", []) or [] + + scraped: list[str] = lookup_loader.get( + "native_metrics_apps", + loader=self._loader, + templar=getattr(self, "_templar", None), + ).run([], variables=vars_)[0] result = [ role_id - for role_id in sorted(set(group_names)) + for role_id in sorted(set(scraped)) if (ROLES_DIR / role_id).joinpath(*ALERT_RULES_TEMPLATE).exists() ] diff --git a/plugins/lookup/api.py b/plugins/lookup/api.py index 9740e8d16b..a10e26c2f2 100644 --- a/plugins/lookup/api.py +++ b/plugins/lookup/api.py @@ -5,13 +5,16 @@ from typing import Any from ansible.errors import AnsibleError from ansible.plugins.lookup import LookupBase +from utils.api import resolve_api + class LookupModule(LookupBase): """ lookup('api', '.') e.g. lookup('api', 'github.client_id') Single point of truth for proprietary / external API credentials. Resolves a - dotted path against the global ``API`` mapping (group_vars/all/18_api.yml). + dotted path against the effective ``API`` mapping: the declaration in + group_vars/all/18_api.yml with the calling scope's override merged over it. Routing every access through this plugin keeps one place to change how proprietary API credentials are sourced (vault, env, secret manager, a different provider abstraction) without touching the call sites. @@ -31,11 +34,10 @@ class LookupModule(LookupBase): if not path: raise AnsibleError("lookup('api'): empty key path.") - api = variables.get("API", {}) - if templar is not None: - api = templar.template(api) - if not isinstance(api, dict): - raise AnsibleError("lookup('api'): global 'API' is not a mapping.") + try: + api = resolve_api(variables, templar=templar) + except TypeError as error: + raise AnsibleError(f"lookup('api'): {error}") from error node: Any = api for part in path.split("."): diff --git a/plugins/lookup/api_enabled.py b/plugins/lookup/api_enabled.py new file mode 100644 index 0000000000..c5f189070b --- /dev/null +++ b/plugins/lookup/api_enabled.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Any + +from ansible.errors import AnsibleError +from ansible.plugins.lookup import LookupBase + +from utils.api import provider_enabled, resolve_api + + +class LookupModule(LookupBase): + """ + lookup('api_enabled', '') e.g. lookup('api_enabled', 'github') + + True when every credential the provider declares in group_vars/all/18_api.yml + carries a value in the effective ``API`` mapping. Integrations that depend on + a proprietary provider gate on this instead of spelling the emptiness test + out per call site, so an added credential key extends the gate everywhere and + the result is a real boolean rather than a string a filter has to coerce. + """ + + def run(self, terms, variables: dict[str, Any] | None = None, **kwargs): + if not terms or len(terms) != 1: + raise AnsibleError( + "lookup('api_enabled') takes exactly one term: '' " + "(e.g. 'github')." + ) + + templar = getattr(self, "_templar", None) + variables = variables or getattr(templar, "available_variables", {}) or {} + + provider = str(terms[0]).strip() + if not provider: + raise AnsibleError("lookup('api_enabled'): empty provider name.") + + try: + api = resolve_api(variables, templar=templar) + return [provider_enabled(api, provider)] + except (KeyError, TypeError) as error: + raise AnsibleError(f"lookup('api_enabled'): {error}") from error diff --git a/plugins/lookup/container_ports.py b/plugins/lookup/container_ports.py index 87ce236006..f6cb5504a2 100644 --- a/plugins/lookup/container_ports.py +++ b/plugins/lookup/container_ports.py @@ -1,33 +1,67 @@ -"""Lookup `container_ports`: build a compose ``ports:`` publish block from -``[service_name, protocol]`` or ``[service_name, protocol, host_ip]`` terms. +"""Lookup `container_ports`: build a compose ``ports:`` block from declared ports. -Single SPOT replacing the repeated, verbose port lookups. Each term is a -two- or three-element list; pass one or more. The optional third element is -the host IP to bind that port to; omit it to publish on every interface: +Single SPOT for every published port in the repo. A term is either the short +list form or the dict form; pass one or more. + +List form, unchanged:: {{ lookup('container_ports', ['gitea', 'http', DOCKER_BIND_HOST], ['gitea', 'ssh']) | indent(4) }} -produces +produces:: ports: - - "::" + - "::<...internal.http>" - ":" -Per term the published (host) port is ``services..ports.local.`` -when declared, else ``services..ports.public.``; the container -port is always ``services..ports.internal.``. A third element -prefixes ``:``; without it the port binds to all interfaces. This -makes every mapping expressible, including binds to a specific public IP -(e.g. ``['mailu', 'smtp', MAILU_IP4_PUBLIC]``). +The published (host) port is ``ports.local.`` when declared, else +``ports.public.``; the container port is ``ports.internal.``. -``application_id`` is read from the play variables unless given as ``application_id=``. +Dict form, for the shapes the list form cannot express:: + + {{ lookup('container_ports', + {'service': 'coturn', 'protocol': 'stun_turn', + 'transport': ['udp', 'tcp'], 'container': 'same'}, + {'service': 'coturn', 'protocol': 'relay', 'transport': 'udp', + 'container': 'same'}, + {'service': 'openresty', 'protocol': 'http', 'mode': 'host'}, + {'service': 'funkwhale', 'protocol': 'api', + 'publish': 'ephemeral'}) | indent(4) }} + +Keys: + +``service``, ``protocol`` + Required, as in the list form. +``ip`` + Host address to bind to; omit to publish on every interface. +``transport`` + ``tcp``, ``udp``, or a list of both. One list item per transport. Omit for + compose's own default, which publishes no suffix. +``container`` + ``internal`` (default) reads ``ports.internal.``; ``same`` reuses the + published port, for services that cannot remap (STUN/TURN, media relays). +``mode`` + ``ingress`` (default) emits the short ``"host:published:container"`` string; + ``host`` emits the long block with ``mode: host``, which is what an edge + role needs in swarm so the client address survives the hop. +``publish`` + ``ephemeral`` publishes the container port on a host port docker picks, for + a service addressed only through the compose network. + +A declared value that is a ``{start, end}`` mapping renders as ``start-end`` on +both sides, so a relay range needs no special term. + +``application_id`` is read from the play variables unless given as +``application_id=``. ``key='expose'`` names the block instead of ``ports``, for +Discourse's launcher file, whose ``expose:`` list is what becomes ``docker run +-p`` there. """ from __future__ import annotations import contextlib +from collections.abc import Mapping from typing import Any from ansible.errors import AnsibleError @@ -37,11 +71,115 @@ from ansible.template import trust_as_template from utils.roles.applications.config import get +_TERM_KEYS = frozenset( + {"service", "protocol", "ip", "transport", "container", "mode", "publish"} +) +_TRANSPORTS = ("tcp", "udp") +_CONTAINER_SOURCES = ("internal", "same") +_MODES = ("ingress", "host") + def _as_str(value: Any) -> str: return "" if value is None else str(value).strip() +def _rendered(value: Any) -> str: + """A declared port value as compose writes it, ranges included.""" + if isinstance(value, Mapping): + start, end = value.get("start"), value.get("end") + if start is None or end is None: + return "" + return f"{_as_str(start)}-{_as_str(end)}" + return _as_str(value) + + +def _transports_of(term: Mapping) -> list[str]: + declared = term.get("transport") + if declared is None: + return [""] + wanted = [declared] if isinstance(declared, str) else list(declared) + for transport in wanted: + if transport not in _TRANSPORTS: + raise AnsibleError( + f"container_ports: transport must be one of {_TRANSPORTS}, " + f"got {transport!r}" + ) + return wanted + + +def _normalised(term: Any) -> dict[str, Any]: + """One term as a dict, accepting the legacy list form.""" + if isinstance(term, (list, tuple)): + if len(term) not in (2, 3): + raise AnsibleError( + "container_ports: a list term must be [service_name, protocol] " + f"or [service_name, protocol, host_ip], got {term!r}" + ) + term = { + "service": term[0], + "protocol": term[1], + **({"ip": term[2]} if len(term) == 3 else {}), + } + if not isinstance(term, Mapping): + raise AnsibleError( + f"container_ports: each term must be a list or a mapping, got {term!r}" + ) + unknown = set(term) - _TERM_KEYS + if unknown: + raise AnsibleError( + f"container_ports: unknown term key(s) {sorted(unknown)}; " + f"valid keys are {sorted(_TERM_KEYS)}" + ) + normalised = { + "service": _as_str(term.get("service")), + "protocol": _as_str(term.get("protocol")), + "ip": _as_str(term.get("ip")), + "transport": term.get("transport"), + "container": _as_str(term.get("container")) or "internal", + "mode": _as_str(term.get("mode")) or "ingress", + "publish": _as_str(term.get("publish")), + } + if not normalised["service"] or not normalised["protocol"]: + raise AnsibleError("container_ports: service and protocol must be non-empty") + if normalised["container"] not in _CONTAINER_SOURCES: + raise AnsibleError( + f"container_ports: container must be one of {_CONTAINER_SOURCES}, " + f"got {normalised['container']!r}" + ) + if normalised["mode"] not in _MODES: + raise AnsibleError( + f"container_ports: mode must be one of {_MODES}, got {normalised['mode']!r}" + ) + if normalised["publish"] and normalised["publish"] != "ephemeral": + raise AnsibleError( + "container_ports: publish accepts only 'ephemeral', " + f"got {normalised['publish']!r}" + ) + if normalised["mode"] == "host" and normalised["ip"]: + raise AnsibleError( + "container_ports: mode 'host' binds the node's own interface and " + "takes no ip" + ) + return normalised + + +def _short_line(term: Mapping, published: str, container: str, transport: str) -> str: + suffix = f"/{transport}" if transport else "" + if term["publish"] == "ephemeral": + return f' - "{container}{suffix}"' + head = f"{term['ip']}:" if term["ip"] else "" + return f' - "{head}{published}:{container}{suffix}"' + + +def _host_lines(published: str, container: str, transport: str) -> list[str]: + return [ + f" - target: {container}", + f" published: {published}", + f" protocol: {transport or 'tcp'}", + " mode: host", + ] + + class LookupModule(LookupBase): def run( self, @@ -58,22 +196,9 @@ class LookupModule(LookupBase): if not terms: raise AnsibleError( "lookup('container_ports', [service_name, protocol[, host_ip]], ...) " - "expects one or more [service_name, protocol] terms" + "expects one or more terms" ) - triples: list[tuple[str, str, str]] = [] - for term in terms: - if not (isinstance(term, (list, tuple)) and len(term) in (2, 3)): - raise AnsibleError( - "container_ports: each term must be a [service_name, protocol] " - f"or [service_name, protocol, host_ip] list, got {term!r}" - ) - service, protocol = _as_str(term[0]), _as_str(term[1]) - host = _as_str(term[2]) if len(term) == 3 else "" - if not service or not protocol: - raise AnsibleError( - "container_ports: service_name and protocol must be non-empty" - ) - triples.append((service, protocol, host)) + parsed = [_normalised(term) for term in terms] templar = getattr(self, "_templar", None) variables = variables or getattr(self._templar, "available_variables", {}) or {} @@ -96,7 +221,7 @@ class LookupModule(LookupBase): ).run([], variables=variables)[0] def _port(service: str, scope: str, protocol: str, *, required: bool) -> str: - value = _as_str( + value = _rendered( get( applications=applications, application_id=application_id, @@ -112,14 +237,24 @@ class LookupModule(LookupBase): ) return value - lines = ["ports:"] - for service, protocol, host in triples: - internal = _port(service, "internal", protocol, required=True) - published = _port(service, "local", protocol, required=False) or _port( - service, "public", protocol, required=True + block_key = _as_str(kwargs.get("key")) or "ports" + if block_key not in ("ports", "expose"): + raise AnsibleError( + f"container_ports: key must be 'ports' or 'expose', got {block_key!r}" ) - if host: - lines.append(f' - "{host}:{published}:{internal}"') + lines = [f"{block_key}:"] + for term in parsed: + service, protocol = term["service"], term["protocol"] + published = _port(service, "local", protocol, required=False) or _port( + service, "public", protocol, required=term["publish"] != "ephemeral" + ) + if term["container"] == "same": + container = published else: - lines.append(f' - "{published}:{internal}"') + container = _port(service, "internal", protocol, required=True) + for transport in _transports_of(term): + if term["mode"] == "host": + lines.extend(_host_lines(published, container, transport)) + else: + lines.append(_short_line(term, published, container, transport)) return ["\n".join(lines)] diff --git a/plugins/lookup/role_subnets.py b/plugins/lookup/role_subnets.py new file mode 100644 index 0000000000..b3a06976a1 --- /dev/null +++ b/plugins/lookup/role_subnets.py @@ -0,0 +1,64 @@ +"""Lookup `role_subnets`: the local subnets other roles declare. + +Single SPOT for "which address block does role X own". The block is declared +once, in that role's ``meta/networks.yml``; anything that has to reason about +the deployment's own address space reads it from here instead of repeating the +literal, which is how a CIDR ends up stated twice and then drifts. + +Usage: + + {{ lookup('role_subnets', 'web-app-bigbluebutton') }} # ['10.7.7.0/24'] + {{ lookup('role_subnets', SOME_ROLE_LIST) }} # one entry per role + +Terms may be role ids or lists of them, mixed. A role that declares no local +subnet raises, because a silently dropped entry would narrow whatever firewall +or trust list consumes the result. +""" + +from __future__ import annotations + +from typing import Any + +from ansible.errors import AnsibleError +from ansible.plugins.loader import lookup_loader +from ansible.plugins.lookup import LookupBase + +_SUBNET_PATH = "networks.local.subnet" + + +def _flattened(terms: list[Any]) -> list[str]: + roles: list[str] = [] + for term in terms: + if isinstance(term, (list, tuple)): + roles.extend(str(entry).strip() for entry in term) + else: + roles.append(str(term).strip()) + return [role for role in roles if role] + + +class LookupModule(LookupBase): + def run( + self, + terms: list[Any] | None, + variables: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Any]: + roles = _flattened(terms or []) + if not roles: + raise AnsibleError( + "lookup('role_subnets', ) expects " + "at least one role id" + ) + config = lookup_loader.get( + "config", + loader=getattr(self, "_loader", None), + templar=getattr(self, "_templar", None), + ) + subnets: list[str] = [] + for role in roles: + value = config.run([role, _SUBNET_PATH], variables=variables)[0] + text = "" if value is None else str(value).strip() + if not text: + raise AnsibleError(f"role_subnets: '{role}' declares no {_SUBNET_PATH}") + subnets.append(text) + return [subnets] diff --git a/plugins/lookup/sso_oidc_plugin.py b/plugins/lookup/sso_oidc_plugin.py index 3e895dffc2..50c293009c 100644 --- a/plugins/lookup/sso_oidc_plugin.py +++ b/plugins/lookup/sso_oidc_plugin.py @@ -6,9 +6,6 @@ from ansible.errors import AnsibleError from ansible.plugins.loader import lookup_loader from ansible.plugins.lookup import LookupBase -from utils.roles.applications.config import get -from utils.roles.applications.services.sso import get_sso_config - _APPLICATION_ID = "web-app-nextcloud" @@ -40,33 +37,18 @@ class LookupModule(LookupBase): templar = getattr(self, "_templar", None) variables = variables or getattr(self._templar, "available_variables", {}) or {} - applications = lookup_loader.get( - "applications", loader=self._loader, templar=templar - ).run([], variables=variables)[0] + config = lookup_loader.get("config", loader=self._loader, templar=templar) - if not get_sso_config(applications, _APPLICATION_ID)["is_enabled"]: + def _setting(path: str, default: Any) -> Any: + return config.run([_APPLICATION_ID, path, default], variables=variables)[0] + + if not bool(_setting("services.sso.enabled", False)): return [""] - explicit = get( - applications=applications, - application_id=_APPLICATION_ID, - config_path="services.sso.oidc.plugin", - strict=False, - default=None, - skip_missing_app=True, - ) + explicit = _setting("services.sso.oidc.plugin", None) if isinstance(explicit, str) and explicit.strip(): return [explicit.strip()] - ldap_enabled = bool( - get( - applications=applications, - application_id=_APPLICATION_ID, - config_path="services.ldap.enabled", - strict=False, - default=False, - skip_missing_app=True, - ) - ) + ldap_enabled = bool(_setting("services.ldap.enabled", False)) return ["oidc_login" if ldap_enabled else "sociallogin"] diff --git a/plugins/lookup/tor_ports.py b/plugins/lookup/tor_ports.py index ec24f33741..fea32575f8 100644 --- a/plugins/lookup/tor_ports.py +++ b/plugins/lookup/tor_ports.py @@ -1,19 +1,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from pathlib import Path +from typing import Any from ansible.errors import AnsibleError from ansible.plugins.loader import lookup_loader from ansible.plugins.lookup import LookupBase -from utils.cache.base import _resolve_roles_dir -from utils.cache.yaml import load_yaml_any -from utils.roles.mapping import ROLE_FILE_META_SERVICES +UDP_ONLY_CATEGORIES = frozenset({"relay", "media", "stun_turn", "stun_turn_tls"}) +"""Categories a hidden service cannot carry. -_UDP_ONLY_CATEGORIES = frozenset({"relay", "media"}) +``relay`` and ``media`` are UDP. ``stun_turn`` and its TLS variant do speak TCP, +but the allocation they hand out is a UDP relay address the onion client can +never receive on, so forwarding the signalling port buys nothing. +""" def _as_bool(value: object) -> bool: @@ -24,6 +23,10 @@ def _as_bool(value: object) -> bool: return bool(value) +def _mapping(value: object) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + def _collect_local_tcp_ports(port_categories: Any, into: set[int]) -> None: """Add single-int TCP ports of the loopback-published ``ports.local`` group only. An exposed service publishes just its local plaintext port to @@ -36,37 +39,57 @@ def _collect_local_tcp_ports(port_categories: Any, into: set[int]) -> None: if not isinstance(local, dict): return for category, value in local.items(): - if category in _UDP_ONLY_CATEGORIES: + if category in UDP_ONLY_CATEGORIES: continue if isinstance(value, int): into.add(value) -def collect_public_ports( +def collect_onion_ports( + applications: dict[str, Any], deployed_roles: list[str], - roles_dir: Path, ) -> list[int]: - """Single-int TCP ``ports.public`` entries of the deployed roles, sorted.""" + """Single-int TCP ports a deployed role asks for by name under ``ports.onion``. + + ``ports.onion`` is a category-keyed map of booleans alongside ``internal`` / + ``local`` / ``public``: it names which of a service's already-declared ports + should answer on the node onion, and nothing else. The port number comes + from ``ports.local`` when that category is declared there and from + ``ports.public`` otherwise, which is the precedence ``container_ports`` + publishes by, so the forward always targets the port the service is actually + reachable on. + + Naming the category is what makes this safe. Sweeping ``ports.public`` + wholesale forwards ports the deployment never publishes -- ldaps 636 is + declared next to ``network.public: false``, and Mailu's implicit-TLS ports + are dropped from the publish list whenever TLS is off, which an onion + deployment always is. + """ ports: set[int] = set() - for role in deployed_roles: - services_yml = roles_dir / role / ROLE_FILE_META_SERVICES - if not services_yml.is_file(): + deployed = set(deployed_roles) + if not isinstance(applications, dict): + return [] + for app_id, cfg in applications.items(): + if app_id not in deployed: continue - data = load_yaml_any(str(services_yml), default_if_missing={}) - if not isinstance(data, dict): + services = (cfg or {}).get("services") if isinstance(cfg, dict) else None + if not isinstance(services, dict): continue - tor = data.get("tor") - if not (isinstance(tor, dict) and _as_bool(tor.get("enabled"))): - continue - for entity in data.values(): + for entity in services.values(): if not isinstance(entity, dict): continue - public = (entity.get("ports") or {}).get("public") - if not isinstance(public, dict): + declared = entity.get("ports") + if not isinstance(declared, dict): continue - for category, value in public.items(): - if category in _UDP_ONLY_CATEGORIES: + wanted = declared.get("onion") + if not isinstance(wanted, dict): + continue + local = _mapping(declared.get("local")) + public = _mapping(declared.get("public")) + for category, flag in wanted.items(): + if category in UDP_ONLY_CATEGORIES or not _as_bool(flag): continue + value = local.get(category, public.get(category)) if isinstance(value, int): ports.add(value) return sorted(ports) @@ -111,16 +134,18 @@ class LookupModule(LookupBase): Returns the HiddenServicePort mappings for the roles in the current deploy (``group_names``): a list of ``{'onion_port': , 'target': - '127.0.0.1:'}`` dicts, sorted by port. Two sources are unioned: + '127.0.0.1:'}`` dicts, sorted by port. Two opt-ins are unioned: - * every ``ports.public`` TCP port of tor-enabled roles, and - * every port of a service that opts in with ``exposed: true`` (resolved - against the variant-merged applications view, so per-variant). + * every port a service names under ``ports.onion`` in meta/services.yml, + and + * every port of a service that opts in with ``exposed: true``. - Public/exposed ports bind the host interface, so the loopback target reaches - them from svc-net-tor's host-network container. Consumed by - ``TOR_ONION_EXTRA_PORTS`` (group_vars/all/19_tor.yml) and rendered as - ``HiddenServicePort`` lines in svc-net-tor's torrc. + Both are read from the variant-merged applications view, so a variant that + drops the opt-in drops the forward with it. The published port binds the + host interface, so the loopback target reaches it from svc-net-tor's + host-network container. Composed with the flagged forwards by + ``lookup('tor_extra_ports')`` and rendered as ``HiddenServicePort`` lines in + svc-net-tor's torrc. """ def run( @@ -138,17 +163,15 @@ class LookupModule(LookupBase): group_names = [] deployed = [str(g) for g in group_names] - roles_dir = _resolve_roles_dir(roles_dir=kwargs.get("roles_dir")) - ports = set(collect_public_ports(deployed, roles_dir)) - try: applications = lookup_loader.get( "applications", loader=getattr(self, "_loader", None), templar=getattr(self, "_templar", None), ).run([], variables=variables, roles_dir=kwargs.get("roles_dir"))[0] - except Exception: # noqa: BLE001 no merged view -> exposed ports simply unavailable + except Exception: # noqa: BLE001 no merged view -> no ports to forward applications = {} + ports = set(collect_onion_ports(applications, deployed)) ports.update(collect_exposed_ports(applications, deployed)) return [ diff --git a/pyproject.toml b/pyproject.toml index 606ff87fab..9a8f0df95c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "infinito-nexus" -version = "13.0.0" +version = "14.2.0" description = "Infinito.Nexus" readme = "README.md" requires-python = ">=3.11" @@ -33,7 +33,7 @@ deploy = [ "requests", ] dev = [ - "ruff==0.16.6", + "ruff==0.16.7", "import-linter", "pre-commit", "git-maintainer-tools", diff --git a/requirements/requirements.galaxy.yml b/requirements/requirements.galaxy.yml index 33ae9d1493..30d8759605 100644 --- a/requirements/requirements.galaxy.yml +++ b/requirements/requirements.galaxy.yml @@ -1,9 +1,16 @@ --- collections: - name: ansible.posix - - name: kewlfft.aur - - name: community.general - - name: community.docker - - name: hetzner.hcloud - - name: ansible.posix + version: 2.2.2 - name: ansible.mysql + version: 5.2.0 + - name: kewlfft.aur + version: 0.13.0 + - name: community.general + version: 13.4.0 + - name: community.docker + version: 5.3.0 + - name: community.library_inventory_filtering_v1 + version: 1.1.5 + - name: hetzner.hcloud + version: 7.1.0 diff --git a/requirements/requirements.git.yml b/requirements/requirements.git.yml index 31ddb7279d..fbc7e57776 100644 --- a/requirements/requirements.git.yml +++ b/requirements/requirements.git.yml @@ -3,34 +3,34 @@ collections: - name: ansible.posix source: https://github.com/ansible-collections/ansible.posix.git type: git - version: main + version: 2.2.2 - name: ansible.mysql source: https://github.com/ansible-collections/ansible.mysql.git type: git - version: main + version: 5.2.0 - name: kewlfft.aur source: https://github.com/kewlfft/ansible-aur.git type: git - version: master + version: 7affd00193b03bf820ebcd8b13dfa81aab59803e - name: community.general source: https://github.com/ansible-collections/community.general.git type: git - version: main + version: 13.4.0 - name: community.docker source: https://github.com/ansible-collections/community.docker.git type: git - version: main + version: 5.3.0 - name: community.library_inventory_filtering_v1 source: https://github.com/ansible-collections/community.library_inventory_filtering.git type: git - version: stable-1 + version: 1.1.5 - name: hetzner.hcloud source: https://github.com/ansible-collections/hetzner.hcloud.git type: git - version: main + version: 7.1.0 diff --git a/roles/desk-ssh/tasks/00_core.yml b/roles/desk-ssh/tasks/00_core.yml index 8996de8785..a6c37710c1 100644 --- a/roles/desk-ssh/tasks/00_core.yml +++ b/roles/desk-ssh/tasks/00_core.yml @@ -21,26 +21,49 @@ - name: ✅ Ensure systemd user directory exists file: - path: "$HOME/.config/systemd/user" + path: "{{ DIR_SYSTEM_SERVICE_USER }}" state: directory mode: "0700" become: false -- name: 🚀 Deploy ssh-agent systemd unit file - template: # nocheck: stack-host-template (per-host/user workstation systemd unit) - src: ssh-agent.service.j2 - dest: "$HOME/.config/systemd/user/ssh-agent.service" - mode: "0644" +- name: 🔍 Look for the unversioned ssh-agent unit + stat: + path: "{{ desk_ssh_legacy_unit }}" + register: desk_ssh_legacy_unit_state become: false -- name: 🟢 Enable and start ssh-agent service - systemd: - name: ssh-agent.service - scope: user - enabled: true - state: started - daemon_reload: true - become: false +- name: 🧹 Retire the unversioned ssh-agent unit + block: + - name: 🛑 Stop and disable the unversioned ssh-agent unit + systemd: + name: "{{ desk_ssh_legacy_unit | basename }}" + scope: user + state: stopped + enabled: false + become: false + + - name: 🗑️ Delete the unversioned ssh-agent unit + file: + path: "{{ desk_ssh_legacy_unit }}" + state: absent + become: false + when: desk_ssh_legacy_unit_state.stat.exists + +- name: 🚀 Deploy ssh-agent systemd unit file + include_role: + name: sys-service + vars: + system_service_scope: user + system_service_copy_files: false + system_service_state: started + system_service_tpl_on_failure: "" + system_service_tpl_type: simple + system_service_tpl_runtime: "" + system_service_tpl_before: default.target + system_service_tpl_wanted_by: default.target + system_service_tpl_environment: + - "SSH_AUTH_SOCK=%t/ssh-agent.socket" + system_service_tpl_exec_start: "/usr/bin/ssh-agent -D -a $SSH_AUTH_SOCK" - name: ✅ Ensure ~/.profile exists with common environment lineinfile: diff --git a/roles/desk-ssh/templates/ssh-agent.service.j2 b/roles/desk-ssh/templates/ssh-agent.service.j2 deleted file mode 100644 index 3125724f18..0000000000 --- a/roles/desk-ssh/templates/ssh-agent.service.j2 +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=User SSH Agent -Before=default.target - -[Service] -Type=simple -Environment=SSH_AUTH_SOCK=%t/ssh-agent.socket -ExecStart=/usr/bin/ssh-agent -D -a $SSH_AUTH_SOCK - -[Install] -WantedBy=default.target \ No newline at end of file diff --git a/roles/desk-ssh/vars/main.yml b/roles/desk-ssh/vars/main.yml index 7d866e2329..d8bb7879e0 100644 --- a/roles/desk-ssh/vars/main.yml +++ b/roles/desk-ssh/vars/main.yml @@ -1,3 +1,5 @@ --- application_id: desk-ssh +system_service_id: "{{ application_id }}" desk_ssh_repository: "{{ lookup('config', application_id, 'services.ssh.repository') }}" +desk_ssh_legacy_unit: "{{ [ DIR_SYSTEM_SERVICE_USER, 'ssh-agent.service' ] | path_join }}" diff --git a/roles/svc-ai-litellm/README.md b/roles/svc-ai-litellm/README.md index a87bcb9b7b..a975a2d0b9 100644 --- a/roles/svc-ai-litellm/README.md +++ b/roles/svc-ai-litellm/README.md @@ -6,7 +6,7 @@ ## Overview -This role deploys LiteLLM as a shared gateway container in Compose and Swarm deployments, backed by the central PostgreSQL service. The gateway is headless: it binds its HTTP port on the container host and claims no domain of its own, and the browser-facing surface is published separately by the LiteLLM Admin UI. It renders the model list from the interchangeable local backends available on the host, Ollama and LM Studio, and adds an OpenRouter entry when an OpenRouter API key is configured. Once the gateway is up, it provisions one virtual key per consuming application through the gateway admin API. +This role deploys LiteLLM as a shared gateway container in Compose and Swarm deployments, backed by the central PostgreSQL service. The gateway is headless: it binds its HTTP port on the container host and claims no domain of its own, and the browser-facing surface is published separately by the LiteLLM Admin UI. It renders the model list from the interchangeable local backends available on the host, Ollama and LM Studio, and adds the OpenAI, Anthropic and OpenRouter entries whose API key is configured. Once the gateway is up, it provisions one virtual key per consuming application through the gateway admin API. ## Cosmos @@ -63,7 +63,8 @@ Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional ( ## Features - **OpenAI-compatible API:** One HTTP endpoint serves every model listed in the gateway configuration. -- **Backend routing:** Model entries are generated for Ollama and LM Studio when those services run on the host, and for OpenRouter when its API key is set. +- **Backend routing:** Model entries are generated for Ollama and LM Studio when those services run on the host, under the shared alias each model carries so a consumer names one model whichever local backend answers it. +- **Remote providers:** OpenAI, Anthropic and OpenRouter each add their models when a key is configured. The keys are declared in `meta/secrets.yml` with the `type` and `regex` their value must satisfy, and default to the central `API..api_key` entry; an unset key leaves that provider's routes unpublished. - **Per-consumer virtual keys:** Each consuming application receives its own virtual key, created through the gateway admin API under an alias naming that application. - **File-based model list:** The model list is mounted as a read-only config file, with database-stored model entries turned off. - **Managed credentials:** The gateway master key and the admin UI password are generated and kept as role credentials, and the admin UI username is the platform administrator name. diff --git a/roles/svc-ai-litellm/files/test/.nocheck b/roles/svc-ai-litellm/files/test/.nocheck new file mode 100644 index 0000000000..2b9d82d840 --- /dev/null +++ b/roles/svc-ai-litellm/files/test/.nocheck @@ -0,0 +1 @@ +# nocheck: role-files-layout - Role test harness with test.sh as its entry point; test-e2e-cli stages files/test/ by that fixed path. diff --git a/roles/svc-ai-litellm/files/test/probe.py b/roles/svc-ai-litellm/files/test/probe.py new file mode 100644 index 0000000000..f6ad07ce32 --- /dev/null +++ b/roles/svc-ai-litellm/files/test/probe.py @@ -0,0 +1,190 @@ +"""Assert the gateway routes to exactly the backends this deployment enabled. + +Runs inside the litellm container, where the gateway answers on loopback and +the backend containers resolve by name. Standard library only: the image ships +the litellm app, not a test toolchain. + +Env (rendered into test.env from templates/test.env.j2): + PORT gateway http port inside the container + MASTER_KEY gateway master key + CHAT_MODEL the model every consumer asks for (LITELLM_CHAT_MODEL) + CHAT_MODEL_SERVED true|false, whether a backend can answer it + EXPECTED_MODELS JSON list the config template published + LMSTUDIO_ALIASES JSON list of aliases only LM Studio provides, rendered + whether or not the backend is deployed so the absent + case stays assertable + REMOTE_ALIASES JSON list of aliases a configured provider key publishes + (OpenAI, Anthropic, OpenRouter) + OLLAMA_ENABLED true|false + LMSTUDIO_ENABLED true|false + RETRIES completion attempts (default 10) + SLEEP_SECONDS wait between attempts (default 6) +""" + +from __future__ import annotations + +import json +import os +import sys +import time +import urllib.error +import urllib.request + + +def evaluate( + *, + served: set[str], + expected: list[str], + chat_model: str, + chat_model_served: bool, + ollama_enabled: bool, + lmstudio_enabled: bool, + lmstudio_aliases: list[str], + remote_aliases: list[str], +) -> list[str]: + """Every routing violation the published model list shows, as messages. + + Args: + served: model ids the gateway answers ``/v1/models`` with. + expected: model ids the rendered config declared. + chat_model: the id consumers are configured to ask for. + chat_model_served: whether any backend can answer *chat_model*. + ollama_enabled: svc-ai-ollama is deployed on the gateway's host. + lmstudio_enabled: svc-ai-lmstudio is deployed on the gateway's host. + lmstudio_aliases: aliases only LM Studio provides, whatever is deployed. + remote_aliases: aliases a configured remote provider key publishes. + + Returns: + One message per violation; empty when the routing is consistent. + """ + failures: list[str] = [] + + undelivered = sorted(set(expected) - served) + if undelivered: + failures.append( + f"the config declares {undelivered} but the gateway does not serve " + f"them; the rendered config.yaml and the running gateway disagree" + ) + + exclusive = set(lmstudio_aliases) + if lmstudio_enabled and not exclusive <= served: + failures.append( + f"svc-ai-lmstudio is deployed but the gateway does not serve " + f"{sorted(exclusive - served)}; its models were declared and not routed" + ) + if not lmstudio_enabled and exclusive & served: + failures.append( + f"svc-ai-lmstudio is not deployed yet the gateway publishes " + f"{sorted(exclusive & served)}; a route stands without its backend" + ) + + unbacked = sorted(served - exclusive - set(remote_aliases)) + if not ollama_enabled and unbacked: + failures.append( + f"svc-ai-ollama is not deployed yet the gateway publishes {unbacked}" + ) + + if not chat_model_served: + if served: + failures.append( + f"no backend is deployed but the gateway publishes {sorted(served)}" + ) + return failures + + if chat_model not in served: + failures.append( + f"consumers are configured for '{chat_model}' (LITELLM_CHAT_MODEL, " + f"built from the deployment inventory) but the gateway serves " + f"{sorted(served)} (built from this host's group_names); the two " + f"sources disagree" + ) + + return failures + + +def _call(base: str, key: str, path: str, payload: dict | None = None): + body = None if payload is None else json.dumps(payload).encode() + request = urllib.request.Request( # noqa: S310 - literal http:// to loopback + base + path, + data=body, + headers={ + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen( # noqa: S310 - literal http:// to loopback + request, timeout=120 + ) as response: + return json.loads(response.read().decode() or "{}") + + +def _completion(base: str, key: str, model: str, retries: int, pause: float) -> str: + last_error = "no attempt ran" + for attempt in range(1, retries + 1): + try: + completion = _call( + base, + key, + "/v1/chat/completions", + { + "model": model, + "messages": [ + {"role": "user", "content": "Reply with the word pong."} + ], + "max_tokens": 16, + }, + ) + choices = completion.get("choices") or [{}] + answer = (choices[0].get("message", {}).get("content") or "").strip() + if answer: + return answer + last_error = "empty content" + except (urllib.error.URLError, TimeoutError, ValueError) as exc: + last_error = f"{type(exc).__name__}: {exc}" + if attempt < retries: + time.sleep(pause) + raise RuntimeError(last_error) + + +def main() -> int: + key = os.environ["MASTER_KEY"] + base = f"http://127.0.0.1:{os.environ['PORT']}" + chat_model = os.environ["CHAT_MODEL"].strip() + chat_model_served = os.environ["CHAT_MODEL_SERVED"] == "true" + retries = int(os.environ.get("RETRIES", "10")) + pause = float(os.environ.get("SLEEP_SECONDS", "6")) + + listing = _call(base, key, "/v1/models") + served = {entry.get("id") for entry in listing.get("data", [])} + print(f"[INFO] gateway answered /v1/models, serving {sorted(served)}") + + failures = evaluate( + served=served, + expected=json.loads(os.environ["EXPECTED_MODELS"]), + chat_model=chat_model, + chat_model_served=chat_model_served, + ollama_enabled=os.environ["OLLAMA_ENABLED"] == "true", + lmstudio_enabled=os.environ["LMSTUDIO_ENABLED"] == "true", + lmstudio_aliases=json.loads(os.environ["LMSTUDIO_ALIASES"]), + remote_aliases=json.loads(os.environ["REMOTE_ALIASES"]), + ) + + if chat_model_served and not failures: + try: + answer = _completion(base, key, chat_model, retries, pause) + print(f"[OK] '{chat_model}' answered: {answer[:60]}") + except RuntimeError as exc: + failures.append( + f"'{chat_model}' is published but answered nothing after " + f"{retries} attempts; last failure: {exc}" + ) + + for failure in failures: + print(f"[FAIL] {failure}", file=sys.stderr) + if not failures: + print("[OK] the published routes match the deployed backends") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/roles/svc-ai-litellm/files/test/test.sh b/roles/svc-ai-litellm/files/test/test.sh new file mode 100644 index 0000000000..f8e8a895f1 --- /dev/null +++ b/roles/svc-ai-litellm/files/test/test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# svc-ai-litellm routing test. Runs probe.py inside the gateway container, where +# the API answers on loopback and the backends resolve by container name, and +# asserts the published model list matches the backends this deployment enabled. +# +# Env (rendered into test.env from templates/test.env.j2): +# LITELLM_CONTAINER resolved gateway container (CLI_LOCAL_CID) +# LITELLM_PORT gateway http port inside the container +# LITELLM_MASTER_KEY_B64 base64 master key (avoids shell-quoting issues) +# LITELLM_CHAT_MODEL the model every consumer asks for +# LITELLM_CHAT_MODEL_SERVED true|false +# LITELLM_EXPECTED_MODELS JSON list the config template published +# LITELLM_LMSTUDIO_ALIASES JSON list of aliases only LM Studio provides +# LITELLM_REMOTE_ALIASES JSON list of aliases a configured provider key adds +# LITELLM_OLLAMA_ENABLED true|false +# LITELLM_LMSTUDIO_ENABLED true|false +# READY_RETRIES gateway readiness attempts (default 30) +# READY_SLEEP_SECONDS wait between attempts (default 5) + +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +READY_RETRIES="${READY_RETRIES:-30}" +READY_SLEEP_SECONDS="${READY_SLEEP_SECONDS:-5}" + +MASTER_KEY="$(printf '%s' "${LITELLM_MASTER_KEY_B64}" | base64 -d 2>/dev/null)" +[ -n "${MASTER_KEY}" ] || { + echo "[FATAL] LITELLM_MASTER_KEY_B64 missing or undecodable" >&2 + exit 2 +} +[ -n "${LITELLM_CONTAINER}" ] || { + echo "[FATAL] LITELLM_CONTAINER unset; the gateway container was not resolved" >&2 + exit 2 +} + +# The image ships the litellm app, so a python interpreter is present, but the +# upstream tag has changed which of the two names is on PATH. +interpreter() { + # nocheck: container-exec-resolver address resolved by the caller and passed in + if container exec -i "${LITELLM_CONTAINER}" python3 -c '' 2>/dev/null; then + echo python3 + else + echo python + fi +} + +probe() { + # nocheck: container-exec-resolver address resolved by the caller and passed in + container exec -i \ + -e "PORT=${LITELLM_PORT}" \ + -e "MASTER_KEY=${MASTER_KEY}" \ + -e "CHAT_MODEL=${LITELLM_CHAT_MODEL}" \ + -e "CHAT_MODEL_SERVED=${LITELLM_CHAT_MODEL_SERVED}" \ + -e "EXPECTED_MODELS=${LITELLM_EXPECTED_MODELS}" \ + -e "LMSTUDIO_ALIASES=${LITELLM_LMSTUDIO_ALIASES}" \ + -e "REMOTE_ALIASES=${LITELLM_REMOTE_ALIASES}" \ + -e "OLLAMA_ENABLED=${LITELLM_OLLAMA_ENABLED}" \ + -e "LMSTUDIO_ENABLED=${LITELLM_LMSTUDIO_ENABLED}" \ + "${LITELLM_CONTAINER}" "$(interpreter)" - <"${here}/probe.py" +} + +attempt=1 +while [ "${attempt}" -le "${READY_RETRIES}" ]; do + output="$(probe 2>&1)" + rc=$? + case "${output}" in + *"gateway answered /v1/models"*) + echo "${output}" + exit "${rc}" + ;; + esac + attempt=$((attempt + 1)) + [ "${attempt}" -le "${READY_RETRIES}" ] && sleep "${READY_SLEEP_SECONDS}" +done + +echo "[FAIL] the gateway never answered /v1/models after ${READY_RETRIES} attempts" >&2 +echo "${output}" >&2 +exit 1 diff --git a/roles/svc-ai-litellm/meta/secrets.yml b/roles/svc-ai-litellm/meta/secrets.yml index 3a0c374abc..a2fb9ce606 100644 --- a/roles/svc-ai-litellm/meta/secrets.yml +++ b/roles/svc-ai-litellm/meta/secrets.yml @@ -10,3 +10,19 @@ credentials: algorithm: "alphanumeric" validation: min_length: 32 + openai_api_key: + description: "OpenAI API key; empty leaves the openai/* models unpublished. The pattern rejects a truncated or whitespace-carrying paste, not a vendor prefix: + OpenAI moved from sk- to sk-proj- without notice, and a schema that pinned the prefix would reject a valid key" + type: "string" + regex: "\\S{20,}" + default: "{{ lookup('api', 'openai.api_key') }}" + anthropic_api_key: + description: "Anthropic API key; empty leaves the anthropic/* models unpublished" + type: "string" + regex: "\\S{20,}" + default: "{{ lookup('api', 'anthropic.api_key') }}" + openrouter_api_key: + description: "OpenRouter API key; empty leaves openrouter/auto unpublished" + type: "string" + regex: "\\S{20,}" + default: "{{ lookup('api', 'openrouter.api_key') }}" diff --git a/roles/svc-ai-litellm/meta/services.yml b/roles/svc-ai-litellm/meta/services.yml index 39c3430c6e..663f5e67c0 100644 --- a/roles/svc-ai-litellm/meta/services.yml +++ b/roles/svc-ai-litellm/meta/services.yml @@ -11,7 +11,7 @@ litellm: backup: no_stop_required: true image: ghcr.io/berriai/litellm - version: main-v1.77.3.dynamic_rates + version: main-v1.77.7-stable name: litellm max_output_tokens: 512 upstream_timeout: 240 @@ -42,6 +42,6 @@ ollama: # nocheck: playwright-service-flag, dynamic-flag lmstudio: - bond: 1 + bond: 0.5 enabled: "{{ 'svc-ai-lmstudio' in group_names }}" shared: "{{ 'svc-ai-lmstudio' in group_names }}" diff --git a/roles/svc-ai-litellm/meta/tests.yml b/roles/svc-ai-litellm/meta/tests.yml new file mode 100644 index 0000000000..89594be937 --- /dev/null +++ b/roles/svc-ai-litellm/meta/tests.yml @@ -0,0 +1,4 @@ +--- +cli: + timeout: 600 + container_service_key: litellm diff --git a/roles/svc-ai-litellm/meta/variants.yml b/roles/svc-ai-litellm/meta/variants.yml index be12f8165f..8ae1ddd8aa 100644 --- a/roles/svc-ai-litellm/meta/variants.yml +++ b/roles/svc-ai-litellm/meta/variants.yml @@ -7,8 +7,8 @@ enabled: true shared: true lmstudio: - enabled: true - shared: true + enabled: false + shared: false postgres: shared: true @@ -21,3 +21,14 @@ lmstudio: enabled: false shared: false # nocheck: variants-static-override backup shared stays literally true + +- services: + ollama: + enabled: false + shared: false + lmstudio: + bond: 0.5 + enabled: true + shared: true + postgres: + shared: true diff --git a/roles/svc-ai-litellm/tasks/00_core.yml b/roles/svc-ai-litellm/tasks/00_core.yml index db70913e93..9076c123b0 100644 --- a/roles/svc-ai-litellm/tasks/00_core.yml +++ b/roles/svc-ai-litellm/tasks/00_core.yml @@ -21,6 +21,15 @@ - src: "config.yaml.j2" dest: "{{ LITELLM_CONFIG_PATH_HOST }}" +- name: "🧮 Freeze the routing contract the CLI test asserts against" + ansible.builtin.set_fact: + LITELLM_TEST_CONTRACT: + ollama: "{{ LITELLM_OLLAMA_BACKEND | bool }}" + lmstudio: "{{ LITELLM_LMSTUDIO_BACKEND | bool }}" + served: "{{ LITELLM_CHAT_MODEL_SERVED | bool }}" + delegate_to: "{{ STACK_HOST }}" + run_once: true + - name: "🔧 Setup container network for {{ application_id }}" include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/network/routine.yml') }}" vars: diff --git a/roles/svc-ai-litellm/templates/config.yaml.j2 b/roles/svc-ai-litellm/templates/config.yaml.j2 index 1b55fbc39e..c4bed5dcce 100644 --- a/roles/svc-ai-litellm/templates/config.yaml.j2 +++ b/roles/svc-ai-litellm/templates/config.yaml.j2 @@ -1,31 +1,35 @@ -{% set _ollama_models = lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') if 'svc-ai-ollama' in group_names else [] %} -{% set has_backend = _ollama_models or ('svc-ai-lmstudio' in group_names) or LITELLM_OPENROUTER_API_KEY %} +{% set _ollama_models = lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') if LITELLM_OLLAMA_BACKEND | bool else [] %} +{% set _lmstudio_models = lookup('config', 'svc-ai-lmstudio', 'services.lmstudio.preload_models') if LITELLM_LMSTUDIO_BACKEND | bool else [] %} +{% set _remote_models = LITELLM_REMOTE_BACKENDS | selectattr('key') | list %} +{% set has_backend = _ollama_models or _lmstudio_models or _remote_models %} model_list:{{ ' []' if not has_backend else '' }} {% for _model in _ollama_models %} - - model_name: "{{ _model }}" + - model_name: "{{ _model.alias }}" litellm_params: - model: "ollama/{{ _model }}" + model: "ollama/{{ _model.alias }}" api_base: {{ OLLAMA_BASE_LOCAL_URL }} rpm: 1000 max_tokens: {{ LITELLM_MAX_OUTPUT_TOKENS }} timeout: {{ LITELLM_UPSTREAM_TIMEOUT }} {% endfor %} -{% if 'svc-ai-lmstudio' in group_names %} - - model_name: lmstudio/default +{% for _model in _lmstudio_models if _model.alias not in (_ollama_models | map(attribute='alias') | list) %} + - model_name: "{{ _model.alias }}" litellm_params: - model: openai/local-model + model: "openai/{{ _model.name }}" api_base: {{ LMSTUDIO_BASE_LOCAL_URL }}/v1 api_key: lm-studio rpm: 1000 max_tokens: {{ LITELLM_MAX_OUTPUT_TOKENS }} timeout: {{ LITELLM_UPSTREAM_TIMEOUT }} -{% endif %} -{% if LITELLM_OPENROUTER_API_KEY %} - - model_name: openrouter/auto +{% endfor %} +{% for _backend in _remote_models %} + - model_name: "{{ _backend.alias }}" litellm_params: - model: openrouter/openrouter/auto - api_key: os.environ/OPENROUTER_API_KEY -{% endif %} + model: "{{ _backend.model }}" + api_key: os.environ/{{ _backend.env }} + max_tokens: {{ LITELLM_MAX_OUTPUT_TOKENS }} + timeout: {{ LITELLM_UPSTREAM_TIMEOUT }} +{% endfor %} litellm_settings: drop_params: true diff --git a/roles/svc-ai-litellm/templates/env.j2 b/roles/svc-ai-litellm/templates/env.j2 index c435fd3aa7..e77c43da8e 100644 --- a/roles/svc-ai-litellm/templates/env.j2 +++ b/roles/svc-ai-litellm/templates/env.j2 @@ -5,9 +5,9 @@ DATABASE_URL={{ lookup('database', application_id, 'url_full') | dotenv_quote }} STORE_MODEL_IN_DB=False UI_USERNAME={{ lookup('users', 'administrator').username }} UI_PASSWORD={{ lookup('config', application_id, 'secrets.credentials.ui_password') | dotenv_quote }} -{% if LITELLM_OPENROUTER_API_KEY %} -OPENROUTER_API_KEY={{ LITELLM_OPENROUTER_API_KEY | dotenv_quote }} -{% endif %} +{% for _backend in LITELLM_REMOTE_BACKENDS if _backend.key %} +{{ _backend.env }}={{ _backend.key | dotenv_quote }} +{% endfor %} {% if LITELLM_UI_SSO | bool %} PROXY_BASE_URL={{ LITELLM_UI_BASE_URL }} PROXY_ADMIN_ID={{ LITELLM_UI_ADMIN }} diff --git a/roles/svc-ai-litellm/templates/test.env.j2 b/roles/svc-ai-litellm/templates/test.env.j2 new file mode 100644 index 0000000000..a3a33b2cc6 --- /dev/null +++ b/roles/svc-ai-litellm/templates/test.env.j2 @@ -0,0 +1,15 @@ +{%- set _ollama_models = (lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') | map(attribute='alias') | list) if LITELLM_TEST_CONTRACT.ollama | bool else [] -%} +{%- set _lmstudio_declared = lookup('config', 'svc-ai-lmstudio', 'services.lmstudio.preload_models') | map(attribute='alias') | list -%} +{%- set _lmstudio_only = _lmstudio_declared | reject('in', _ollama_models) | list -%} +{%- set _remote = LITELLM_REMOTE_BACKENDS | selectattr('key') | map(attribute='alias') | list -%} +{%- set _expected = _ollama_models + (_lmstudio_only if LITELLM_TEST_CONTRACT.lmstudio | bool else []) + _remote -%} +LITELLM_CONTAINER={{ CLI_LOCAL_CID }} +LITELLM_PORT={{ LITELLM_PORT }} +LITELLM_MASTER_KEY_B64={{ LITELLM_MASTER_KEY | b64encode }} +LITELLM_CHAT_MODEL={{ LITELLM_CHAT_MODEL }} +LITELLM_CHAT_MODEL_SERVED={{ LITELLM_TEST_CONTRACT.served | bool | string | lower }} +LITELLM_OLLAMA_ENABLED={{ LITELLM_TEST_CONTRACT.ollama | bool | string | lower }} +LITELLM_LMSTUDIO_ENABLED={{ LITELLM_TEST_CONTRACT.lmstudio | bool | string | lower }} +LITELLM_LMSTUDIO_ALIASES='{{ _lmstudio_only | to_json }}' +LITELLM_REMOTE_ALIASES='{{ _remote | to_json }}' +LITELLM_EXPECTED_MODELS='{{ _expected | to_json }}' diff --git a/roles/svc-ai-litellm/vars/main.yml b/roles/svc-ai-litellm/vars/main.yml index 8d9da03e66..a2cbbf9cef 100644 --- a/roles/svc-ai-litellm/vars/main.yml +++ b/roles/svc-ai-litellm/vars/main.yml @@ -9,7 +9,22 @@ LITELLM_MAX_OUTPUT_TOKENS: "{{ lookup('config', application_id, 'services.litell LITELLM_UPSTREAM_TIMEOUT: "{{ lookup('config', application_id, 'services.litellm.upstream_timeout') }}" LITELLM_CONFIG_PATH_HOST: "{{ [ lookup('container', application_id, 'directories.config'), 'config.yaml' ] | path_join }}" LITELLM_CONFIG_PATH_DOCKER: "{{ lookup('volume', application_id, 'litellm_config').target }}" -LITELLM_OPENROUTER_API_KEY: "{{ OPENROUTER_API_KEY }}" +LITELLM_OPENAI_API_KEY: "{{ lookup('config', application_id, 'secrets.credentials.openai_api_key') }}" +LITELLM_ANTHROPIC_API_KEY: "{{ lookup('config', application_id, 'secrets.credentials.anthropic_api_key') }}" +LITELLM_OPENROUTER_API_KEY: "{{ lookup('config', application_id, 'secrets.credentials.openrouter_api_key') }}" +LITELLM_REMOTE_BACKENDS: + - alias: openai/gpt-4o-mini + model: openai/gpt-4o-mini + key: "{{ LITELLM_OPENAI_API_KEY }}" + env: OPENAI_API_KEY + - alias: anthropic/claude-sonnet-4-5 + model: anthropic/claude-sonnet-4-5 + key: "{{ LITELLM_ANTHROPIC_API_KEY }}" + env: ANTHROPIC_API_KEY + - alias: openrouter/auto + model: openrouter/openrouter/auto + key: "{{ LITELLM_OPENROUTER_API_KEY }}" + env: OPENROUTER_API_KEY LITELLM_UI_ROLE: "web-app-litellm" LITELLM_UI_SSO: "{{ (LITELLM_UI_ROLE in group_names) and (lookup('sso', LITELLM_UI_ROLE, 'is_oidc_native') | bool) }}" LITELLM_UI_BASE_URL: "{{ lookup('tls', LITELLM_UI_ROLE, 'url.base') }}" diff --git a/roles/svc-ai-lmstudio/README.md b/roles/svc-ai-lmstudio/README.md index 4e311ba7c7..bb671103ee 100644 --- a/roles/svc-ai-lmstudio/README.md +++ b/roles/svc-ai-lmstudio/README.md @@ -6,7 +6,7 @@ ## Overview -This role deploys LM Studio as a headless model server in a single container, in both Docker Compose and Docker Swarm deployments. The server listens on port 1234 on the internal container network and is not published through the reverse proxy, while downloaded models and server settings persist in a dedicated volume. Deployed together with the LiteLLM Gateway, the instance is reachable through the gateway under the model alias `lmstudio/default`, next to or instead of the Ollama backend. +This role deploys LM Studio as a headless model server in a single container, in both Docker Compose and Docker Swarm deployments. The server listens on port 1234 on the internal container network and is not published through the reverse proxy, while downloaded models and server settings persist in a dedicated volume. The role downloads its declared models at deploy time and the LiteLLM Gateway publishes them under the same aliases Ollama uses, so a consumer asks for one model name and reaches whichever backend the deployment provides. ## Cosmos @@ -35,7 +35,9 @@ Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional ( - **OpenAI-compatible endpoint:** The headless server answers `/v1` requests on port 1234 inside the container network. - **CPU inference:** The role pins the CPU build of the upstream image and passes no GPU device into the container. - **Persistent model store:** A named volume mounted at `/root/.lmstudio` keeps downloaded models and server settings across redeploys. -- **Gateway backend:** The LiteLLM Gateway routes the model alias `lmstudio/default` to this instance when both roles are deployed together, as one of the interchangeable local backends alongside Ollama. +- **Preloaded models:** Every entry of `services.lmstudio.preload_models` is downloaded on the hosting node with `lms get --gguf`, overlapped and reaped like the Ollama pre-pull. Each entry carries the `source` repository to download, the `name` LM Studio indexes that repository under and the gateway addresses it by, and the `alias` the gateway publishes it as. `source` is a full Hugging Face URL, never a search term: a search term resolves to whichever model the catalogue lists first. +- **Loading:** The pre-pull downloads without loading. LM Studio serves with just-in-time loading, so the first `/v1/chat/completions` naming a `name` loads that model; the gateway's warm task issues that first request at deploy time. +- **Gateway backend:** The LiteLLM Gateway publishes those aliases, which are the names Ollama serves too, so the same model name routes to whichever local backend a deployment runs. - **Bounded resources:** The container is capped at 4 CPUs, 8 GB of memory and 2048 processes, and the role declares a minimum of 588 MB free storage for the image; downloaded models grow the volume beyond that. - **Backup integration:** Backup Docker Volumes snapshots the model volume when that role is present, without stopping the container. diff --git a/roles/svc-ai-lmstudio/files/test/.nocheck b/roles/svc-ai-lmstudio/files/test/.nocheck new file mode 100644 index 0000000000..a0fdb0bf8c --- /dev/null +++ b/roles/svc-ai-lmstudio/files/test/.nocheck @@ -0,0 +1 @@ +# nocheck: role-files-layout - Role test harness with test.sh as its entry point; the CLI runner stages roles//files/test/ by path. diff --git a/roles/svc-ai-lmstudio/files/test/test.sh b/roles/svc-ai-lmstudio/files/test/test.sh new file mode 100644 index 0000000000..d30ca9b919 --- /dev/null +++ b/roles/svc-ai-lmstudio/files/test/test.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Env (rendered into test.env from templates/test.env.j2): +# LMSTUDIO_CONTAINER resolved container id on this node +# LMSTUDIO_PORT the daemon's HTTP port +# LMSTUDIO_EXPECTED_MODELS JSON array of model names the daemon must serve +# LMSTUDIO_EXPECTED_BLOBS JSON array of / paths under models/ +# LMSTUDIO_EXPECTED_DIGESTS JSON array of sha256, aligned with the blobs +# LMSTUDIO_MODELS_DIR the models mount inside the container +# RETRIES attempts (default 30) +# SLEEP_SECONDS wait between (default 10) + +set -uo pipefail + +RETRIES="${RETRIES:-30}" +SLEEP_SECONDS="${SLEEP_SECONDS:-10}" +MODELS_DIR="${LMSTUDIO_MODELS_DIR}" + +[ -n "${LMSTUDIO_CONTAINER}" ] || { + echo "[FATAL] LMSTUDIO_CONTAINER unset; the harness did not resolve a container" >&2 + exit 2 +} + +in_container() { + container exec "${LMSTUDIO_CONTAINER}" "$@" +} + +json_items() { + python3 -c 'import json,sys; [print(x) for x in json.loads(sys.argv[1])]' "$1" +} + +server_url="" + +serves_model() { + in_container curl -fsS --connect-timeout 5 --max-time 30 "${server_url}/v1/models" 2>/dev/null | + python3 -c 'import json,sys; d=json.load(sys.stdin); print("\n".join(m.get("id","") for m in d.get("data",[])))' 2>/dev/null | + grep -qxF "$1" +} + +failed=0 + +echo "=== 1. the CLI authenticates against its own daemon ===" +attempt=1 +auth_out="" +while [ "${attempt}" -le "${RETRIES}" ]; do + if auth_out="$(in_container /app/.bundle/lms ls 2>&1)"; then + echo "[OK] lms ls authenticated" + break + fi + if printf '%s' "${auth_out}" | grep -q 'authPacket'; then + echo "[FAIL] the daemon rejected its own CLI: $(printf '%s' "${auth_out}" | grep -m1 authPacket)" >&2 + echo " a second daemon shares /root/.lmstudio and rewrote .internal/lms-key-2" >&2 + exit 1 + fi + attempt=$((attempt + 1)) + [ "${attempt}" -le "${RETRIES}" ] && sleep "${SLEEP_SECONDS}" +done +if [ "${attempt}" -gt "${RETRIES}" ]; then + echo "[FAIL] lms ls never succeeded after ${RETRIES} attempts: ${auth_out}" >&2 + exit 1 +fi + +echo "=== 2. every declared blob is in place with its declared digest ===" +mapfile -t blobs < <(json_items "${LMSTUDIO_EXPECTED_BLOBS}") +mapfile -t digests < <(json_items "${LMSTUDIO_EXPECTED_DIGESTS}") +for i in "${!blobs[@]}"; do + path="${MODELS_DIR}/${blobs[$i]}" + if ! in_container test -f "${path}"; then + echo "[FAIL] ${blobs[$i]} is not in the model store" >&2 + failed=1 + continue + fi + actual="$(in_container sha256sum "${path}" 2>/dev/null | awk '{print $1}')" + if [ "${actual}" != "${digests[$i]}" ]; then + echo "[FAIL] ${blobs[$i]} hashes to ${actual}, declared ${digests[$i]}" >&2 + failed=1 + continue + fi + echo "[OK] ${blobs[$i]} present and matching" +done + +echo "=== 3. the daemon serves every declared model on its network address ===" +address="$(in_container hostname -I | tr ' ' '\n' | grep -m1 -E '^[0-9]+(\.[0-9]+){3}$')" +if [ -z "${address}" ]; then + echo "[FAIL] hostname -I reported no IPv4 address inside the container" >&2 + exit 1 +fi +server_url="http://${address}:${LMSTUDIO_PORT}" +mapfile -t models < <(json_items "${LMSTUDIO_EXPECTED_MODELS}") +for model in "${models[@]}"; do + attempt=1 + while [ "${attempt}" -le "${RETRIES}" ]; do + if serves_model "${model}"; then + echo "[OK] ${model} is served" + break + fi + attempt=$((attempt + 1)) + [ "${attempt}" -le "${RETRIES}" ] && sleep "${SLEEP_SECONDS}" + done + if [ "${attempt}" -gt "${RETRIES}" ]; then + if ! in_container curl -fsS --connect-timeout 5 --max-time 30 -o /dev/null "${server_url}/v1/models"; then + echo "[FAIL] ${server_url} does not answer" >&2 + echo " other containers cannot reach the server; check that LMS_SERVER_HOST binds it beyond loopback" >&2 + else + echo "[FAIL] ${model} is on disk but /v1/models never listed it" >&2 + echo " the daemon did not index the store it was given at startup" >&2 + fi + failed=1 + fi +done + +[ "${failed}" -eq 0 ] || exit 1 +echo "[OK] svc-ai-lmstudio serves every declared model from the seeded store" diff --git a/roles/svc-ai-lmstudio/meta/services.yml b/roles/svc-ai-lmstudio/meta/services.yml index d3471ca63e..53bab2e9c2 100644 --- a/roles/svc-ai-lmstudio/meta/services.yml +++ b/roles/svc-ai-lmstudio/meta/services.yml @@ -11,6 +11,7 @@ lmstudio: backup: no_stop_required: true image: lmstudio/llmster-preview + # nocheck: docker-version -- the repository publishes 2 tags and neither is a version; 'cpu' selects the backend, not a release version: cpu name: lmstudio platform_arch: x86_64 @@ -22,6 +23,10 @@ lmstudio: ports: internal: http: 1234 + preload_models: "{{ AI_LOCAL_MODELS }}" + preload_async_time: 3600 + preload_floor_seconds: 600 + preload_min_bytes_per_second: 1000000 lifecycle: beta # nocheck: playwright-service-flag, dynamic-flag diff --git a/roles/svc-ai-lmstudio/meta/tests.yml b/roles/svc-ai-lmstudio/meta/tests.yml new file mode 100644 index 0000000000..712dbdf2f8 --- /dev/null +++ b/roles/svc-ai-lmstudio/meta/tests.yml @@ -0,0 +1,4 @@ +--- +cli: + container_service_key: lmstudio + timeout: 900 diff --git a/roles/svc-ai-lmstudio/meta/volumes.yml b/roles/svc-ai-lmstudio/meta/volumes.yml index 51f7e8af72..0250216548 100644 --- a/roles/svc-ai-lmstudio/meta/volumes.yml +++ b/roles/svc-ai-lmstudio/meta/volumes.yml @@ -1,7 +1,17 @@ --- -# nocheck: embedded-state-on-nfs model blobs written once on pull and read back afterwards lmstudio_data: type: volume + name: lmstudio_data + # nocheck: nfs-false-data-loss Reason: per-daemon state LM Studio rewrites in place (lms-key-2, settings.json, extensions/plugins, bin/lms, server-logs), regenerated on every start; the models it indexes live in lmstudio_models + nfs: false mounts: - service: lmstudio target: /root/.lmstudio + +# nocheck: embedded-state-on-nfs content-addressed model blobs only; every writer fetches against the sha256 AI_LOCAL_MODELS declares and the play fails unless the store reads back at that digest, so concurrent writers of one file write identical bytes +lmstudio_models: + type: volume + name: lmstudio_models + mounts: + - service: lmstudio + target: /root/.lmstudio/models diff --git a/roles/svc-ai-lmstudio/tasks/00_core.yml b/roles/svc-ai-lmstudio/tasks/00_core.yml index bd183d0e32..e29cf2a6f2 100644 --- a/roles/svc-ai-lmstudio/tasks/00_core.yml +++ b/roles/svc-ai-lmstudio/tasks/00_core.yml @@ -1,7 +1,19 @@ --- - include_tasks: utils/once/flag.yml +- name: "⬇️ Seed the shared model store before the daemons index it" + ansible.builtin.include_tasks: + file: utils/preseed.yml + apply: + delegate_to: "{{ LMSTUDIO_NFS_SERVER }}" + run_once: true + when: LMSTUDIO_PRESEED_ENABLED | bool + - name: "🔧 Setup container network for {{ application_id }}" include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/network/routine.yml') }}" vars: compose_handlers_flush: true + +- name: "⬇️ Manager-side model pre-pull on the hosting node" + ansible.builtin.include_tasks: 01_manager_ops.yml + when: IS_STACK_HOST | bool diff --git a/roles/svc-ai-lmstudio/tasks/01_manager_ops.yml b/roles/svc-ai-lmstudio/tasks/01_manager_ops.yml new file mode 100644 index 0000000000..a4362e07d8 --- /dev/null +++ b/roles/svc-ai-lmstudio/tasks/01_manager_ops.yml @@ -0,0 +1,10 @@ +--- +- name: "🔍 Resolve every node that hosts an {{ application_id }} task" + ansible.builtin.include_tasks: utils/nodes.yml + +- name: "⬇️ Preload LM Studio on each hosting node" + ansible.builtin.include_tasks: utils/preload/node.yml + loop: "{{ LMSTUDIO_HOST_NODES }}" + loop_control: + loop_var: lmstudio_node + label: "{{ lmstudio_node }}" diff --git a/roles/svc-ai-lmstudio/tasks/utils/cid.yml b/roles/svc-ai-lmstudio/tasks/utils/cid.yml new file mode 100644 index 0000000000..e29b8fafba --- /dev/null +++ b/roles/svc-ai-lmstudio/tasks/utils/cid.yml @@ -0,0 +1,22 @@ +--- +- name: "🔍 Resolve the LM Studio container on '{{ lmstudio_node }}'" + ansible.builtin.shell: | + {% if DEPLOYMENT_MODE == 'swarm' %} + container ps \ + --filter "label=com.docker.swarm.service.name={{ lmstudio_service_name }}" \ + --filter status=running \ + --format '{{ "{{.ID}}" }}' | head -1 + {% else %} + echo {{ LMSTUDIO_CONTAINER | quote }} + {% endif %} + register: lmstudio_node_cid + changed_when: false + retries: 30 + delay: 2 + until: DEPLOYMENT_MODE == 'compose' or (lmstudio_node_cid.stdout | trim) | length > 0 + delegate_to: "{{ lmstudio_node }}" + +- name: "🔧 Target '{{ lmstudio_node }}' for the next LM Studio step" + ansible.builtin.set_fact: + LMSTUDIO_HOST_NODE: "{{ lmstudio_node }}" + LMSTUDIO_LOCAL_CID: "{{ lmstudio_node_cid.stdout | trim }}" diff --git a/roles/svc-ai-lmstudio/tasks/utils/nodes.yml b/roles/svc-ai-lmstudio/tasks/utils/nodes.yml new file mode 100644 index 0000000000..b3ee1a6726 --- /dev/null +++ b/roles/svc-ai-lmstudio/tasks/utils/nodes.yml @@ -0,0 +1,29 @@ +--- +- name: "🔧 Resolve the swarm service name of '{{ entity_name }}'" + ansible.builtin.set_fact: + lmstudio_service_name: "{{ lookup('container_service', application_id, entity_name) if DEPLOYMENT_MODE == 'swarm' else '' }}" + +- name: "⏳ Wait until every LM Studio task has converged" + ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/wait_converged.yml') }}" + vars: + wait_service_key: "{{ entity_name }}" + wait_service_name: "{{ lmstudio_service_name }}" + +- name: "🔍 List every node that runs an LM Studio task" + ansible.builtin.shell: | + container service ps {{ lmstudio_service_name | quote }} \ + --filter desired-state=running \ + --format '{{ "{{.Node}}" }}' | sort -u + register: lmstudio_node_scan + changed_when: false + retries: 30 + delay: 2 + until: (lmstudio_node_scan.stdout | trim) | length > 0 + delegate_to: "{{ STACK_HOST }}" + when: DEPLOYMENT_MODE == 'swarm' + +- name: "🔧 Set the LM Studio hosting nodes" + ansible.builtin.set_fact: + LMSTUDIO_HOST_NODES: >- + {{ (lmstudio_node_scan.stdout_lines | map('trim') | select | list) + if DEPLOYMENT_MODE == 'swarm' else [inventory_hostname] }} diff --git a/roles/svc-ai-lmstudio/tasks/utils/preload/node.yml b/roles/svc-ai-lmstudio/tasks/utils/preload/node.yml new file mode 100644 index 0000000000..d4a0aeb063 --- /dev/null +++ b/roles/svc-ai-lmstudio/tasks/utils/preload/node.yml @@ -0,0 +1,52 @@ +--- +- name: "🔍 Resolve the container before preloading on '{{ lmstudio_node }}'" + ansible.builtin.include_tasks: utils/cid.yml + +- name: "⏳ Wait until the LM Studio daemon has finished starting on '{{ lmstudio_node }}'" + ansible.builtin.shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node + container exec {{ LMSTUDIO_LOCAL_CID | quote }} {{ 10 | curl(retries=0) }} -fS -o /dev/null http://127.0.0.1:{{ LMSTUDIO_PORT }}/lmstudio-greeting + register: lmstudio_daemon_ready + changed_when: false + retries: 60 + delay: 5 + until: lmstudio_daemon_ready.rc == 0 + delegate_to: "{{ lmstudio_node }}" + +- name: "🗄️ Measure the space the model store offers on '{{ lmstudio_node }}'" + ansible.builtin.shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node + container exec {{ LMSTUDIO_LOCAL_CID | quote }} df -P -B1 {{ LMSTUDIO_MODELS_TARGET | quote }} + register: lmstudio_store_space + changed_when: false + retries: 30 + delay: 2 + until: lmstudio_store_space.rc == 0 + delegate_to: "{{ lmstudio_node }}" + when: not (LMSTUDIO_PRESEED_ENABLED | bool) + +- name: "🛑 Refuse to preload models the store on '{{ lmstudio_node }}' cannot hold" + vars: + _free: "{{ lmstudio_store_space.stdout_lines[1].split()[3] | int }}" + _required: "{{ LMSTUDIO_PRELOAD_LARGEST_BYTES | int }}" + ansible.builtin.fail: + msg: >- + The model store on {{ lmstudio_node }} has + {{ (_free | int / 1024 / 1024 / 1024) | round(1) }} GiB free, less than the + {{ (_required | int / 1024 / 1024 / 1024) | round(1) }} GiB the largest + declared model needs. Shrink AI_LOCAL_MODELS in group_vars/all/16_ai.yml or + give the node more disk. + when: + - not (LMSTUDIO_PRESEED_ENABLED | bool) + - _free | int < _required | int + +- name: "⬇️ Preload the LM Studio models on '{{ lmstudio_node }}'" + block: + - name: "⬇️ Preload the LM Studio models (first pass)" + ansible.builtin.include_tasks: utils/preload/pull.yml + rescue: + - name: "🐝 Re-raise the preload failure unchanged in swarm" # nocheck: rescue-recovery a timed-out transfer leaves the daemon reporting 0.00% for every further pull, and restarting the container it owns is the only lever the role has, though CI job 105558224377 shows a restart returning rc 0 without clearing it; the generic capture cannot even try, so swarm re-raises here instead, because under any_errors_fatal a rescued host keeps running while every other host is already marked failed, and compose ends the block by preloading again so an unrecovered failure still re-raises + ansible.builtin.fail: + msg: "{{ ansible_failed_result.msg | default('LM Studio preload failed') }}" + when: DEPLOYMENT_MODE == 'swarm' + + - name: "♻️ Recover the wedged transfer and preload again" + ansible.builtin.include_tasks: utils/preload/recover.yml diff --git a/roles/svc-ai-lmstudio/tasks/utils/preload/pull.yml b/roles/svc-ai-lmstudio/tasks/utils/preload/pull.yml new file mode 100644 index 0000000000..162812fb1c --- /dev/null +++ b/roles/svc-ai-lmstudio/tasks/utils/preload/pull.yml @@ -0,0 +1,39 @@ +--- +- name: ⬇️ Pre-pull LM Studio models + ansible.builtin.shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node + container exec -i {{ LMSTUDIO_LOCAL_CID | quote }} lms get --yes --gguf {{ model.source | quote }} + environment: + CONTAINER_EXEC_TIMEOUT: >- + {{ [ LMSTUDIO_PRELOAD_FLOOR_SECONDS | int, + ((model.bytes | int) / (LMSTUDIO_PRELOAD_MIN_BYTES_PER_SECOND | int)) + | round(0, 'ceil') | int ] | max }} + register: lmstudio_pull_result + loop: "{{ LMSTUDIO_PRELOAD_MODELS }}" + loop_control: + loop_var: model + label: "{{ model.alias }}" + # rationale: async; the reap below reads every job back before the play continues, so no model is still downloading when playwright starts + async: "{{ LMSTUDIO_PRELOAD_ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" + poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" + retries: "{{ 0 if ASYNC_ENABLED | bool else 1 }}" + delay: 15 + until: > + (lmstudio_pull_result.rc | default(0)) == 0 or + ('You already have this model' in (lmstudio_pull_result.stderr | default(''))) + changed_when: false + failed_when: >- + (lmstudio_pull_result.failed | default(false)) + and 'You already have this model' not in ((lmstudio_pull_result.stdout | default('')) ~ (lmstudio_pull_result.stderr | default(''))) + delegate_to: "{{ LMSTUDIO_HOST_NODE }}" + +- name: "⏳ Reap the LM Studio model pre-pulls" + ansible.builtin.include_tasks: + file: utils/async/reap.yml + apply: + delegate_to: "{{ LMSTUDIO_HOST_NODE }}" + vars: + async_reap_jobs: "{{ lmstudio_pull_result.results | default([]) }}" + async_reap_label: "LM Studio model pre-pull" + async_reap_tolerate: + - "You already have this model" + ASYNC_TIME: "{{ LMSTUDIO_PRELOAD_ASYNC_TIME }}" diff --git a/roles/svc-ai-lmstudio/tasks/utils/preload/recover.yml b/roles/svc-ai-lmstudio/tasks/utils/preload/recover.yml new file mode 100644 index 0000000000..e8f526456f --- /dev/null +++ b/roles/svc-ai-lmstudio/tasks/utils/preload/recover.yml @@ -0,0 +1,28 @@ +--- +- name: "🩺 Dump the LM Studio transfer record" + ansible.builtin.shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node + container exec -i {{ LMSTUDIO_LOCAL_CID | quote }} + cat /root/.lmstudio/.internal/single-downloads-info.json + register: lmstudio_transfer_record + changed_when: false + failed_when: false + delegate_to: "{{ LMSTUDIO_HOST_NODE }}" + +- name: "🩺 Report the LM Studio transfer record" + ansible.builtin.debug: + var: lmstudio_transfer_record.stdout + +- name: "♻️ Restart LM Studio to release the wedged transfer" + ansible.builtin.command: container restart {{ LMSTUDIO_LOCAL_CID }} + changed_when: true + delegate_to: "{{ LMSTUDIO_HOST_NODE }}" + +- name: "🔍 Re-resolve the {{ application_id }} hosting node + container id" + ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}" + vars: + resolve_service_key: "{{ entity_name }}" + resolve_output_prefix: LMSTUDIO + resolve_compose_cid: "{{ LMSTUDIO_CONTAINER }}" + +- name: "⬇️ Preload the LM Studio models (after the restart)" + ansible.builtin.include_tasks: utils/preload/pull.yml diff --git a/roles/svc-ai-lmstudio/tasks/utils/preseed.yml b/roles/svc-ai-lmstudio/tasks/utils/preseed.yml new file mode 100644 index 0000000000..49fa570841 --- /dev/null +++ b/roles/svc-ai-lmstudio/tasks/utils/preseed.yml @@ -0,0 +1,118 @@ +--- +- name: "📁 Create the shared model store before the daemons start" + ansible.builtin.file: + path: "{{ LMSTUDIO_SHARED_MODELS_PATH }}/{{ model.repo }}" + state: directory + mode: "0755" + loop: "{{ LMSTUDIO_PRELOAD_MODELS }}" + loop_control: + loop_var: model + label: "{{ model.file }}" + +- name: "📁 Create the staging directory beside the model store" + ansible.builtin.file: + path: "{{ LMSTUDIO_SHARED_STAGING_PATH }}" + state: directory + mode: "0755" + +- name: "🔍 Read what the shared model store already holds" + ansible.builtin.stat: + path: "{{ LMSTUDIO_SHARED_MODELS_PATH }}/{{ model.repo }}/{{ model.file }}" + checksum_algorithm: sha256 + register: lmstudio_present + changed_when: false + loop: "{{ LMSTUDIO_PRELOAD_MODELS }}" + loop_control: + loop_var: model + label: "{{ model.file }}" + +- name: "🗄️ Measure the free space the shared model store offers" + ansible.builtin.command: "df -P -B1 {{ LMSTUDIO_SHARED_MODELS_PATH | quote }}" + register: lmstudio_export_space + changed_when: false + +- name: "🛑 Refuse to fetch models the shared store cannot hold" + vars: + _free: "{{ lmstudio_export_space.stdout_lines[1].split()[3] | int }}" + _absent: >- + {{ lmstudio_present.results + | rejectattr('stat.exists') + | map(attribute='model.bytes') | map('int') | sum }} + _required: "{{ (_absent | int) + (LMSTUDIO_PRELOAD_LARGEST_BYTES | int) }}" + ansible.builtin.fail: + msg: >- + The shared model store on {{ LMSTUDIO_NFS_SERVER }} has + {{ (_free | int / 1024 / 1024 / 1024) | round(1) }} GiB free but needs + {{ (_required | int / 1024 / 1024 / 1024) | round(1) }} GiB: it is missing + {{ (_absent | int / 1024 / 1024 / 1024) | round(1) }} GiB of declared models, + plus {{ (LMSTUDIO_PRELOAD_LARGEST_BYTES | int / 1024 / 1024 / 1024) | round(1) }} + GiB for the largest one while it stages. Shrink AI_LOCAL_MODELS in + group_vars/all/16_ai.yml or grow the export. + when: _free | int < _required | int + +- name: "⬇️ Fetch the models into the shared store" + get_url_retry: + url: "{{ model.url }}" + dest: "{{ LMSTUDIO_SHARED_MODELS_PATH }}/{{ model.repo }}/{{ model.file }}" + tmp_dest: "{{ LMSTUDIO_SHARED_STAGING_PATH }}" + checksum: "sha256:{{ model.sha256 }}" + mode: "0644" + loop: "{{ LMSTUDIO_PRELOAD_MODELS }}" + loop_control: + loop_var: model + label: "{{ model.file }}" + register: lmstudio_fetch + changed_when: false + failed_when: false + +- name: "🪞 Fetch from a mirror when the source did not deliver" + get_url_retry: + url: "{{ item.1 }}" + dest: "{{ LMSTUDIO_SHARED_MODELS_PATH }}/{{ item.0.repo }}/{{ item.0.file }}" + tmp_dest: "{{ LMSTUDIO_SHARED_STAGING_PATH }}" + checksum: "sha256:{{ item.0.sha256 }}" + mode: "0644" + loop: "{{ LMSTUDIO_PRELOAD_MODELS | subelements('mirrors', skip_missing=True) }}" + loop_control: + label: "{{ item.0.file }} from {{ item.1 }}" + register: lmstudio_fetch_mirror + changed_when: false + failed_when: false + +- name: "🔍 Read back what the shared model store now holds" + ansible.builtin.stat: + path: "{{ LMSTUDIO_SHARED_MODELS_PATH }}/{{ model.repo }}/{{ model.file }}" + checksum_algorithm: sha256 + register: lmstudio_seeded + changed_when: false + loop: "{{ LMSTUDIO_PRELOAD_MODELS }}" + loop_control: + loop_var: model + label: "{{ model.file }}" + +- name: "🛑 Fail when no source delivered a declared model" + vars: + _missing: >- + {{ lmstudio_seeded.results + | rejectattr('stat.exists') + | map(attribute='model.file') | list }} + _said: >- + {{ (lmstudio_fetch.results | map(attribute='msg') | select | list) + + (lmstudio_fetch_mirror.results | default([]) | map(attribute='msg') | select | list) }} + ansible.builtin.fail: + msg: >- + The shared model store on {{ LMSTUDIO_NFS_SERVER }} is missing + {{ _missing | join(', ') }}. What the sources answered: + {{ _said | join(' | ') }} + when: _missing | length > 0 + +- name: "🛑 Fail when a seeded model does not match its checksum" + ansible.builtin.fail: + msg: >- + {{ item.model.file }} in the shared model store on {{ LMSTUDIO_NFS_SERVER }} + hashes to {{ item.stat.checksum }}, but AI_LOCAL_MODELS declares + {{ item.model.sha256 }}. + loop: "{{ lmstudio_seeded.results | selectattr('stat.exists') | list }}" + loop_control: + label: "{{ item.model.file }}" + when: item.stat.checksum != item.model.sha256 diff --git a/roles/svc-ai-lmstudio/templates/compose.yml.j2 b/roles/svc-ai-lmstudio/templates/compose.yml.j2 index 3e05c26133..ba14ab4e22 100644 --- a/roles/svc-ai-lmstudio/templates/compose.yml.j2 +++ b/roles/svc-ai-lmstudio/templates/compose.yml.j2 @@ -7,6 +7,8 @@ {{ lookup('compose_only', 'container_name', LMSTUDIO_CONTAINER) }} expose: - "{{ LMSTUDIO_PORT }}" + environment: + LMS_SERVER_HOST: "0.0.0.0" {{ lookup('container_volumes', service_name) | indent(4) }} {{ lookup('container_networks') | indent(4) }} diff --git a/roles/svc-ai-lmstudio/templates/test.env.j2 b/roles/svc-ai-lmstudio/templates/test.env.j2 new file mode 100644 index 0000000000..019aeb6e20 --- /dev/null +++ b/roles/svc-ai-lmstudio/templates/test.env.j2 @@ -0,0 +1,6 @@ +LMSTUDIO_CONTAINER={{ CLI_LOCAL_CID }} +LMSTUDIO_EXPECTED_BLOBS='{{ LMSTUDIO_PRELOAD_MODELS | map(attribute='repo') | zip(LMSTUDIO_PRELOAD_MODELS | map(attribute='file')) | map('join', '/') | list | to_json }}' +LMSTUDIO_EXPECTED_DIGESTS='{{ LMSTUDIO_PRELOAD_MODELS | map(attribute='sha256') | list | to_json }}' +LMSTUDIO_EXPECTED_MODELS='{{ LMSTUDIO_PRELOAD_MODELS | map(attribute='name') | list | to_json }}' +LMSTUDIO_MODELS_DIR={{ LMSTUDIO_MODELS_TARGET }} +LMSTUDIO_PORT={{ LMSTUDIO_PORT }} diff --git a/roles/svc-ai-lmstudio/vars/main.yml b/roles/svc-ai-lmstudio/vars/main.yml index cea5302881..8314305c99 100644 --- a/roles/svc-ai-lmstudio/vars/main.yml +++ b/roles/svc-ai-lmstudio/vars/main.yml @@ -4,3 +4,19 @@ entity_name: "{{ application_id | get_entity_name }}" LMSTUDIO_CONTAINER: "{{ lookup('config', application_id, 'services.lmstudio.name') }}" LMSTUDIO_PORT: "{{ lookup('config', application_id, 'services.lmstudio.ports.internal.http') }}" +LMSTUDIO_PRELOAD_MODELS: "{{ lookup('config', application_id, 'services.lmstudio.preload_models') }}" +LMSTUDIO_PRELOAD_ASYNC_TIME: "{{ lookup('config', application_id, 'services.lmstudio.preload_async_time') }}" +LMSTUDIO_PRELOAD_FLOOR_SECONDS: "{{ lookup('config', application_id, 'services.lmstudio.preload_floor_seconds') }}" +LMSTUDIO_PRELOAD_MIN_BYTES_PER_SECOND: "{{ lookup('config', application_id, 'services.lmstudio.preload_min_bytes_per_second') }}" +LMSTUDIO_PRELOAD_LARGEST_BYTES: >- + {{ LMSTUDIO_PRELOAD_MODELS | map(attribute='bytes') | map('int') | max }} +LMSTUDIO_MODELS_TARGET: "{{ lookup('volume', application_id, 'lmstudio_models').target }}" +LMSTUDIO_NFS_SERVER: "{{ (groups['svc-storage-nfs-server'] | default([]))[0] | default(inventory_hostname) }}" +LMSTUDIO_SHARED_MODELS_PATH: "{{ [ STORAGE_NFS_STATE_PATH, lookup('volume', application_id, 'lmstudio_models').name ] | path_join }}" +LMSTUDIO_SHARED_STAGING_PATH: "{{ [ STORAGE_NFS_STATE_PATH, '.lmstudio-staging' ] | path_join }}" +LMSTUDIO_PRESEED_ENABLED: >- + {{ (DEPLOYMENT_MODE == 'swarm') + and (storage.backend == 'nfs') + and ((groups['svc-storage-nfs-server'] | default([])) | length > 0) + and (lookup('volume', application_id, 'lmstudio_models').nfs is not false) + and (application_id not in lookup('roles_with_placement', 'manager')) }} diff --git a/roles/svc-ai-ollama/meta/services.yml b/roles/svc-ai-ollama/meta/services.yml index 6b3fda75b0..751717156b 100644 --- a/roles/svc-ai-ollama/meta/services.yml +++ b/roles/svc-ai-ollama/meta/services.yml @@ -13,7 +13,7 @@ ollama: backup: no_stop_required: true image: ollama/ollama - version: 0.34.0 + version: 0.34.2 name: ollama min_storage: 3473MB cpus: "4.0" @@ -23,10 +23,7 @@ ollama: ports: internal: http: 11434 - preload_models: - - llama3:latest - - mistral:latest - - nomic-embed-text:latest + preload_models: "{{ AI_LOCAL_MODELS }}" preload_async_time: 3600 keep_alive: "-1" lifecycle: beta diff --git a/roles/svc-ai-ollama/meta/variants.yml b/roles/svc-ai-ollama/meta/variants.yml index 43b327aa21..6b9ad2d919 100644 --- a/roles/svc-ai-ollama/meta/variants.yml +++ b/roles/svc-ai-ollama/meta/variants.yml @@ -11,7 +11,5 @@ container_backup: enabled: true ollama: - preload_models: - - qwen2.5:0.5b mem_reservation: 1g mem_limit: 2g diff --git a/roles/svc-ai-ollama/tasks/01_manager_ops.yml b/roles/svc-ai-ollama/tasks/01_manager_ops.yml index c192f0272a..11fc3a70fb 100644 --- a/roles/svc-ai-ollama/tasks/01_manager_ops.yml +++ b/roles/svc-ai-ollama/tasks/01_manager_ops.yml @@ -8,11 +8,12 @@ - name: ⬇️ Pre-pull Ollama models ansible.builtin.shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node - container exec -i {{ OLLAMA_LOCAL_CID | quote }} ollama pull {{ model }} + container exec -i {{ OLLAMA_LOCAL_CID | quote }} ollama pull {{ model.alias }} register: pull_result loop: "{{ OLLAMA_PRELOAD_MODELS }}" loop_control: loop_var: model + label: "{{ model.alias }}" # rationale: async; model pulls run overlapped, results reaped below before playwright async: "{{ OLLAMA_PRELOAD_ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" diff --git a/roles/svc-bkp-remote-2-local/templates/script.sh.j2 b/roles/svc-bkp-remote-2-local/templates/script.sh.j2 index 0000b7c862..4cdffc1dc8 100644 --- a/roles/svc-bkp-remote-2-local/templates/script.sh.j2 +++ b/roles/svc-bkp-remote-2-local/templates/script.sh.j2 @@ -1,4 +1,5 @@ #!/bin/bash +{# nocheck: shell-template grandfathered, values only: the provider list and two paths #} # Pulls the remote backups from multiple hosts hosts="{{ DOCKER_BACKUP_REMOTE_2_LOCAL_BACKUP_PROVIDERS | join(' ') }}" diff --git a/roles/svc-db-elasticsearch/meta/services.yml b/roles/svc-db-elasticsearch/meta/services.yml index 64c6d96b81..6f13b566e6 100644 --- a/roles/svc-db-elasticsearch/meta/services.yml +++ b/roles/svc-db-elasticsearch/meta/services.yml @@ -25,6 +25,9 @@ elasticsearch: elasticsearch: 9200 internal: elasticsearch: 9200 + onion: + # nocheck: onion-flag the index is queried by the search backends of the apps sharing this stack, never by a client off the node + elasticsearch: false lifecycle: beta # nocheck: variants-coverage — sso-variant-tor requires the onion on in every variant, so the false polarity has no variant left to live in tor: diff --git a/roles/svc-db-elasticsearch/meta/variants.yml b/roles/svc-db-elasticsearch/meta/variants.yml index 1576e2511e..a5e59537b6 100644 --- a/roles/svc-db-elasticsearch/meta/variants.yml +++ b/roles/svc-db-elasticsearch/meta/variants.yml @@ -2,6 +2,3 @@ - services: tor: enabled: true -- services: - tor: - enabled: true diff --git a/roles/svc-db-memcached/meta/services.yml b/roles/svc-db-memcached/meta/services.yml index d47db749a6..0af5df1e7b 100644 --- a/roles/svc-db-memcached/meta/services.yml +++ b/roles/svc-db-memcached/meta/services.yml @@ -11,7 +11,7 @@ memcached: enabled: true shared: true image: memcached - version: alpine + version: 1.6.45-alpine name: memcached min_storage: 206MB backup: @@ -28,6 +28,9 @@ memcached: memcached: 11211 internal: memcached: 11211 + onion: + # nocheck: onion-flag the session cache speaks an unauthenticated text protocol and is read only by app containers on the role network + memcached: false lifecycle: beta # nocheck: variants-coverage — sso-variant-tor requires the onion on in every variant, so the false polarity has no variant left to live in tor: diff --git a/roles/svc-db-memcached/meta/variants.yml b/roles/svc-db-memcached/meta/variants.yml index 1576e2511e..a5e59537b6 100644 --- a/roles/svc-db-memcached/meta/variants.yml +++ b/roles/svc-db-memcached/meta/variants.yml @@ -2,6 +2,3 @@ - services: tor: enabled: true -- services: - tor: - enabled: true diff --git a/roles/svc-db-openldap/meta/services.yml b/roles/svc-db-openldap/meta/services.yml index 08111859de..0372a514db 100644 --- a/roles/svc-db-openldap/meta/services.yml +++ b/roles/svc-db-openldap/meta/services.yml @@ -12,7 +12,7 @@ openldap: shared: true provides: ldap image: debian - version: bookworm-slim + version: 12.15-slim custom: true name: openldap min_storage: 229MB diff --git a/roles/svc-db-qdrant/meta/services.yml b/roles/svc-db-qdrant/meta/services.yml index fd8ee4690d..50a5a5fb7c 100644 --- a/roles/svc-db-qdrant/meta/services.yml +++ b/roles/svc-db-qdrant/meta/services.yml @@ -28,6 +28,9 @@ qdrant: qdrant: 6333 internal: qdrant: 6333 + onion: + # nocheck: onion-flag the vector store is read by the svc-ai roles in this stack; its MCP sidecar is the surface meant for outside callers + qdrant: false lifecycle: beta # nocheck: playwright-service-flag internal adapter sidecar, covered by the role's MCP integration test # nocheck: dockerfile-custom the Dockerfile is owned by svc-ai-mcp-adapter and staged per instance, so every adapter-backed provider shares one audited build diff --git a/roles/svc-db-rabbitmq/meta/services.yml b/roles/svc-db-rabbitmq/meta/services.yml index 330ec8ef2c..b3183f721d 100644 --- a/roles/svc-db-rabbitmq/meta/services.yml +++ b/roles/svc-db-rabbitmq/meta/services.yml @@ -28,6 +28,9 @@ rabbitmq: rabbitmq: 5672 internal: rabbitmq: 5672 + onion: + # nocheck: onion-flag AMQP carries the apps' own worker queues; a broker reachable off the node would let anyone enqueue jobs + rabbitmq: false lifecycle: beta # nocheck: variants-coverage — sso-variant-tor requires the onion on in every variant, so the false polarity has no variant left to live in tor: diff --git a/roles/svc-db-rabbitmq/meta/variants.yml b/roles/svc-db-rabbitmq/meta/variants.yml index 1576e2511e..a5e59537b6 100644 --- a/roles/svc-db-rabbitmq/meta/variants.yml +++ b/roles/svc-db-rabbitmq/meta/variants.yml @@ -2,6 +2,3 @@ - services: tor: enabled: true -- services: - tor: - enabled: true diff --git a/roles/svc-db-redis/meta/services.yml b/roles/svc-db-redis/meta/services.yml index b22b73508c..d8d43fba90 100644 --- a/roles/svc-db-redis/meta/services.yml +++ b/roles/svc-db-redis/meta/services.yml @@ -12,7 +12,7 @@ redis: shared: true image: redis name: redis-central - version: alpine + version: 8.10.1-alpine min_storage: 238MB backup: disabled: true @@ -25,6 +25,9 @@ redis: redis: 6379 internal: redis: 6379 + onion: + # nocheck: onion-flag the shared cache and queue runs without AUTH inside the stack, so its reachability must stay bounded by the role network + redis: false lifecycle: beta # nocheck: variants-coverage — sso-variant-tor requires the onion on in every variant, so the false polarity has no variant left to live in tor: diff --git a/roles/svc-db-redis/meta/variants.yml b/roles/svc-db-redis/meta/variants.yml index 1576e2511e..a5e59537b6 100644 --- a/roles/svc-db-redis/meta/variants.yml +++ b/roles/svc-db-redis/meta/variants.yml @@ -2,6 +2,3 @@ - services: tor: enabled: true -- services: - tor: - enabled: true diff --git a/roles/svc-db-typesense/meta/services.yml b/roles/svc-db-typesense/meta/services.yml index fe07bdae91..8304c9e73a 100644 --- a/roles/svc-db-typesense/meta/services.yml +++ b/roles/svc-db-typesense/meta/services.yml @@ -28,6 +28,9 @@ typesense: typesense: 8108 internal: typesense: 8108 + onion: + # nocheck: onion-flag the search index is queried by the app containers holding its API key, and the apps themselves are served through the onion's port 80 + typesense: false lifecycle: beta # nocheck: variants-coverage — sso-variant-tor requires the onion on in every variant, so the false polarity has no variant left to live in tor: diff --git a/roles/svc-db-typesense/meta/variants.yml b/roles/svc-db-typesense/meta/variants.yml index 1576e2511e..a5e59537b6 100644 --- a/roles/svc-db-typesense/meta/variants.yml +++ b/roles/svc-db-typesense/meta/variants.yml @@ -2,6 +2,3 @@ - services: tor: enabled: true -- services: - tor: - enabled: true diff --git a/roles/svc-dns-unbound/meta/services.yml b/roles/svc-dns-unbound/meta/services.yml index bb8b51983d..e440222e4b 100644 --- a/roles/svc-dns-unbound/meta/services.yml +++ b/roles/svc-dns-unbound/meta/services.yml @@ -21,8 +21,13 @@ unbound: mem_limit: "512m" pids_limit: 256 ports: + internal: + unbound: 53 local: unbound: 53 + onion: + # nocheck: onion-flag a recursive resolver published on the onion is an open resolver, and the containers it serves reach it on the role network + unbound: false lifecycle: beta # nocheck: dynamic-flag - the resolver state volume is backup-disabled rebuildable cache, the backup consumer stays permanently off diff --git a/roles/svc-dns-unbound/templates/compose.yml.j2 b/roles/svc-dns-unbound/templates/compose.yml.j2 index f6371fed79..86f759571d 100644 --- a/roles/svc-dns-unbound/templates/compose.yml.j2 +++ b/roles/svc-dns-unbound/templates/compose.yml.j2 @@ -6,9 +6,9 @@ {{ lookup('container_image', application_id, 'unbound') }} {% include 'roles/sys-svc-container/templates/base.yml.j2' %} {% if DEPLOYMENT_MODE == 'compose' and not (DOCKER_IN_CONTAINER | bool) %} - ports: - - "{{ DOCKER_BIND_HOST }}:{{ UNBOUND_PORT }}:53/udp" # nocheck: compose-ports-must-use-container-ports - - "{{ DOCKER_BIND_HOST }}:{{ UNBOUND_PORT }}:53/tcp" # nocheck: compose-ports-must-use-container-ports + {{ lookup('container_ports', + {'service': 'unbound', 'protocol': 'unbound', 'ip': DOCKER_BIND_HOST, + 'transport': ['udp', 'tcp']}) | indent(4) }} {% endif %} {{ lookup('container_volumes', service_name) | indent(4) }} networks: # nocheck: networks-literal static-IP resolver on its own engine overlay so consumers can point dns: at it diff --git a/roles/svc-dns-unbound/templates/unbound.conf.j2 b/roles/svc-dns-unbound/templates/unbound.conf.j2 index d9f07c33b3..047486d8da 100644 --- a/roles/svc-dns-unbound/templates/unbound.conf.j2 +++ b/roles/svc-dns-unbound/templates/unbound.conf.j2 @@ -7,10 +7,9 @@ server: do-udp: yes do-tcp: yes do-daemonize: no - access-control: 127.0.0.0/8 allow - access-control: 10.0.0.0/8 allow - access-control: 172.16.0.0/12 allow - access-control: 192.168.0.0/16 allow +{% for _cidr in NETWORK_INTERNAL_CIDRS %} + access-control: {{ _cidr }} allow +{% endfor %} directory: "{{ lookup('volume', 'svc-dns-unbound', 'data').target }}" username: unbound auto-trust-anchor-file: trusted-key.key @@ -18,6 +17,9 @@ server: hide-identity: yes hide-version: yes cache-min-ttl: 300 +{% if UNBOUND_ONION_FORWARD_ADDR | length > 0 %} + domain-insecure: "onion." +{% endif %} remote-control: control-enable: yes @@ -30,3 +32,9 @@ forward-zone: forward-addr: {{ resolver }} {% endfor %} {% endif %} +{% if UNBOUND_ONION_FORWARD_ADDR | length > 0 %} + +forward-zone: + name: "onion." + forward-addr: {{ UNBOUND_ONION_FORWARD_ADDR }} +{% endif %} diff --git a/roles/svc-dns-unbound/vars/main.yml b/roles/svc-dns-unbound/vars/main.yml index 918bac40d0..75207698c0 100644 --- a/roles/svc-dns-unbound/vars/main.yml +++ b/roles/svc-dns-unbound/vars/main.yml @@ -4,7 +4,6 @@ entity_name: "{{ application_id | get_entity_name }}" UNBOUND_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.name') }}" UNBOUND_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, UNBOUND_CONTAINER) }}" -UNBOUND_PORT: "{{ lookup('config', application_id, 'services.unbound.ports.local.unbound') }}" UNBOUND_NETWORK_NAME: "{{ entity_name }}" UNBOUND_RESOLVER_IP: "{{ lookup('config', application_id, 'networks.local.dns_resolver') }}" @@ -12,3 +11,7 @@ UNBOUND_VOLUMES_DIR: "{{ lookup('container', application_id, 'directories.volume UNBOUND_CONF_FILE: "{{ [ UNBOUND_VOLUMES_DIR, 'unbound.conf' ] | path_join }}" UNBOUND_FORWARD_DNS: "{{ (DOCKER_IN_CONTAINER | bool) or ('microsoft' in lookup('file', '/proc/version') | lower) }}" + +UNBOUND_ONION_FORWARD_ADDR: >- + {{ (lookup('subnet_gateway') ~ '@' ~ lookup('config', 'svc-net-tor', 'services.tor.ports.local.dns')) + if ('svc-net-tor' in group_names and DEPLOYMENT_MODE == 'compose') else '' }} diff --git a/roles/svc-net-firewall/README.md b/roles/svc-net-firewall/README.md new file mode 100644 index 0000000000..9556984acb --- /dev/null +++ b/roles/svc-net-firewall/README.md @@ -0,0 +1,92 @@ +# Firewall ruleset + +## Description + +[nftables](https://www.netfilter.org/projects/nftables/) is the packet filtering framework of the Linux kernel and the successor to `iptables`. Its rules live in memory: a reboot drops them unless something loads them again. + +## Overview + +This role gives every other role one place to put packet filter rules. A role that needs them writes `templates/nftables.conf.j2` declaring its own nftables table and includes this role; the fragment is rendered to `/etc/nftables.d/.conf`, checked, loaded, and read back, so a deploy fails rather than quietly leaving a gap. Deployed on its own, the role installs nftables, the ruleset that loads every fragment, and the `nftables.service` that replays them at boot. + +One table per role is what makes the rules composable. A fragment replaces its own table and touches no other, so two roles on one host neither overwrite nor merge into each other, and a broken fragment fails alone instead of freezing everyone's rules. + +Replacing beats adding. `nft -f` on an existing table merges, so a rule dropped from the declaration would stay in the kernel and the ruleset could only ever grow. Each fragment therefore creates, deletes and redeclares its table in one transaction, which is also why the table is never observed empty: unlike a flush followed by rules, there is no window in which the boundary is gone. + +## Cosmos + +The diagram places Firewall ruleset in the Infinito.Nexus cosmos: the components it deploys (capabilities), the central services it consumes (dependencies), and its outward reach (federation and bridged external networks). + +```mermaid +flowchart LR + subgraph role [svc-net-firewall 💻] + svc_firewall["firewall"] + end +``` + +Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional (enabled only in matching deployments); red `0..0` edges are turned off in this role. Node markers show the role's deploy modes (💻 host, 🐳 compose, 🐝 swarm); ❌ marks a service that is explicitly turned off, and ⚙️ an Ansible role dependency declared in `meta/main.yml`. + +## Features + +- **Owned per role:** Each consumer declares one table under its own name, so rules are added and retired with the role that needs them. +- **Atomic:** A fragment is applied in a single `nft -f` transaction, so the table is never observed half built. +- **Persistent:** `nftables.service` loads every fragment at boot, so rules outlive a reboot without a unit of this project's own. The ruleset it reads is a distribution decision, so the path follows `os_family`: RedHat reads `/etc/sysconfig/nftables.conf`, the others `/etc/nftables.conf`. +- **Independent of docker:** Docker owns its own tables and never touches these, so no rule has to be inserted into a chain another component controls. The unit is only enabled, never started or restarted: on Debian its `ExecStop` flushes the entire ruleset, docker's tables included, and the fragments are loaded explicitly anyway. +- **Verified:** `nft -c` rejects a fragment before it is written, and the loaded table is read back and refused when it carries no chain. A table that exists is not a rule that runs, so the policy itself is proven by the role that owns it. + +## Use Cases + +- Bounding a service port that carries no access policy of its own, where the packet filter is the only boundary available. +- Redirecting or translating traffic a role owns, scoped to an address range nothing else claims. + +A fragment can only ever be stricter than the host already is. In nftables an `accept` terminates its own base chain and nothing more: the packet still traverses every other chain at that hook, and a `drop` in any of them wins. A rule that has to overrule a policy someone else set, such as the `DROP` docker installs on forwarded packets, does not belong in a fragment at all; it belongs in the chain that carries that policy. + +## Quick Setup + +### Development + +Clone, set up the workstation, and deploy Firewall ruleset onto the local stack: + +```bash +git clone https://github.com/infinito-nexus/core.git +cd core +make onboard +make compose-deploy mode=reinstall apps=svc-net-firewall full_cycle=false +``` + +### Production + +Install Firewall ruleset directly onto the target machine: clone the repository, install the OS prerequisites and the repository toolchain, then deploy against localhost over a local connection (no SSH, no container): + +```bash +git clone https://github.com/infinito-nexus/core.git +cd core +bash scripts/install/package.sh +make install +source scripts/meta/env/load.sh + +APP=svc-net-firewall +DOMAIN= +TLS_MODE=self_signed +SSH_PUBLIC_KEY="" +INVENTORY=inventories/production +infinito administration inventory provision "$INVENTORY" \ + --inventory-file "$INVENTORY/devices.yml" \ + --host localhost \ + --include "$APP" \ + --vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" +infinito administration deploy dedicated "$INVENTORY/devices.yml" \ + --password-file "$INVENTORY/.password" \ + --diff -vv +``` + +## Further Resources + +- [nftables Wiki](https://wiki.nftables.org/) +- [nft(8) manual page](https://www.netfilter.org/projects/nftables/manpage.html) +- [Moving from iptables to nftables](https://wiki.nftables.org/wiki-nftables/index.php/Moving_from_iptables_to_nftables) + +## Credits + +Implemented by **[Kevin Veen-Birkenbach](https://www.veen.world)**. +Part of the [Infinito.Nexus Project](https://s.infinito.nexus/code) and maintained by [Kevin Veen-Birkenbach](https://www.veen.world). +Licensed under the [Infinito.Nexus Community License (Non-Commercial)](https://s.infinito.nexus/license). diff --git a/roles/svc-net-firewall/meta/info.yml b/roles/svc-net-firewall/meta/info.yml new file mode 100644 index 0000000000..ca99331c28 --- /dev/null +++ b/roles/svc-net-firewall/meta/info.yml @@ -0,0 +1,5 @@ +--- +logo: + class: fa-solid fa-shield-halved +homepage: https://www.netfilter.org/projects/nftables/ +video: https://www.youtube.com/watch?v=EGKhIljDPCw diff --git a/roles/svc-net-firewall/meta/main.yml b/roles/svc-net-firewall/meta/main.yml new file mode 100644 index 0000000000..93ca3d7d86 --- /dev/null +++ b/roles/svc-net-firewall/meta/main.yml @@ -0,0 +1,27 @@ +--- +galaxy_info: + author: Kevin Veen-Birkenbach + description: nftables packet filter whose rules each role owns as its own table, replaced atomically and reloaded at boot + company: | + Kevin Veen-Birkenbach + https://www.veen.world + license: Infinito.Nexus Community License (Non-Commercial) + min_ansible_version: "2.9" + galaxy_tags: + - firewall + - nftables + - netfilter + - security + issue_tracker_url: https://s.infinito.nexus/issues + platforms: + - name: ArchLinux + versions: [all] + - name: Debian + versions: [all] + - name: EL + versions: [all] + - name: Fedora + versions: [all] + - name: Ubuntu + versions: [all] +dependencies: [] diff --git a/roles/svc-net-firewall/meta/packages.yml b/roles/svc-net-firewall/meta/packages.yml new file mode 100644 index 0000000000..06709ff242 --- /dev/null +++ b/roles/svc-net-firewall/meta/packages.yml @@ -0,0 +1,8 @@ +--- +nftables: + Archlinux: + - nftables + Debian: + - nftables + RedHat: + - nftables diff --git a/roles/svc-net-firewall/meta/services.yml b/roles/svc-net-firewall/meta/services.yml new file mode 100644 index 0000000000..15c0b390d5 --- /dev/null +++ b/roles/svc-net-firewall/meta/services.yml @@ -0,0 +1,16 @@ +--- +# nocheck: playwright-service-flag host packet filter, no container and no web surface +firewall: + modes: + host: + enabled: true + compose: + enabled: false + swarm: + enabled: false + lifecycle: beta + min_storage: "50MB" + cpus: "0.1" + mem_reservation: "32m" + mem_limit: "64m" + pids_limit: 64 diff --git a/roles/svc-net-firewall/tasks/01_host.yml b/roles/svc-net-firewall/tasks/01_host.yml new file mode 100644 index 0000000000..9474747829 --- /dev/null +++ b/roles/svc-net-firewall/tasks/01_host.yml @@ -0,0 +1,32 @@ +--- +- name: "📦 Firewall | Install nftables" + become: true + package_install: + id: nftables + +- name: "📁 Firewall | Ensure the fragment directory exists" + become: true + ansible.builtin.file: + path: "{{ FIREWALL_FRAGMENT_DIR }}" + state: directory + owner: root + group: root + mode: "0750" + +- name: "🧱 Firewall | Install the ruleset every fragment is loaded from" + become: true + ansible.builtin.template: # nocheck: stack-host-template per-host packet filter: every host carrying rules needs the ruleset, not only the stack host + src: main.nft.j2 + dest: "{{ FIREWALL_MAIN_CONF }}" + owner: root + group: root + mode: "0640" + backup: true + +- name: "🚀 Firewall | Arm the ruleset for boot without touching the live one" + become: true + ansible.builtin.systemd_service: + name: "{{ FIREWALL_SERVICE }}" + enabled: true + +- include_tasks: utils/once/flag.yml diff --git a/roles/svc-net-firewall/tasks/02_fragment.yml b/roles/svc-net-firewall/tasks/02_fragment.yml new file mode 100644 index 0000000000..5ed9c5900e --- /dev/null +++ b/roles/svc-net-firewall/tasks/02_fragment.yml @@ -0,0 +1,43 @@ +--- +- name: "🔍 Firewall | Resolve the fragment of '{{ FIREWALL_FRAGMENT_ROLE }}'" + ansible.builtin.set_fact: + firewall_fragment_src: >- + {{ lookup('first_found', + { + 'files': [ FIREWALL_FRAGMENT_SRC ], + 'paths': [ lookup('path_absolute', 'roles', FIREWALL_FRAGMENT_ROLE) ] + } + ) }} + firewall_loaded_table: "{{ FIREWALL_TABLE }}" + +- name: "🧱 Firewall | Stage the ruleset of '{{ FIREWALL_FRAGMENT_ROLE }}'" + become: true + ansible.builtin.template: # nocheck: stack-host-template per-host packet filter: every host carrying the rules needs the fragment, not only the stack host + src: "{{ firewall_fragment_src }}" + dest: "{{ FIREWALL_FRAGMENT_FILE }}" + owner: root + group: root + mode: "0640" + validate: "nft -c -f %s" + +- name: "🚀 Firewall | Load the ruleset of '{{ FIREWALL_FRAGMENT_ROLE }}'" + become: true + ansible.builtin.command: + argv: [nft, -f, "{{ FIREWALL_FRAGMENT_FILE }}"] + changed_when: true + +- name: "🔍 Firewall | Read back the table of '{{ FIREWALL_FRAGMENT_ROLE }}'" + become: true + ansible.builtin.command: + argv: [nft, list, table, inet, "{{ FIREWALL_TABLE }}"] + register: firewall_loaded_ruleset + changed_when: false + +- name: "✅ Firewall | Refuse a table that carries no chain" + ansible.builtin.assert: + that: + - "'chain ' in firewall_loaded_ruleset.stdout" + fail_msg: >- + {{ FIREWALL_FRAGMENT_ROLE }} loaded {{ FIREWALL_TABLE }} but the table + holds no chain, so nothing it declares is hooked into the packet path. + A table that exists is not a rule that runs. diff --git a/roles/svc-net-firewall/tasks/main.yml b/roles/svc-net-firewall/tasks/main.yml new file mode 100644 index 0000000000..435c971317 --- /dev/null +++ b/roles/svc-net-firewall/tasks/main.yml @@ -0,0 +1,8 @@ +--- +- name: "🧱 Firewall | Prepare the host ruleset" + ansible.builtin.include_tasks: 01_host.yml + when: run_once_svc_net_firewall is not defined + +- name: "🧱 Firewall | Apply the ruleset of '{{ FIREWALL_FRAGMENT_ROLE }}'" + ansible.builtin.include_tasks: 02_fragment.yml + when: FIREWALL_FRAGMENT_ROLE | length > 0 diff --git a/roles/svc-net-firewall/templates/main.nft.j2 b/roles/svc-net-firewall/templates/main.nft.j2 new file mode 100644 index 0000000000..ef967514c8 --- /dev/null +++ b/roles/svc-net-firewall/templates/main.nft.j2 @@ -0,0 +1,3 @@ +#!/usr/sbin/nft -f +# Managed by {{ SOFTWARE_NAME }} (roles/svc-net-firewall). Do not edit by hand. +include "{{ FIREWALL_FRAGMENT_DIR }}/*.conf" diff --git a/roles/svc-net-firewall/vars/main.yml b/roles/svc-net-firewall/vars/main.yml new file mode 100644 index 0000000000..0303fda6d6 --- /dev/null +++ b/roles/svc-net-firewall/vars/main.yml @@ -0,0 +1,15 @@ +--- +application_id: svc-net-firewall + +FIREWALL_MAIN_CONF_BY_OS_FAMILY: + Archlinux: "/etc/nftables.conf" + Debian: "/etc/nftables.conf" + RedHat: "/etc/sysconfig/nftables.conf" + +FIREWALL_MAIN_CONF: "{{ FIREWALL_MAIN_CONF_BY_OS_FAMILY[ansible_facts['os_family']] }}" +FIREWALL_FRAGMENT_DIR: "/etc/nftables.d" +FIREWALL_SERVICE: "nftables.service" +FIREWALL_FRAGMENT_SRC: "templates/nftables.conf.j2" +FIREWALL_FRAGMENT_ROLE: "{{ ansible_parent_role_names | default([]) | first | default('') }}" +FIREWALL_FRAGMENT_FILE: "{{ [ FIREWALL_FRAGMENT_DIR, FIREWALL_FRAGMENT_ROLE ~ '.conf' ] | path_join }}" +FIREWALL_TABLE: "infinito_{{ FIREWALL_FRAGMENT_ROLE | regex_replace('[^a-zA-Z0-9]', '_') }}" diff --git a/roles/svc-net-tor/README.md b/roles/svc-net-tor/README.md index 0dfdf7a21c..1091186daf 100644 --- a/roles/svc-net-tor/README.md +++ b/roles/svc-net-tor/README.md @@ -86,19 +86,20 @@ Run the published image to provision the inventory and deploy Tor Onion Service APP=svc-net-tor HOST= DOMAIN= +DNS= TLS_MODE=self_signed SSH_PUBLIC_KEY="" docker run --rm -it \ -v "$PWD/inventories:/etc/infinito.nexus/inventories" \ - -e APP="$APP" -e HOST="$HOST" -e DOMAIN="$DOMAIN" -e TLS_MODE="$TLS_MODE" -e SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \ + -e APP="$APP" -e HOST="$HOST" -e DOMAIN="$DOMAIN" -e DNS="$DNS" -e TLS_MODE="$TLS_MODE" -e SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \ ghcr.io/infinito-nexus/core/debian bash -c ' INVENTORY=/etc/infinito.nexus/inventories/production infinito administration inventory provision "$INVENTORY" \ --inventory-file "$INVENTORY/devices.yml" \ --host "$HOST" \ --include "$APP" \ - --vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" && + --vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"networks\": {\"internet\": {\"dns\": \"$DNS\"}}, \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" && infinito administration deploy dedicated "$INVENTORY/devices.yml" \ --password-file "$INVENTORY/.password" \ --diff -vv' diff --git a/roles/svc-net-tor/files/test/onion_ports.py b/roles/svc-net-tor/files/test/onion_ports.py new file mode 100755 index 0000000000..adb6a81c66 --- /dev/null +++ b/roles/svc-net-tor/files/test/onion_ports.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Assert every TCP port the node onion forwards actually accepts a connection. + +The ports are the ones roles ask for by name under ports.onion, plus any +service opting in with exposed: true, which is what lookup('tor_ports') returns +and what torrc turns into HiddenServicePort lines. The unconditional SSH forward +is deliberately not probed: it targets a host service this role does not deploy, +so its absence says nothing about the forwards under test. + +The HTTP probe in test.sh only sees the reverse proxy's vhosts, so mail and +every other named port went unchecked, and from outside a forwarded port that +reaches nothing looks exactly like a port that never reached torrc at all. + +A port passes when the SOCKS proxy reports CONNECT success, which it does only +after Tor has established the stream to the target behind the onion. One pass +probes every port and only the failures are retried, so a green run costs one +connect per port and a red one cannot multiply the retry budget by the number of +healthy ports. + +Standard library only: this runs wherever the CLI test runs, not in a container +carrying a test toolchain. + +Env (rendered into test.env from templates/test.env.j2): + TOR_SOCKS SOCKS proxy, host:port + ONION_HOST the node onion host + ONION_PORTS space-separated forwarded ports; empty means there is + nothing to check + ONION_PORT_TIMEOUT seconds per connect attempt. 60 for the same reason the + domain probe uses it: Tor's SocksTimeout default is 120s + and abandoning earlier is destructive, because the next + attempt opens a fresh SOCKS connection and throws away + the descriptor fetches and rendezvous retries the last + one was still working through + ONION_PORT_RETRIES passes over the failing ports. Below the domain probe's + RETRIES because this phase multiplies by the port count, + and meta/tests.yml caps the whole CLI test + SLEEP_SECONDS wait between passes +""" + +from __future__ import annotations + +import os +import secrets +import socket +import sys +import time + +_GREETING_USER_PASS = b"\x05\x01\x02" +_GREETING_OK = b"\x05\x02" +_AUTH_VERSION = 0x01 +_AUTH_OK = b"\x01\x00" +_CONNECT_TO_DOMAIN = b"\x05\x01\x00\x03" +_REPLY_SUCCESS = 0x00 + + +class ProbeError(RuntimeError): + """A port did not answer, with the reason a reader needs to act on it.""" + + +def _required(name: str, hint: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise SystemExit(f"pass {name} as env ({hint})") + return value + + +def connect_through_socks(proxy: str, host: str, port: int, timeout: float) -> None: + """Open a SOCKS5 CONNECT to ``host:port`` and raise when it is refused. + + Every call authenticates with fresh random credentials. Tor accepts any and, + under its default IsolateSOCKSAuth, builds a separate rendezvous circuit for + each. Tor never moves an onion stream off the rendezvous circuit it attached + to, so a retry sharing the credentials of the failed attempt would wait on + the same dead circuit again. + """ + proxy_host, _, proxy_port = proxy.rpartition(":") + token = secrets.token_hex(8).encode() + try: + sock = socket.create_connection((proxy_host, int(proxy_port)), timeout=timeout) + except OSError as error: + raise ProbeError(f"socks proxy unreachable: {error}") from None + + with sock: + sock.settimeout(timeout) + try: + sock.sendall(_GREETING_USER_PASS) + if sock.recv(2) != _GREETING_OK: + raise ProbeError("socks proxy refused the username/password method") + sock.sendall( + bytes([_AUTH_VERSION, len(token)]) + token + bytes([len(token)]) + token + ) + if sock.recv(2) != _AUTH_OK: + raise ProbeError("socks proxy rejected the isolation credentials") + target = host.encode() + sock.sendall( + _CONNECT_TO_DOMAIN + + bytes([len(target)]) + + target + + port.to_bytes(2, "big") + ) + reply = sock.recv(4) + except OSError as error: + raise ProbeError(f"socks handshake failed: {error}") from None + if len(reply) < 2: + raise ProbeError("socks proxy closed the connection") + if reply[1] != _REPLY_SUCCESS: + raise ProbeError(f"socks CONNECT failed with reply 0x{reply[1]:02x}") + + +def probe( + *, + proxy: str, + host: str, + ports: list[int], + timeout: float, + retries: int, + sleep_seconds: float, + sleeper=time.sleep, +) -> dict[int, str]: + """Return the ports still failing after the retries, with their reason.""" + pending = list(ports) + reasons: dict[int, str] = {} + for attempt in range(1, retries + 1): + still: list[int] = [] + for port in pending: + try: + connect_through_socks(proxy, host, port, timeout) + except ProbeError as error: + reasons[port] = str(error) + still.append(port) + else: + reasons.pop(port, None) + print(f"[OK] {host}:{port} connect") + pending = still + if not pending or attempt == retries: + break + print( + f"[INFO] pass {attempt + 1}/{retries} retries " + f"{len(pending)} port(s) in {sleep_seconds}s" + ) + sleeper(sleep_seconds) + return {port: reasons[port] for port in pending} + + +def main() -> int: + proxy = _required("TOR_SOCKS", "host:port of the node SOCKS proxy") + host = _required("ONION_HOST", "svc-net-tor services.tor.node") + retries = int(_required("ONION_PORT_RETRIES", "passes over the failing ports")) + sleep_seconds = float(_required("SLEEP_SECONDS", "wait between passes")) + timeout = float( + _required("ONION_PORT_TIMEOUT", "seconds per connect, flavor-dependent") + ) + ports = [int(entry) for entry in os.environ.get("ONION_PORTS", "").split()] + + if not ports: + print("[INFO] no onion-forwarded ports declared; nothing to probe") + return 0 + + print( + f"[INFO] probing {len(ports)} onion-forwarded port(s) on {host} " + f"via socks5://{proxy} ({timeout}s per connect)" + ) + failures = probe( + proxy=proxy, + host=host, + ports=ports, + timeout=timeout, + retries=retries, + sleep_seconds=sleep_seconds, + ) + for port, reason in failures.items(): + print(f"[FAIL] {host}:{port} {reason}") + print(f"[INFO] {len(ports)} port(s) probed, {len(failures)} failed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/roles/svc-net-tor/files/test/test.sh b/roles/svc-net-tor/files/test/test.sh index 67bb92e08e..68677e034b 100755 --- a/roles/svc-net-tor/files/test/test.sh +++ b/roles/svc-net-tor/files/test/test.sh @@ -99,4 +99,7 @@ for domain in "${domains[@]}"; do done echo "[INFO] ${#domains[@]} probed, ${failed} failed" + +"$(dirname "${BASH_SOURCE[0]}")/onion_ports.py" || failed=$((failed + 1)) + [[ "${failed}" -eq 0 ]] diff --git a/roles/svc-net-tor/lookup_plugins/tor_egress_clients.py b/roles/svc-net-tor/lookup_plugins/tor_egress_clients.py new file mode 100644 index 0000000000..bd0a9647ae --- /dev/null +++ b/roles/svc-net-tor/lookup_plugins/tor_egress_clients.py @@ -0,0 +1,101 @@ +"""Lookup `tor_egress_clients`: the address space Tor's egress ports admit. + +Deliberately not the three RFC1918 blocks: 10.0.0.0/8 would hand the operator's +whole LAN a resolver and a transparent proxy that carry no access policy of +their own. What is admitted is what this deployment declares for itself, in rule +order: loopback, docker's address pools, the swarm gateway bridge, the role +subnet pool, and the subnets of the roles that sit outside it. + +Usage: + + {{ lookup('tor_egress_clients') }} +""" + +from __future__ import annotations + +from typing import Any + +from ansible.errors import AnsibleError +from ansible.plugins.loader import lookup_loader +from ansible.plugins.lookup import LookupBase +from ansible.template import trust_as_template + +_REQUIRED = ( + "NETWORK_LOOPBACK_CIDR", + "NETWORK_DOCKER_ADDRESS_POOLS", + "NETWORK_SWARM_GWBRIDGE_POOL", + "NETWORK_ROLE_SUBNET_POOL", + "NETWORK_ROLE_SUBNET_EXCEPTION_ROLES", +) + + +def _rendered(templar: Any, value: Any) -> str: + """A variable's value with its Jinja resolved. + + Group vars reach a lookup as whatever the file holds, and several of these + are references rather than literals. Passing one through unrendered would + put the template text itself into a source address, where it cannot match + and therefore silently narrows the admitted set. + """ + text = "" if value is None else str(value).strip() + if "{{" not in text or templar is None: + return text + rendered = str(templar.template(trust_as_template(text))).strip() + if "{{" in rendered: + raise AnsibleError(f"tor_egress_clients: {text!r} did not render to an address") + return rendered + + +class LookupModule(LookupBase): + def run( + self, + terms: list[Any] | None, + variables: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Any]: + if terms: + raise AnsibleError("lookup('tor_egress_clients') expects no terms.") + variables = variables or getattr(self._templar, "available_variables", {}) or {} + missing = [name for name in _REQUIRED if variables.get(name) is None] + if missing: + raise AnsibleError( + "tor_egress_clients: missing variable(s) " + f"{', '.join(missing)}; an incomplete list would install a guard " + "that admits more than it was asked to" + ) + + templar = getattr(self, "_templar", None) + excepted = lookup_loader.get( + "role_subnets", + loader=getattr(self, "_loader", None), + templar=templar, + ).run( + [variables["NETWORK_ROLE_SUBNET_EXCEPTION_ROLES"]], + variables=variables, + )[0] + + pools = variables["NETWORK_DOCKER_ADDRESS_POOLS"] + if not isinstance(pools, (list, tuple)): + raise AnsibleError( + "tor_egress_clients: NETWORK_DOCKER_ADDRESS_POOLS must be a list" + ) + bases = [] + for pool in pools: + base = pool.get("base") if isinstance(pool, dict) else None + if not base: + raise AnsibleError( + f"tor_egress_clients: address pool {pool!r} declares no base" + ) + bases.append(_rendered(templar, base)) + + ordered = [ + _rendered(templar, variables["NETWORK_LOOPBACK_CIDR"]), + *bases, + _rendered(templar, variables["NETWORK_SWARM_GWBRIDGE_POOL"]), + _rendered(templar, variables["NETWORK_ROLE_SUBNET_POOL"]), + *[_rendered(templar, subnet) for subnet in excepted], + ] + seen: dict[str, None] = {} + for cidr in ordered: + seen.setdefault(cidr, None) + return [list(seen)] diff --git a/roles/svc-net-tor/lookup_plugins/tor_extra_ports.py b/roles/svc-net-tor/lookup_plugins/tor_extra_ports.py new file mode 100644 index 0000000000..f5f90a95d1 --- /dev/null +++ b/roles/svc-net-tor/lookup_plugins/tor_extra_ports.py @@ -0,0 +1,100 @@ +"""Lookup `tor_extra_ports`: every port the node onion forwards. + +Two of them are flags rather than derivations, because no inventory carries +them. ``lookup('tor_ports')`` walks ``group_names`` and collects what the roles +listed there declare under ``ports.onion``; SSH belongs to no role at all, and +HTTP belongs to ``svc-prx-openresty``, which a node acquires through +``sys-stk-front-proxy`` and which therefore appears in no dependency list an +inventory holds. A node provisioned with ``--include svc-net-tor`` alone +published an onion forwarding SSH and nothing else, leaving every app it serves +unreachable over that onion. + +Forwarding HTTP is what this role exists for, so it is declared, and a node that +must not answer HTTP over its onion turns ``TOR_ONION_HTTP_ENABLED`` off. + +The derived ports still come from ``tor_ports``; a port named twice collapses, +since ``torrc.j2`` writes one ``HiddenServicePort`` line per entry. + +Usage: + + {{ lookup('tor_extra_ports') }} +""" + +from __future__ import annotations + +from typing import Any + +from ansible.errors import AnsibleError +from ansible.module_utils.parsing.convert_bool import boolean +from ansible.plugins.loader import lookup_loader +from ansible.plugins.lookup import LookupBase +from ansible.template import trust_as_template + +FLAGGED_PORTS: tuple[tuple[str, int], ...] = ( + ("TOR_ONION_SSH_ENABLED", 22), + ("TOR_ONION_HTTP_ENABLED", 80), +) + + +def _flag(templar: Any, name: str, value: Any) -> bool: + """A declared flag as a boolean, with its Jinja resolved first. + + Args: + templar: the lookup's templar, or None when it has none. + name: the variable the flag came from, for the error message. + value: whatever the variable holds; group vars reach a lookup + unrendered, so a reference arrives as its own template text. + """ + if isinstance(value, str) and "{{" in value and templar is not None: + value = templar.template(trust_as_template(value)) + try: + return boolean(value) + except TypeError as exc: + raise AnsibleError( + f"lookup('tor_extra_ports'): {name} is not a boolean: {value!r}" + ) from exc + + +def _entry(port: int) -> dict[str, Any]: + """One forward, in the shape ``torrc.j2`` consumes. + + Args: + port: the port the onion answers on; the backend host is substituted by + the template, so the target carries the port alone. + """ + return {"onion_port": port, "target": f"127.0.0.1:{port}"} + + +class LookupModule(LookupBase): + def run( + self, + terms: list[Any] | None, + variables: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Any]: + if terms: + raise AnsibleError("lookup('tor_extra_ports') expects no terms.") + + variables = variables or getattr(self._templar, "available_variables", {}) or {} + + entries: list[dict[str, Any]] = [] + for name, port in FLAGGED_PORTS: + if name not in variables: + raise AnsibleError(f"lookup('tor_extra_ports'): {name} is not defined") + if _flag(getattr(self, "_templar", None), name, variables[name]): + entries.append(_entry(port)) + + derived = lookup_loader.get( + "tor_ports", + loader=getattr(self, "_loader", None), + templar=getattr(self, "_templar", None), + ).run([], variables=variables, **kwargs)[0] + + seen = {entry["onion_port"] for entry in entries} + for entry in derived: + if entry["onion_port"] in seen: + continue + seen.add(entry["onion_port"]) + entries.append(entry) + + return [entries] diff --git a/roles/svc-net-tor/meta/services.yml b/roles/svc-net-tor/meta/services.yml index 0b3345feac..8ea686d6c8 100644 --- a/roles/svc-net-tor/meta/services.yml +++ b/roles/svc-net-tor/meta/services.yml @@ -20,12 +20,17 @@ tor: # nocheck: category-modes exclusive: true primary: true image: debian - version: trixie-slim + version: 13.7-slim custom: true name: tor node: "" use_entry_guards: true flavor: public + forwards: + ssh: true + http: true + egress: + enabled: true timeout_multipliers: chutney: 2 public: 5 @@ -34,9 +39,16 @@ tor: # nocheck: category-modes socks: 9050 trans: 9040 dns: 9053 + onion: + # nocheck: onion-flag a SOCKS port anyone can reach is an open proxy, and SocksPolicy cannot refuse it because the forward arrives from the node's own address + socks: false + # nocheck: onion-flag the transparent port takes its destination from the caller's routing, which an onion client does not share + trans: false + # nocheck: onion-flag publishing the resolver would answer recursive queries for anyone holding the onion address + dns: false min_storage: 100MB cpus: "0.25" mem_reservation: 128m mem_limit: 256m pids_limit: 128 - lifecycle: alpha + lifecycle: beta diff --git a/roles/svc-net-tor/tasks/00_core.yml b/roles/svc-net-tor/tasks/00_core.yml index 7cf9dfc626..b7cbc59ccb 100644 --- a/roles/svc-net-tor/tasks/00_core.yml +++ b/roles/svc-net-tor/tasks/00_core.yml @@ -125,7 +125,9 @@ file: router.yml apply: become: true - when: TOR_EGRESS_ROUTER_ENABLED | bool + when: + - TOR_EGRESS_ROUTER_ENABLED | bool + - DEPLOYMENT_MODE == 'compose' or (IS_STACK_HOST | bool) - name: "🧅 Verify the node onion is reachable" ansible.builtin.include_tasks: onion_reach.yml diff --git a/roles/svc-net-tor/tasks/router.yml b/roles/svc-net-tor/tasks/router.yml index 3ab0421d00..08f4119e8e 100644 --- a/roles/svc-net-tor/tasks/router.yml +++ b/roles/svc-net-tor/tasks/router.yml @@ -68,7 +68,7 @@ ansible.builtin.systemd: name: dnsmasq enabled: true - daemon_reload: "{{ _tor_dnsmasq_dropin is changed }}" + daemon_reload: "{{ TOR_DNSMASQ_OWNS_LISTENER | bool and _tor_dnsmasq_dropin is changed }}" state: "{{ 'restarted' if (_tor_dnsmasq_conf is changed or _tor_dnsmasq_confdir is changed) else 'started' }}" - name: "🔀 Egress router | Route host name resolution through dnsmasq" when: TOR_DNSMASQ_OWNS_LISTENER | bool @@ -88,12 +88,22 @@ regexp: "^(hosts:.*\\bresolve)\\s*\\[!UNAVAIL=return\\](.*)$" line: "\\1 [SUCCESS=return NOTFOUND=continue TRYAGAIN=return]\\2" backrefs: true -- name: "🔀 Egress router | Redirect .onion virtual-IP TCP to Tor TransPort" - ansible.builtin.iptables: - table: nat - chain: OUTPUT - protocol: tcp - destination: "{{ TOR_EGRESS_VIRTUAL_NET_IPV4 }}" - jump: REDIRECT - to_ports: "{{ TOR_TRANS_PORT | string }}" - comment: "Tor egress router: .onion virtual range -> TransPort" +- name: "🔀 Egress router | Route .onion traffic and bound the ports that carry no policy" + ansible.builtin.include_role: + name: svc-net-firewall + +- name: "🛡️ Egress guard | Read back the chain that bounds the ports" + become: true + ansible.builtin.command: + argv: [nft, list, chain, inet, "{{ firewall_loaded_table }}", guard] + register: tor_egress_guard_chain + changed_when: false + +- name: "🛡️ Egress guard | Refuse a guard that drops nothing" + ansible.builtin.assert: + that: + - "'drop' in tor_egress_guard_chain.stdout" + fail_msg: >- + The guard chain in {{ firewall_loaded_table }} carries no drop, so + TransPort and DNSPort are reachable from every source. These two ports + have no access policy of their own; this chain is the only boundary. diff --git a/roles/svc-net-tor/templates/compose.yml.j2 b/roles/svc-net-tor/templates/compose.yml.j2 index 4284a342ca..81c0ffdaec 100644 --- a/roles/svc-net-tor/templates/compose.yml.j2 +++ b/roles/svc-net-tor/templates/compose.yml.j2 @@ -12,11 +12,13 @@ {{ lookup('compose_only', 'container_name', TOR_CONTAINER) }} {{ lookup('container_image', application_id, 'tor') }} {% if DEPLOYMENT_MODE == 'swarm' %} - ports: - - target: {{ TOR_SOCKS_PORT }} - published: {{ TOR_SOCKS_PORT }} - protocol: tcp - mode: host + {{ lookup('container_ports', + {'service': 'tor', 'protocol': 'socks', 'mode': 'host', + 'container': 'same'}, + {'service': 'tor', 'protocol': 'dns', 'mode': 'host', + 'container': 'same', 'transport': ['udp', 'tcp']}, + {'service': 'tor', 'protocol': 'trans', 'mode': 'host', + 'container': 'same', 'transport': 'tcp'}) | indent(4) }} {{ lookup('container_networks') | indent(4) }} {% else %} network_mode: "host" diff --git a/roles/svc-net-tor/templates/nftables.conf.j2 b/roles/svc-net-tor/templates/nftables.conf.j2 new file mode 100644 index 0000000000..eab3aa5bb0 --- /dev/null +++ b/roles/svc-net-tor/templates/nftables.conf.j2 @@ -0,0 +1,52 @@ +{# Loaded by roles/svc-net-firewall into {{ FIREWALL_TABLE }}. #} +table inet {{ FIREWALL_TABLE }} +delete table inet {{ FIREWALL_TABLE }} +table inet {{ FIREWALL_TABLE }} { + set containers { + type ipv4_addr + flags interval + elements = { {{ TOR_EGRESS_CLIENT_CIDRS | join(', ') }} } + } + + set internal { + type ipv4_addr + flags interval + elements = { {{ NETWORK_INTERNAL_CIDRS | join(', ') }} } + } + +{% set _tcp_guarded = TOR_EGRESS_GUARDED_PORTS | selectattr('protocol', 'equalto', 'tcp') | map(attribute='port') | unique | list %} +{% set _udp_guarded = TOR_EGRESS_GUARDED_PORTS | selectattr('protocol', 'equalto', 'udp') | map(attribute='port') | unique | list %} + chain guard { +{% for entry in TOR_EGRESS_GUARDED_PORTS %} + ip saddr != @{{ entry.admitted }} {{ entry.protocol }} dport {{ entry.port }} drop +{% endfor %} +{% if _tcp_guarded | length > 0 %} + fib saddr . iif oif missing tcp dport { {{ _tcp_guarded | join(', ') }} } drop +{% endif %} +{% if _udp_guarded | length > 0 %} + fib saddr . iif oif missing udp dport { {{ _udp_guarded | join(', ') }} } drop +{% endif %} + } + + chain input { + type filter hook input priority -10; policy accept; + jump guard + } + + chain forward { + type filter hook forward priority -10; policy accept; + jump guard + } + + chain output { + type nat hook output priority -150; policy accept; + ip daddr {{ TOR_EGRESS_VIRTUAL_NET_IPV4 }} meta l4proto tcp redirect to :{{ TOR_TRANS_PORT }} + } +{% if TOR_CONTAINER_DNS_HOST | length > 0 %} + + chain prerouting { + type nat hook prerouting priority -150; policy accept; + ip daddr {{ TOR_EGRESS_VIRTUAL_NET_IPV4 }} meta l4proto tcp dnat ip to {{ TOR_CONTAINER_DNS_HOST }}:{{ TOR_TRANS_PORT }} + } +{% endif %} +} diff --git a/roles/svc-net-tor/templates/test.env.j2 b/roles/svc-net-tor/templates/test.env.j2 index 10028ca3a0..6e0bec9054 100644 --- a/roles/svc-net-tor/templates/test.env.j2 +++ b/roles/svc-net-tor/templates/test.env.j2 @@ -1,4 +1,8 @@ NGINX_SERVERS_DIR={{ lookup('nginx', 'directories.configuration.servers') }} +ONION_HOST={{ lookup('config', 'svc-net-tor', 'services.tor.node') }} +ONION_PORTS={{ lookup('tor_ports') | map(attribute='onion_port') | unique | sort | join(' ') | dotenv_quote }} +ONION_PORT_RETRIES=2 +ONION_PORT_TIMEOUT=60 RETRIES=5 SLEEP_SECONDS=10 TOR_DNSMASQ_CONF={{ TOR_DNSMASQ_CONF }} diff --git a/roles/svc-net-tor/templates/torrc.j2 b/roles/svc-net-tor/templates/torrc.j2 index b2167aeea9..20e1dddbbb 100644 --- a/roles/svc-net-tor/templates/torrc.j2 +++ b/roles/svc-net-tor/templates/torrc.j2 @@ -1,13 +1,12 @@ -# Managed by Infinito.Nexus (roles/svc-net-tor) — do not edit by hand. +# Managed by {{ SOFTWARE_NAME }} (roles/svc-net-tor) — do not edit by hand. RunAsDaemon 0 Log notice stdout DataDirectory {{ lookup('volume', 'svc-net-tor', 'tor_data').target }} SocksPort {{ lookup('tor_socks', '0.0.0.0') }} -SocksPolicy accept 127.0.0.0/8 -SocksPolicy accept 10.0.0.0/8 -SocksPolicy accept 172.16.0.0/12 -SocksPolicy accept 192.168.0.0/16 +{% for _cidr in NETWORK_INTERNAL_CIDRS %} +SocksPolicy accept {{ _cidr }} +{% endfor %} SocksPolicy reject * ControlPort 127.0.0.1:9051 CookieAuthentication 1 @@ -24,12 +23,20 @@ UseEntryGuards 0 AutomapHostsOnResolve 1 AutomapHostsSuffixes .onion VirtualAddrNetworkIPv4 {{ TOR_EGRESS_VIRTUAL_NET_IPV4 }} +{% if DEPLOYMENT_MODE == 'swarm' %} +TransPort 0.0.0.0:{{ TOR_TRANS_PORT }} +DNSPort 0.0.0.0:{{ TOR_DNS_PORT }} +{% else %} TransPort 127.0.0.1:{{ TOR_TRANS_PORT }} DNSPort 127.0.0.1:{{ TOR_DNS_PORT }} +{% if TOR_CONTAINER_DNS_HOST | length > 0 %} +TransPort {{ TOR_CONTAINER_DNS_HOST }}:{{ TOR_TRANS_PORT }} +DNSPort {{ TOR_CONTAINER_DNS_HOST }}:{{ TOR_DNS_PORT }} +{% endif %} +{% endif %} {% endif %} HiddenServiceDir /var/lib/tor/infinito/node/ HiddenServiceVersion 3 -HiddenServicePort 80 {{ TOR_HS_BACKEND_HOST }}:80 {% for _p in TOR_ONION_EXTRA_PORTS %} HiddenServicePort {{ _p.onion_port }} {{ TOR_HS_BACKEND_HOST }}:{{ _p.target.rsplit(':', 1) | last }} {% endfor %} diff --git a/roles/svc-net-tor/vars/main.yml b/roles/svc-net-tor/vars/main.yml index 482668303f..f8245d1817 100644 --- a/roles/svc-net-tor/vars/main.yml +++ b/roles/svc-net-tor/vars/main.yml @@ -6,13 +6,40 @@ TOR_IMAGE: "{{ lookup('config', application_id, 'services.tor.image') }}" TOR_VERSION: "{{ lookup('config', application_id, 'services.tor.version') }}" TOR_CONTAINER: "{{ lookup('config', application_id, 'services.tor.name') }}" -TOR_SOCKS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.socks') }}" TOR_NODE_ONION: "{{ lookup('config', application_id, 'services.tor.node', '') }}" TOR_USE_ENTRY_GUARDS: "{{ lookup('config', application_id, 'services.tor.use_entry_guards') | bool }}" TOR_FLAVOR: "{{ lookup('config', application_id, 'services.tor.flavor') }}" +TOR_ONION_SSH_ENABLED: "{{ lookup('config', application_id, 'services.tor.forwards.ssh') | bool }}" +TOR_ONION_HTTP_ENABLED: "{{ lookup('config', application_id, 'services.tor.forwards.http') | bool }}" +TOR_ONION_EXTRA_PORTS: "{{ lookup('tor_extra_ports') }}" +TOR_EGRESS_ROUTER_ENABLED: "{{ lookup('config', application_id, 'services.tor.egress.enabled') | bool }}" +TOR_EGRESS_VIRTUAL_NET_IPV4: "{{ NETWORK_TOR_VIRTUAL_CIDR }}" TOR_TRANS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.trans') }}" TOR_DNS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.dns') }}" +TOR_SOCKS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.socks') }}" +TOR_DNSMASQ_PORT: "{{ NETWORK_DNS_PORT }}" TOR_DNSMASQ_CONF: "/etc/dnsmasq.d/tor-onion.conf" + +TOR_EGRESS_GUARDED_PORTS: + - port: "{{ TOR_TRANS_PORT }}" + protocol: tcp + admitted: containers + - port: "{{ TOR_DNS_PORT }}" + protocol: tcp + admitted: containers + - port: "{{ TOR_DNS_PORT }}" + protocol: udp + admitted: containers + - port: "{{ TOR_SOCKS_PORT }}" + protocol: tcp + admitted: internal + - port: "{{ TOR_DNSMASQ_PORT }}" + protocol: tcp + admitted: internal + - port: "{{ TOR_DNSMASQ_PORT }}" + protocol: udp + admitted: internal +TOR_EGRESS_CLIENT_CIDRS: "{{ lookup('tor_egress_clients') }}" TOR_DNSMASQ_DROPIN: "/etc/systemd/system/dnsmasq.service.d/infinito-restart.conf" # nocheck: host-local-fact meta/services.yml pins this role to the manager, so the fact describes the node the hidden service forwards on TOR_CONTAINER_DNS_HOST: "{{ ansible_facts['docker0']['ipv4']['address'] | default('', true) }}" diff --git a/roles/svc-prx-openresty/meta/services.yml b/roles/svc-prx-openresty/meta/services.yml index d94903c97f..70f57222ff 100644 --- a/roles/svc-prx-openresty/meta/services.yml +++ b/roles/svc-prx-openresty/meta/services.yml @@ -11,6 +11,17 @@ openresty: enabled: true shared: true name: openresty + ports: + internal: + http: 80 + https: 443 + public: + http: 80 + https: 443 + onion: + http: true + # nocheck: onion-flag the hidden service is already the transport encryption, and the onion address has no certificate a TLS listener could present + https: false healthcheck: test: ["CMD", "openresty", "-t", "-q"] min_storage: 264MB @@ -20,7 +31,7 @@ openresty: mem_reservation: 1g mem_limit: 2g image: openresty/openresty - version: alpine + version: 1.31.1.1-alpine custom: true lifecycle: beta pids_limit: 256 diff --git a/roles/svc-prx-openresty/meta/variants.yml b/roles/svc-prx-openresty/meta/variants.yml index 1576e2511e..a5e59537b6 100644 --- a/roles/svc-prx-openresty/meta/variants.yml +++ b/roles/svc-prx-openresty/meta/variants.yml @@ -2,6 +2,3 @@ - services: tor: enabled: true -- services: - tor: - enabled: true diff --git a/roles/svc-prx-openresty/templates/compose.yml.j2 b/roles/svc-prx-openresty/templates/compose.yml.j2 index 7d8f791608..b3bbe4721a 100644 --- a/roles/svc-prx-openresty/templates/compose.yml.j2 +++ b/roles/svc-prx-openresty/templates/compose.yml.j2 @@ -14,15 +14,9 @@ {{ lookup('compose_only', 'container_name', OPENRESTY_CONTAINER) }} {{ lookup('container_image', application_id, 'openresty') }} {% if DEPLOYMENT_MODE == 'swarm' %} - ports: - - target: 80 - published: 80 - protocol: tcp - mode: host - - target: 443 - published: 443 - protocol: tcp - mode: host + {{ lookup('container_ports', + {'service': 'openresty', 'protocol': 'http', 'mode': 'host'}, + {'service': 'openresty', 'protocol': 'https', 'mode': 'host'}) | indent(4) }} {{ lookup('container_extra_hosts', extra_hosts=['host.docker.internal:host-gateway']) | indent(4) }} {{ lookup('container_networks') | indent(4) }} {% else %} diff --git a/roles/svc-registry-cache/meta/services.yml b/roles/svc-registry-cache/meta/services.yml index 582e5433bd..69a32710b8 100644 --- a/roles/svc-registry-cache/meta/services.yml +++ b/roles/svc-registry-cache/meta/services.yml @@ -34,6 +34,9 @@ cache: registry: 5001 internal: registry: 5000 + onion: + # nocheck: onion-flag the pull-through mirror exists to serve this node's own image pulls; publishing it would relay Docker Hub for strangers + registry: false proxy: remote_url: "https://registry-1.docker.io" # nocheck: url lifecycle: beta diff --git a/roles/svc-registry-docker/meta/services.yml b/roles/svc-registry-docker/meta/services.yml index 5b2d65525d..66774e620e 100644 --- a/roles/svc-registry-docker/meta/services.yml +++ b/roles/svc-registry-docker/meta/services.yml @@ -34,6 +34,9 @@ docker: registry: 5000 internal: registry: 5000 + onion: + # nocheck: onion-flag the registry accepts unauthenticated pushes from the node that builds into it, so its reach must stay the node + registry: false lifecycle: beta # nocheck: dynamic-flag - locally built images are rebuildable, the backup consumer stays permanently off container_backup: diff --git a/roles/svc-runner/files/test/external.sh b/roles/svc-runner/files/test/external.sh index 28378ec774..6a36661122 100644 --- a/roles/svc-runner/files/test/external.sh +++ b/roles/svc-runner/files/test/external.sh @@ -33,7 +33,7 @@ else echo "OK: ${online_count} runner(s) online on GitHub (${RUNNER_GITHUB_OWNER}/${RUNNER_GITHUB_REPO})" fi -echo "Dispatching test-runner-smoke.yml against ref=${RUNNER_GIT_REF}..." +echo "Dispatching call-test-runner-smoke.yml against ref=${RUNNER_GIT_REF}..." dispatch_time=$(date -u +%Y-%m-%dT%H:%M:%SZ) dispatch_http=$(${CURL} -sf -o /dev/null -w "%{http_code}" \ diff --git a/roles/svc-runner/meta/services.yml b/roles/svc-runner/meta/services.yml index c02f122847..025ae72661 100644 --- a/roles/svc-runner/meta/services.yml +++ b/roles/svc-runner/meta/services.yml @@ -30,8 +30,7 @@ buildkit: enabled: false shared: false image: moby/buildkit - # nocheck: docker-version - version: "buildx-stable-1" + version: "v0.33.0" # nocheck: dynamic-flag act-runner: enabled: false diff --git a/roles/svc-storage-nfs-server/templates/exports.j2 b/roles/svc-storage-nfs-server/templates/exports.j2 index 471a61869d..830dfceee0 100644 --- a/roles/svc-storage-nfs-server/templates/exports.j2 +++ b/roles/svc-storage-nfs-server/templates/exports.j2 @@ -1,4 +1,4 @@ -# Managed by Infinito.Nexus Ansible role svc-storage-nfs-server. +# Managed by {{ SOFTWARE_NAME }} Ansible role svc-storage-nfs-server. {% for ip in svc_storage_nfs_server_allowed_ips %} {{ STORAGE_NFS_STATE_PATH }} {{ ip }}({{ SVC_STORAGE_NFS_SERVER_STATE_EXPORT_OPTIONS }},fsid=0) {% endfor %} diff --git a/roles/sys-ctl-alm-compose/templates/script.sh.j2 b/roles/sys-ctl-alm-compose/templates/script.sh.j2 index d9007ff9d5..ff85bcf637 100644 --- a/roles/sys-ctl-alm-compose/templates/script.sh.j2 +++ b/roles/sys-ctl-alm-compose/templates/script.sh.j2 @@ -1,4 +1,5 @@ #!/bin/bash +{# nocheck: shell-template grandfathered: one start block per subservice, each naming a unit only unit_name can resolve #} err=0 set -u start_timeout="{{ SYSTEMCTL_ALARM_COMPOSER_START_TIMEOUT_SECONDS }}" diff --git a/roles/sys-ctl-alm-email/templates/script.sh.j2 b/roles/sys-ctl-alm-email/templates/script.sh.j2 index b8b89bf89d..c9ec0bdf61 100644 --- a/roles/sys-ctl-alm-email/templates/script.sh.j2 +++ b/roles/sys-ctl-alm-email/templates/script.sh.j2 @@ -1,4 +1,5 @@ #!/bin/bash +{# nocheck: shell-template grandfathered, values only: the mail envelope, charset and timeout #} set -u # Reverse systemd escaping for human-readable mail subject/body friendly="$(systemd-escape --unescape "$1")" diff --git a/roles/sys-ctl-alm-telegram/templates/script.sh.j2 b/roles/sys-ctl-alm-telegram/templates/script.sh.j2 index 32a1fb0f94..726a842e4f 100644 --- a/roles/sys-ctl-alm-telegram/templates/script.sh.j2 +++ b/roles/sys-ctl-alm-telegram/templates/script.sh.j2 @@ -1,4 +1,5 @@ #!/bin/bash +{# nocheck: shell-template grandfathered, values only: bot token, chat id and the curl filter's timeout #} set -u # Make the instance text human readable again (reverse systemd escaping) diff --git a/roles/sys-ctl-cln-docker/templates/script.sh.j2 b/roles/sys-ctl-cln-docker/templates/script.sh.j2 index 4a7bd76017..5d66f0f6d7 100644 --- a/roles/sys-ctl-cln-docker/templates/script.sh.j2 +++ b/roles/sys-ctl-cln-docker/templates/script.sh.j2 @@ -1,4 +1,5 @@ #!/bin/sh +{# nocheck: shell-template grandfathered, values only: the versioned unit name it starts #} # Cleans up anonymous Docker volumes and performs a full Docker system prune. set -e diff --git a/roles/sys-ctl-hlth-csp/files/python/script.py b/roles/sys-ctl-hlth-csp/files/python/script.py index c79e8ddb2b..7d085e23eb 100755 --- a/roles/sys-ctl-hlth-csp/files/python/script.py +++ b/roles/sys-ctl-hlth-csp/files/python/script.py @@ -57,6 +57,33 @@ def is_skipped_domain(domain: str, skip_set: set[str], skip_labels: set[str]) -> return domain.endswith(".onion") and domain.split(".", 1)[0] in skip_labels +def expand_accept_status(accept_status: list[str], domains: list[str]) -> list[str]: + """ACCEPT_STATUS plus the same codes for every ``.onion`` sibling it covers. + + The checker matches accepted codes by hostname, and a clearnet vhost and + its onion twin share only the leftmost subdomain label, so a code declared + for ``mirror.`` never reaches ``mirror..onion`` — + which serves that same by-design status and would fail the probe. + + Args: + accept_status: ``=[,]`` entries as declared. + domains: every vhost the probe covers, clearnet and onion alike. + """ + onion = [d for d in domains if d.endswith(".onion")] + expanded = list(accept_status) + for entry in accept_status: + host, _, codes = entry.partition("=") + if not codes or host.endswith(".onion"): + continue + label = host.split(".", 1)[0] + expanded.extend( + f"{sibling}={codes}" + for sibling in onion + if sibling.split(".", 1)[0] == label + ) + return expanded + + def detect_scheme_from_conf(conf_path: Path) -> str | None: """ Decide whether this conf listens on HTTP or HTTPS. @@ -131,6 +158,7 @@ def build_docker_cmd( use_host_network: bool = True, proxy: str = "", timeout_ms: int = 0, + accept_status: list[str] | None = None, ) -> list[str]: cmd = ["container", "run", "--rm"] @@ -155,6 +183,12 @@ def build_docker_cmd( if ignore_network_blocks_from: cmd.append("--ignore-network-blocks-from") cmd.extend(ignore_network_blocks_from) + + if accept_status: + cmd.append("--accept-status") + cmd.extend(accept_status) + + if ignore_network_blocks_from or accept_status: cmd.append("--") cmd.extend(urls) @@ -170,6 +204,7 @@ def run_checker( use_host_network: bool = True, proxy: str = "", timeout_ms: int = 0, + accept_status: list[str] | None = None, ) -> int: """ Runs the CSP checker container and returns its exit code. @@ -186,6 +221,7 @@ def run_checker( use_host_network=use_host_network, proxy=proxy, timeout_ms=timeout_ms, + accept_status=accept_status, ) try: @@ -245,6 +281,17 @@ def main() -> None: "otherwise treat as unreachable." ), ) + parser.add_argument( + "--accept-status", + nargs="*", + default=[], + help=( + "Per-vhost status codes the probe must treat as healthy, as " + "=[,]. Declared in the role's " + "server.status_codes; everything below 300 passes without being " + "listed." + ), + ) parser.add_argument( "--onion-timeout", type=int, @@ -290,6 +337,8 @@ def main() -> None: f"--skip-domain: {skipped_present}" ) + accept_status = expand_accept_status(list(args.accept_status or []), domains) + clearnet_domains, onion_domains = split_onion_domains(domains) if onion_domains and not args.tor_proxy: print( @@ -322,6 +371,7 @@ def main() -> None: use_host_network=not bool(args.no_host_network), proxy=batch_proxy, timeout_ms=batch_timeout, + accept_status=accept_status, ) rc = rc or batch_rc sys.exit(rc) diff --git a/roles/sys-ctl-hlth-csp/lookup_plugins/csp_accept_status.py b/roles/sys-ctl-hlth-csp/lookup_plugins/csp_accept_status.py new file mode 100644 index 0000000000..56a815d8ab --- /dev/null +++ b/roles/sys-ctl-hlth-csp/lookup_plugins/csp_accept_status.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ansible.plugins.loader import lookup_loader +from ansible.plugins.lookup import LookupBase + +from utils.roles.applications.config import get +from utils.roles.applications.status_codes import declared_status_codes + +_CHECKER_ACCEPTS_BELOW = 300 + + +def _domains(value: Any) -> list[str]: + if isinstance(value, str): + return [value] if value else [] + if isinstance(value, Mapping): + out: list[str] = [] + for entry in value.values(): + out.extend(_domains(entry)) + return out + if isinstance(value, (list, tuple, set)): + out = [] + for entry in value: + out.extend(_domains(entry)) + return out + return [] + + +class LookupModule(LookupBase): + """Return ``=[,]`` for every declared non-2xx status. + + The checker treats anything outside 2xx as unreachable, so a vhost that + serves a 4xx by design needs its code named. Naming it keeps the page + under CSP inspection, which excluding the domain would not. + + The declaration is per vhost: ``server.status_codes`` is keyed like + ``domains.canonical``, so two domains of one role can differ. + """ + + def run( + self, + terms: list[Any] | None, + variables: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[list[str]]: + applications = lookup_loader.get( + "applications", + loader=self._loader, + templar=getattr(self, "_templar", None), + ).run( + [], + variables=variables + or getattr(self._templar, "available_variables", {}) + or {}, + )[0] + + if not isinstance(applications, Mapping): + return [[]] + + accepted: dict[str, set[int]] = {} + for app_id in applications: + domains = _domains( + get(applications, app_id, "domains.canonical", strict=False, default=[]) + ) + _domains( + get(applications, app_id, "domains.aliases", strict=False, default=[]) + ) + for domain in domains: + codes = { + code + for code in declared_status_codes(applications, app_id, domain) + if code >= _CHECKER_ACCEPTS_BELOW + } + if codes: + accepted.setdefault(domain, set()).update(codes) + + return [ + [ + f"{domain}={','.join(str(code) for code in sorted(codes))}" + for domain, codes in sorted(accepted.items()) + ] + ] diff --git a/roles/sys-ctl-hlth-csp/lookup_plugins/csp_skip_domains.py b/roles/sys-ctl-hlth-csp/lookup_plugins/csp_skip_domains.py index 08d344c073..16af34bded 100644 --- a/roles/sys-ctl-hlth-csp/lookup_plugins/csp_skip_domains.py +++ b/roles/sys-ctl-hlth-csp/lookup_plugins/csp_skip_domains.py @@ -32,21 +32,6 @@ def _to_list(x: Any) -> list[str]: return [] -def _has_code_ge_400(codes: Any) -> bool: - if codes is None: - return False - if not isinstance(codes, (list, tuple, set)): - codes = [codes] - for c in codes: - try: - n = int(c) - except (TypeError, ValueError): - continue - if n >= 400: - return True - return False - - def _selection_from(group_names: Any) -> set[str]: if isinstance(group_names, (list, set, tuple)): return {str(x) for x in group_names if str(x)} @@ -58,11 +43,7 @@ def _selection_from(group_names: Any) -> set[str]: class LookupModule(LookupBase): """Return domains the CSP probe should skip. - Skips canonical + alias domains of every selected application whose - ``server.status_codes.default`` declares any HTTP code >= 400 - (e.g. federation-only roles that legitimately serve 4xx at ``/``). - - Additionally skips canonical domains owned by a disabled service: a + Skips canonical domains owned by a disabled service: a service entry may declare ``domains: [, ...]``; when its ``enabled`` resolves falsy those canonicals get no vhost (e.g. the seaweedfs filer/master frontend on an onion node), so probing them can @@ -118,37 +99,7 @@ class LookupModule(LookupBase): skip: set[str] = set() for app_id in applications: - if selection and app_id not in selection: - continue - - skip |= self._service_disabled_domains(applications, app_id) - - default = get( - applications, - app_id, - "server.status_codes.default", - strict=False, - default=None, - ) - if not _has_code_ge_400(default): - continue - - canonical = get( - applications, - app_id, - "domains.canonical", - strict=False, - default=[], - ) - aliases = get( - applications, - app_id, - "domains.aliases", - strict=False, - default=[], - ) - for d in _to_list(canonical) + _to_list(aliases): - if d: - skip.add(d) + if not selection or app_id in selection: + skip |= self._service_disabled_domains(applications, app_id) return [sorted(skip)] diff --git a/roles/sys-ctl-hlth-csp/meta/services.yml b/roles/sys-ctl-hlth-csp/meta/services.yml index acec2b10aa..a6e9695221 100644 --- a/roles/sys-ctl-hlth-csp/meta/services.yml +++ b/roles/sys-ctl-hlth-csp/meta/services.yml @@ -8,4 +8,4 @@ csp: categories: [web] csp-checker: image: ghcr.io/kevinveenbirkenbach/csp-checker - version: 2.3.0 + version: 3.0.4 diff --git a/roles/sys-ctl-hlth-csp/tasks/00_core.yml b/roles/sys-ctl-hlth-csp/tasks/00_core.yml index 4ca506c279..2d965b1a5c 100644 --- a/roles/sys-ctl-hlth-csp/tasks/00_core.yml +++ b/roles/sys-ctl-hlth-csp/tasks/00_core.yml @@ -60,6 +60,10 @@ '--skip-domain ' ~ (HEALTH_CSP_SKIP_DOMAINS | join(' ')), '' ), + (HEALTH_CSP_ACCEPT_STATUS | length > 0) | ternary( + '--accept-status ' ~ (HEALTH_CSP_ACCEPT_STATUS | join(' ')), + '' + ), (lookup('config', 'svc-net-tor', 'services.tor.node', '') | length > 0) | ternary( '--tor-proxy=socks5://' ~ lookup('tor_socks') ~ ' --onion-timeout=' ~ HEALTH_CSP_ONION_TIMEOUT_MS, diff --git a/roles/sys-ctl-hlth-csp/vars/main.yml b/roles/sys-ctl-hlth-csp/vars/main.yml index 61cad831c2..ea8d4a0ce0 100644 --- a/roles/sys-ctl-hlth-csp/vars/main.yml +++ b/roles/sys-ctl-hlth-csp/vars/main.yml @@ -3,6 +3,7 @@ system_service_id: sys-ctl-hlth-csp HEALTH_CSP_ONION_TIMEOUT_MS: "{{ (HEALTH_CSP_TIMEOUT_MS | int) * (lookup('config', 'svc-net-tor', 'services.tor.timeout_multipliers')[lookup('config', 'svc-net-tor', 'services.tor.flavor')] | int) }}" HEALTH_CSP_ONION_DOMAIN_SECONDS: "{{ ((HEALTH_CSP_ONION_TIMEOUT_MS | int) // 1000) + (HEALTH_CSP_DOMAIN_RESERVE_SECONDS | int) }}" +HEALTH_CSP_ACCEPT_STATUS: "{{ lookup('csp_accept_status') }}" HEALTH_CSP_SKIP_DOMAINS: >- {{ ( lookup('csp_skip_domains', group_names=lookup('deployment').deployed) diff --git a/roles/sys-pip-install/tasks/00_core.yml b/roles/sys-pip-install/tasks/00_core.yml index 65dd4be738..7c25a2fc10 100644 --- a/roles/sys-pip-install/tasks/00_core.yml +++ b/roles/sys-pip-install/tasks/00_core.yml @@ -5,3 +5,69 @@ when: run_once_sys_pip is not defined - include_tasks: utils/once/flag.yml + +- name: 🔍 Resolve active Python executable for sys-pip-install + ansible.builtin.set_fact: + sys_pip_install_python_executable: "{{ ansible_python_interpreter }}" + changed_when: false + +- block: + - name: 🔍 Probe pip for ansible_python_interpreter accepts --break-system-packages + ansible.builtin.command: + argv: + - "{{ sys_pip_install_python_executable }}" + - -m + - pip + - install + - --break-system-packages + - --help + register: sys_pip_install_break_system_packages_probe + changed_when: false + failed_when: false + become: "{{ true if global_install | bool else false }}" + + - name: 🔧 Set fact for --break-system-packages support + ansible.builtin.set_fact: + sys_pip_install_supports_break_system_packages: "{{ (sys_pip_install_break_system_packages_probe.rc | int) == 0 }}" + changed_when: false + + when: global_install | bool + +- name: 🔎 Check whether selected Python interpreter has 'packaging' + ansible.builtin.command: + argv: + - "{{ sys_pip_install_python_executable }}" + - -I + - -c + - import packaging + register: sys_pip_install_python_packaging_check + changed_when: false + failed_when: false + +- name: 🥾 Bootstrap 'packaging' for selected Python interpreter + ansible.builtin.command: + argv: >- + {{ + [sys_pip_install_python_executable, '-m', 'pip', 'install', '--upgrade', 'packaging'] + + ( + ['--break-system-packages'] + if (global_install | bool and sys_pip_install_supports_break_system_packages | default(false) | bool) + else [] + ) + }} + environment: >- + {{ + ( + {'PIP_BREAK_SYSTEM_PACKAGES': '1'} + if sys_pip_install_supports_break_system_packages | default(false) | bool + else {} + ) + if global_install | bool + else omit + }} + become: "{{ true if global_install | bool else false }}" + register: sys_pip_install_packaging_bootstrap + retries: 5 + delay: 15 + until: sys_pip_install_packaging_bootstrap is succeeded + when: sys_pip_install_python_packaging_check.rc != 0 diff --git a/roles/sys-pip-install/tasks/main.yml b/roles/sys-pip-install/tasks/main.yml index 0709680650..415eae462e 100644 --- a/roles/sys-pip-install/tasks/main.yml +++ b/roles/sys-pip-install/tasks/main.yml @@ -4,55 +4,12 @@ - include_tasks: 00_core.yml when: run_once_sys_pip_install is not defined -- name: 🔍 Resolve active Python executable for sys-pip-install - ansible.builtin.set_fact: - sys_pip_install_python_executable: "{{ ansible_python_interpreter }}" - changed_when: false - -- block: - - name: 🔍 Probe pip for ansible_python_interpreter accepts --break-system-packages - ansible.builtin.command: - argv: - - "{{ sys_pip_install_python_executable }}" - - -m - - pip - - install - - --break-system-packages - - --help - register: sys_pip_install_break_system_packages_probe - changed_when: false - failed_when: false - become: "{{ true if global_install | bool else false }}" - - - name: 🔧 Set fact for --break-system-packages support - ansible.builtin.set_fact: - sys_pip_install_supports_break_system_packages: "{{ (sys_pip_install_break_system_packages_probe.rc | int) == 0 }}" - changed_when: false - - when: global_install | bool - -- name: 🔎 Check whether selected Python interpreter has 'packaging' - ansible.builtin.command: - argv: - - "{{ sys_pip_install_python_executable }}" - - -I - - -c - - import packaging - register: sys_pip_install_python_packaging_check - changed_when: false - failed_when: false - -- name: 🥾 Bootstrap 'packaging' for selected Python interpreter - ansible.builtin.command: - argv: >- - {{ - [sys_pip_install_python_executable, '-m', 'pip', 'install', '--upgrade', 'packaging'] - + ( - ['--break-system-packages'] - if (global_install | bool and sys_pip_install_supports_break_system_packages | default(false) | bool) - else [] - ) - }} +- name: 📦 Install or upgrade {{ package_name }} system-wide via pip + ansible.builtin.pip: + name: "{{ package_name }}" + state: "{{ package_state }}" + extra_args: "{{ '--break-system-packages' if (global_install | bool and sys_pip_install_supports_break_system_packages | default(false) | bool) else omit }}" + virtualenv: "{{ omit if global_install | bool else (sys_pip_install_python_executable | dirname | dirname) }}" environment: >- {{ ( @@ -64,29 +21,6 @@ else omit }} become: "{{ true if global_install | bool else false }}" - register: sys_pip_install_packaging_bootstrap - retries: 5 - delay: 15 - until: sys_pip_install_packaging_bootstrap is succeeded - when: sys_pip_install_python_packaging_check.rc != 0 - -- name: 📦 Install or upgrade {{ package_name }} system-wide via pip - ansible.builtin.pip: - name: "{{ package_name }}" - state: "{{ package_state }}" - extra_args: "{{ '--break-system-packages' if (global_install | bool and sys_pip_install_supports_break_system_packages | bool) else omit }}" - virtualenv: "{{ omit if global_install | bool else (sys_pip_install_python_executable | dirname | dirname) }}" - environment: >- - {{ - ( - {'PIP_BREAK_SYSTEM_PACKAGES': '1'} - if sys_pip_install_supports_break_system_packages | bool - else {} - ) - if global_install | bool - else omit - }} - become: "{{ true if global_install | bool else false }}" register: sys_pip_install_result retries: 5 delay: 15 diff --git a/roles/sys-service/handlers/refresh_systemctl_service.yml b/roles/sys-service/handlers/refresh_systemctl_service.yml index 879a7dd6e9..ac19505e55 100644 --- a/roles/sys-service/handlers/refresh_systemctl_service.yml +++ b/roles/sys-service/handlers/refresh_systemctl_service.yml @@ -2,9 +2,10 @@ - name: "🔧 Enable systemctl service" systemd: name: "{{ system_service_name }}" + scope: "{{ system_service_scope }}" enabled: true daemon_reload: true - become: true + become: "{{ system_service_unit_become }}" # rationale: async; opt-out per caller, a role that reads the unit state sets system_service_force_linear_sync async: "{{ system_service_async }}" poll: "{{ system_service_poll }}" @@ -12,8 +13,9 @@ - name: "🔧 Set systemctl service state" systemd: name: "{{ system_service_name }}" + scope: "{{ system_service_scope }}" state: "{{ system_service_state }}" - become: true + become: "{{ system_service_unit_become }}" # rationale: async; the Result gate below only runs meaningfully for callers that set system_service_force_linear_sync async: "{{ system_service_async }}" poll: "{{ system_service_poll }}" @@ -23,28 +25,14 @@ - name: 🔍 sys-service | Read unit Result (oneshot-safe) ansible.builtin.command: - argv: - - systemctl - - --no-pager - - show - - -p - - Result - - --value - - "{{ system_service_name }}" + argv: "{{ system_service_systemctl_show + ['Result', system_service_name] }}" register: system_service_result changed_when: false failed_when: false - name: 🔍 sys-service | Read unit ExecMainStatus (oneshot-safe) ansible.builtin.command: - argv: - - systemctl - - --no-pager - - show - - -p - - ExecMainStatus - - --value - - "{{ system_service_name }}" + argv: "{{ system_service_systemctl_show + ['ExecMainStatus', system_service_name] }}" register: system_service_exec_status changed_when: false failed_when: false diff --git a/roles/sys-service/tasks/06_service.yml b/roles/sys-service/tasks/06_service.yml index 639c21ba32..a89a63ad6d 100644 --- a/roles/sys-service/tasks/06_service.yml +++ b/roles/sys-service/tasks/06_service.yml @@ -28,10 +28,11 @@ - name: "🔧 setup systemctl '{{ system_service_id }}'" template: # nocheck: stack-host-template (per-host systemd service unit) src: "{{ system_service_template_src }}" - dest: "{{ [ DIR_SYSTEM_SERVICE, system_service_name ] | path_join }}" - owner: root - group: root + dest: "{{ [ system_service_unit_dir, system_service_name ] | path_join }}" + owner: "{{ system_service_unit_owner }}" + group: "{{ system_service_unit_owner }}" mode: "0644" + become: "{{ system_service_unit_become }}" notify: "{{ 'reload-system-daemon' if system_service_is_template | bool else 'refresh-systemctl-service' }}" - name: 🔄 refresh systemctl service when SYS_SERVICE_ALL_ENABLE diff --git a/roles/sys-service/tasks/07_timer.yml b/roles/sys-service/tasks/07_timer.yml index 1701ebaaf3..4814d6ad92 100644 --- a/roles/sys-service/tasks/07_timer.yml +++ b/roles/sys-service/tasks/07_timer.yml @@ -1,4 +1,14 @@ --- +- name: 🛑 Fail if a timer is asked for outside the system scope + assert: + that: + - system_service_scope == 'system' + fail_msg: >- + '{{ system_service_id }}' asks for a timer in the '{{ system_service_scope }}' + scope, but sys-timer writes into {{ DIR_SYSTEM_SERVICE }} and drives the unit + in the system scope, so the timer would look for a service that only exists + for the user. + - name: 🛑 Fail if system_service_id contains "@" assert: that: diff --git a/roles/sys-service/tasks/main.yml b/roles/sys-service/tasks/main.yml index ff13725fc0..c759025647 100644 --- a/roles/sys-service/tasks/main.yml +++ b/roles/sys-service/tasks/main.yml @@ -6,7 +6,9 @@ when: run_once_sys_service is defined - include_tasks: 00_core.yml - when: run_once_sys_service is not defined + when: + - run_once_sys_service is not defined + - system_service_scope == 'system' - name: "▶️ Execute service routines for '{{ system_service_id }}'" block: diff --git a/roles/sys-service/templates/systemctl.service.j2 b/roles/sys-service/templates/systemctl.service.j2 index 4ec2bcb6b8..5f9a7dd7d1 100644 --- a/roles/sys-service/templates/systemctl.service.j2 +++ b/roles/sys-service/templates/systemctl.service.j2 @@ -1,6 +1,16 @@ [Unit] Description={{ SOFTWARE_NAME }} - Service for role '{{ system_service_id }}' -{{ system_service_tpl_on_failure | systemd_directive('OnFailure') }} +{% for key, val in [ + ('OnFailure', system_service_tpl_on_failure), + ('After', system_service_tpl_after), + ('Before', system_service_tpl_before), + ('Wants', system_service_tpl_wants), +] %} +{% set directive = val | systemd_directive(key) %} +{% if directive %} +{{ directive }} +{% endif %} +{% endfor %} StartLimitIntervalSec=60 StartLimitBurst=3 OnFailureJobMode=replace-irreversibly @@ -10,13 +20,21 @@ Type={{ system_service_tpl_type }} {% for _environment in system_service_tpl_environment %} Environment={{ _environment }} {% endfor %} - {% for key, val in [ + ('RemainAfterExit', system_service_tpl_remain_after_exit), ('TimeoutStartSec', system_service_tpl_timeout_start_sec), ('ExecStartPre', system_service_tpl_exec_start_pre), ('ExecStart', system_service_tpl_exec_start), ('ExecStartPost', system_service_tpl_exec_start_post), ('RuntimeMaxSec', system_service_tpl_runtime), ] %} -{{ val | systemd_directive(key) }} +{% set directive = val | systemd_directive(key) %} +{% if directive %} +{{ directive }} +{% endif %} {% endfor %} +{% if system_service_tpl_wanted_by | length > 0 %} + +[Install] +WantedBy={{ system_service_tpl_wanted_by }} +{% endif %} diff --git a/roles/sys-service/vars/main.yml b/roles/sys-service/vars/main.yml index 2a91e808bf..24b8e3fae9 100644 --- a/roles/sys-service/vars/main.yml +++ b/roles/sys-service/vars/main.yml @@ -4,6 +4,12 @@ UNIT_SUFFIX_REMOVER_PACKAGE: "unsure" system_service_name: "{{ lookup('unit_name', system_service_id) }}" system_service_is_template: "{{ system_service_id.endswith('@') }}" +system_service_scope: "system" # 'system' for root units, 'user' for per-login units +system_service_unit_dir: "{{ DIR_SYSTEM_SERVICE if system_service_scope == 'system' else DIR_SYSTEM_SERVICE_USER }}" +system_service_unit_owner: "{{ 'root' if system_service_scope == 'system' else omit }}" +system_service_unit_become: "{{ system_service_scope == 'system' }}" +system_service_systemctl_show: "{{ ['systemctl'] + (['--user'] if system_service_scope == 'user' else []) + ['--no-pager', 'show', '--value', '-p'] }}" + system_service_role_name: "{{ system_service_id | regex_replace('@','') }}" system_service_role_dir: "{{ lookup('path_absolute', 'roles', system_service_role_name) }}" system_service_script_dir: "{{ [ DIR_SCRIPTS_SYSTEMCTL, system_service_id ] | path_join }}" @@ -25,7 +31,12 @@ system_service_script_inter: "{{ '/bin/bash' if system_service_script_type == 's system_service_script_exec: "{{ system_service_script_inter }} {{ system_service_id | get_service_script_path( system_service_script_type ) }}" system_service_tpl_on_failure: "{{ SYS_SERVICE_ON_FAILURE_COMPOSE }}" +system_service_tpl_after: "" +system_service_tpl_before: "" +system_service_tpl_wants: "" system_service_tpl_type: "oneshot" +system_service_tpl_remain_after_exit: "" +system_service_tpl_wanted_by: "" # Empty installs no [Install] section, which leaves the unit static system_service_tpl_environment: [] # Environment variables system_service_tpl_runtime: "{{ '' if system_service_tpl_type == 'oneshot' else SYS_SERVICE_DEFAULT_RUNTIME }}" system_service_tpl_exec_start_pre: "" diff --git a/roles/sys-svc-compose/tasks/00_core.yml b/roles/sys-svc-compose/tasks/00_core.yml index c0d631675f..9e53702465 100644 --- a/roles/sys-svc-compose/tasks/00_core.yml +++ b/roles/sys-svc-compose/tasks/00_core.yml @@ -44,3 +44,11 @@ mode: "0755" owner: root group: root + +- name: "🛡️ Assert the NFS client mount is active on this swarm node" + ansible.builtin.command: "mountpoint -q {{ DIR_VAR_LIB }}" + changed_when: false + when: + - DEPLOYMENT_MODE == 'swarm' + - (storage.backend | default('local')) == 'nfs' + - (groups['svc-storage-nfs-server'] | default([])) | length > 0 diff --git a/roles/sys-svc-compose/tasks/utils/nfs_prep.yml b/roles/sys-svc-compose/tasks/utils/nfs_prep.yml index f22615d86d..f7f528da7b 100644 --- a/roles/sys-svc-compose/tasks/utils/nfs_prep.yml +++ b/roles/sys-svc-compose/tasks/utils/nfs_prep.yml @@ -19,19 +19,6 @@ - item.value.driver_opts is defined - application_id not in lookup('roles_with_placement', 'manager') -- name: "🛡️ Assert the NFS client mount is active on every swarm node" - ansible.builtin.command: "mountpoint -q {{ DIR_VAR_LIB }}" - changed_when: false - delegate_to: "{{ item }}" - run_once: true - loop: "{{ groups['svc-swarm-node'] | default([]) }}" - when: - - DEPLOYMENT_MODE == 'swarm' - - (storage.backend | default('local')) == 'nfs' - - (groups['svc-storage-nfs-server'] | default([])) | length > 0 - - (lookup('compose_volumes') | from_yaml).volumes | default({}) | length > 0 - - application_id not in lookup('roles_with_placement', 'manager') - - name: "🔁 Materialize NFS subdirs for '{{ application_id }}' on every swarm node" ansible.builtin.file: path: "{{ DIR_VAR_LIB }}/{{ item.0.value.name | default(item.0.key) }}" diff --git a/roles/test-e2e-cli/tasks/main.yml b/roles/test-e2e-cli/tasks/main.yml index 78e2190aea..cfb3092bf3 100644 --- a/roles/test-e2e-cli/tasks/main.yml +++ b/roles/test-e2e-cli/tasks/main.yml @@ -44,7 +44,7 @@ ansible.builtin.include_tasks: run_one.yml loop: "{{ _cli_apps }}" loop_control: - loop_var: application_id + loop_var: application_id # nocheck: loop-var-shadow the item IS the role id, so run_one reads the same value the tested role declares label: "{{ application_id }}" - name: ✅ Fail if there were CLI test failures diff --git a/roles/test-e2e-playwright/tasks/01_setup/orchestrate.yml b/roles/test-e2e-playwright/tasks/01_setup/orchestrate.yml index 0a0db3cd4b..9580f19f34 100644 --- a/roles/test-e2e-playwright/tasks/01_setup/orchestrate.yml +++ b/roles/test-e2e-playwright/tasks/01_setup/orchestrate.yml @@ -18,7 +18,7 @@ ansible.builtin.include_tasks: 02_run_one.yml loop: "{{ TEST_E2E_PLAYWRIGHT_APPS }}" loop_control: - loop_var: application_id + loop_var: application_id # nocheck: loop-var-shadow the item IS the role id, so 02_run_one reads the same value the tested role's own vars declare label: "{{ application_id }}" - name: 🔁 Stash first-pass failures for one flake retry @@ -31,7 +31,7 @@ ansible.builtin.include_tasks: 02_run_one.yml loop: "{{ _playwright_retry_roles | default([]) }}" loop_control: - loop_var: application_id + loop_var: application_id # nocheck: loop-var-shadow same loop body, same identity between item and role id label: "{{ application_id }} (retry)" - name: 🛑 Fail if there were Playwright failures diff --git a/roles/user-backup/templates/ssh-wrapper.sh.j2 b/roles/user-backup/templates/ssh-wrapper.sh.j2 index fdffae7363..86e03d9763 100644 --- a/roles/user-backup/templates/ssh-wrapper.sh.j2 +++ b/roles/user-backup/templates/ssh-wrapper.sh.j2 @@ -1,9 +1,10 @@ #!/bin/sh +{# nocheck: shell-template grandfathered, values only: the backup directory and the repository list #} # log command if [ -n "$SSH_ORIGINAL_COMMAND" ] then - echo "`/bin/date`: $SSH_ORIGINAL_COMMAND" | systemd-cat -t "ssh-wrapper.sh" + echo "$(/bin/date): $SSH_ORIGINAL_COMMAND" | systemd-cat -t "ssh-wrapper.sh" fi get_hashed_machine_id="sha256sum /etc/machine-id"; diff --git a/roles/web-app-baserow/meta/services.yml b/roles/web-app-baserow/meta/services.yml index 36be76d639..af5c21390b 100644 --- a/roles/web-app-baserow/meta/services.yml +++ b/roles/web-app-baserow/meta/services.yml @@ -66,7 +66,7 @@ baserow: backup: no_stop_required: true image: baserow/baserow - version: 2.3.3 + version: 2.3.4 custom: true name: baserow min_storage: 785MB diff --git a/roles/web-app-bigbluebutton/meta/services.yml b/roles/web-app-bigbluebutton/meta/services.yml index b2985248f6..85af312fbb 100644 --- a/roles/web-app-bigbluebutton/meta/services.yml +++ b/roles/web-app-bigbluebutton/meta/services.yml @@ -194,8 +194,8 @@ bbb-webrtc-recorder: # nocheck: playwright-service-flag: script-time helper of upstream generate-compose, mirror pre-pull only # nocheck: dynamic-flag: fixed upstream-compose helper, not group-gated dockerize: - image: jwilder/dockerize - version: "latest" + image: ghcr.io/jwilder/dockerize + version: "v0.15.1" enabled: false # nocheck: playwright-service-flag, dynamic-flag infrastructure backup consumer diff --git a/roles/web-app-bigbluebutton/templates/compose.override.yml.j2 b/roles/web-app-bigbluebutton/templates/compose.override.yml.j2 index 58c4a51d5e..70439e95fa 100644 --- a/roles/web-app-bigbluebutton/templates/compose.override.yml.j2 +++ b/roles/web-app-bigbluebutton/templates/compose.override.yml.j2 @@ -90,12 +90,12 @@ services: {% set service_name = 'coturn' %} {{ service_name }}: {{ lookup('container_volumes', service_name) | indent(4) }} - ports: - - "{{ BBB_TURN_PORT }}:{{ BBB_TURN_PORT }}/udp" - - "{{ BBB_TURN_PORT }}:{{ BBB_TURN_PORT }}/tcp" - - "{{ BBB_STUN_PORT }}:{{ BBB_STUN_PORT }}/udp" - - "{{ BBB_STUN_PORT }}:{{ BBB_STUN_PORT }}/tcp" - - "{{ BBB_RELAY_PORT_RANGE }}/udp" + {{ lookup('container_ports', + {'service': 'bigbluebutton', 'protocol': 'stun_turn', + 'transport': ['udp', 'tcp'], 'container': 'same'}, + {'service': 'bigbluebutton', 'protocol': 'relay', + 'transport': 'udp', 'container': 'same', + 'publish': 'ephemeral'}) | indent(4) }} command: >- --use-auth-secret --static-auth-secret=${TURN_SECRET} diff --git a/roles/web-app-bigbluebutton/vars/main.yml b/roles/web-app-bigbluebutton/vars/main.yml index 359128cfe8..b7eafd9df5 100644 --- a/roles/web-app-bigbluebutton/vars/main.yml +++ b/roles/web-app-bigbluebutton/vars/main.yml @@ -33,7 +33,6 @@ BBB_STUN_PORT: "{{ lookup('config', application_id, 'services.bigbluebutton.port 'services.coturn.ports.public.stun_turn_tls') }}" BBB_RELAY_PORT_START: "{{ lookup('config', application_id, 'services.bigbluebutton.ports.public.relay.start') }}" BBB_RELAY_PORT_END: "{{ lookup('config', application_id, 'services.bigbluebutton.ports.public.relay.end') }}" -BBB_RELAY_PORT_RANGE: "{{ BBB_RELAY_PORT_START }}-{{ BBB_RELAY_PORT_END }}" BBB_COLLABORA_ENABLED: "{{ not (lookup('config',application_id, 'services.collabora.shared')) }}" BBB_COLLABORA_PORT: "{{ lookup('config', 'web-svc-collabora', 'services.collabora.ports.internal.http') }}" diff --git a/roles/web-app-bookwyrm/meta/services.yml b/roles/web-app-bookwyrm/meta/services.yml index 3bd70141b8..6bd6c005ea 100644 --- a/roles/web-app-bookwyrm/meta/services.yml +++ b/roles/web-app-bookwyrm/meta/services.yml @@ -59,7 +59,7 @@ bookwyrm: image: python # TODO: bump when upstream Bookwyrm declares support for Python >= 3.12 in requirements.txt # nocheck: docker-version - version: 3.11-bookworm + version: 3.11-slim custom: true repository: https://github.com/bookwyrm-social/bookwyrm.git ref: v0.9.3 diff --git a/roles/web-app-bridgy-fed/files/Dockerfile b/roles/web-app-bridgy-fed/files/Dockerfile index 91f07076a6..2446818fdc 100644 --- a/roles/web-app-bridgy-fed/files/Dockerfile +++ b/roles/web-app-bridgy-fed/files/Dockerfile @@ -18,7 +18,7 @@ ARG BRIDGY_REF RUN rm -rf /var/lib/apt/lists/* && \ apt-get -o Acquire::Retries=3 update && apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ - git build-essential curl ca-certificates && \ + git build-essential pkg-config autoconf automake libtool curl ca-certificates && \ rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/roles/web-app-bridgy-fed/meta/services.yml b/roles/web-app-bridgy-fed/meta/services.yml index 41b3d862d3..1678d4b203 100644 --- a/roles/web-app-bridgy-fed/meta/services.yml +++ b/roles/web-app-bridgy-fed/meta/services.yml @@ -47,7 +47,7 @@ bridgy-fed: swarm: enabled: true image: python - version: 3.14-bookworm + version: 3.14-slim name: web-app-bridgy-fed repository: https://github.com/snarfed/bridgy-fed.git # nocheck: repository-version -- upstream publishes no tags, so the pin is a commit diff --git a/roles/web-app-checkmk/meta/services.yml b/roles/web-app-checkmk/meta/services.yml index 16a28d1907..ab52dec53c 100644 --- a/roles/web-app-checkmk/meta/services.yml +++ b/roles/web-app-checkmk/meta/services.yml @@ -83,6 +83,9 @@ checkmk: http: 8087 sso: 16509 agent: 8500 + onion: + # nocheck: onion-flag the monitoring server dials its agents, so nothing connects inward here, and the agent speaks no authentication of its own + agent: false run_after: - web-app-keycloak - svc-db-openldap diff --git a/roles/web-app-dashboard/templates/compose.yml.j2 b/roles/web-app-dashboard/templates/compose.yml.j2 index fe3ae4bc30..619e8c6420 100644 --- a/roles/web-app-dashboard/templates/compose.yml.j2 +++ b/roles/web-app-dashboard/templates/compose.yml.j2 @@ -1,12 +1,10 @@ {% include 'roles/sys-svc-compose/templates/base.yml.j2' %} {% set service_name = 'dashboard' %} - {% set container_port = lookup('config', application_id, 'services.' ~ service_name ~ '.ports.internal.http') %} {{ service_name }}: {% include 'roles/sys-svc-container/templates/deploy.yml.j2' %} {{ lookup('container_image', application_id, 'dashboard') }} {{ lookup('compose_only', 'container_name', DASHBOARD_CONTAINER) }} - ports: - - {{ DOCKER_BIND_HOST }}:{{ http_port }}:{{ container_port }} + {{ lookup('container_ports', ['dashboard', 'http', DOCKER_BIND_HOST]) | indent(4) }} {{ lookup('container_volumes', service_name) | indent(4) }} {{ lookup('compose_restart') }} {{ lookup('container_networks') | indent(4) }} diff --git a/roles/web-app-dashboard/templates/javascript/oidc.js.j2 b/roles/web-app-dashboard/templates/javascript/oidc.js.j2 index 2d641ae521..52b28a94a4 100644 --- a/roles/web-app-dashboard/templates/javascript/oidc.js.j2 +++ b/roles/web-app-dashboard/templates/javascript/oidc.js.j2 @@ -8,7 +8,7 @@ 1) Configuration (filled by Jinja/Ansible) ======================= */ const KC_CONFIG = { - url: "{{ OIDC.URL }}", // e.g. https://auth.infinito.nexus + url: "{{ OIDC.URL }}", // e.g. https://auth.{{ SOFTWARE_DOMAIN }} realm: "{{ OIDC.CLIENT.REALM }}", // e.g. cymais.cloud clientId: "{{ OIDC.CLIENT.ID }}", // e.g. cymais.cloud redirectUri: "{{ DASHBOARD_APP_BASE_URL }}", // canonical app URL for login/logout round-trips diff --git a/roles/web-app-discourse/files/ruby/apply_settings.rb b/roles/web-app-discourse/files/ruby/apply_settings.rb new file mode 100644 index 0000000000..6c212121f5 --- /dev/null +++ b/roles/web-app-discourse/files/ruby/apply_settings.rb @@ -0,0 +1,35 @@ +# nocheck: mirrored-unit-test - runs in the Discourse console against SiteSetting and +# Discourse.cache; both only exist once the application has booted + +require "json" + +SiteSetting.force_https = ENV.fetch("DISCOURSE_FORCE_HTTPS") == "true" +SiteSetting.enable_local_logins = ENV.fetch("DISCOURSE_LOCAL_LOGINS") == "true" + +JSON.parse(ENV.fetch("DISCOURSE_LDAP_SITE_SETTINGS")).each do |key, value| + SiteSetting.public_send("#{key}=", value) +end + +if ENV.fetch("DISCOURSE_OIDC_ENABLED") == "true" + SiteSetting.openid_connect_verbose_logging = ENV.fetch("DISCOURSE_OIDC_VERBOSE") == "true" + SiteSetting.enable_passkeys = false + SiteSetting.username_change_period = 0 + + SiteSetting.openid_connect_discovery_document = ENV.fetch("DISCOURSE_OIDC_DISCOVERY_DOCUMENT") + SiteSetting.openid_connect_client_id = ENV.fetch("DISCOURSE_OIDC_CLIENT_ID") + SiteSetting.openid_connect_client_secret = ENV.fetch("DISCOURSE_OIDC_CLIENT_SECRET") + SiteSetting.openid_connect_enabled = true + SiteSetting.openid_connect_rp_initiated_logout_redirect = ENV.fetch("DISCOURSE_OIDC_LOGOUT_REDIRECT") + SiteSetting.openid_connect_allow_association_change = false + SiteSetting.openid_connect_rp_initiated_logout = true + + url = SiteSetting.openid_connect_discovery_document.to_s.strip + host = url.sub(%r{\Ahttps?://}i, "").split("/").first.to_s.strip.split(":").first.to_s.strip + list = SiteSetting.allowed_internal_hosts.to_s.split("|").map(&:strip).reject(&:empty?) + if !host.empty? && host =~ /\A[a-z0-9.-]+\z/i && !list.include?(host) + list << host + SiteSetting.allowed_internal_hosts = list.join("|") + end + + Discourse.cache.delete("openid-connect-discovery-#{url}") +end diff --git a/roles/web-app-discourse/files/ruby/ensure_admin_user.rb b/roles/web-app-discourse/files/ruby/ensure_admin_user.rb new file mode 100644 index 0000000000..15bb55151e --- /dev/null +++ b/roles/web-app-discourse/files/ruby/ensure_admin_user.rb @@ -0,0 +1,17 @@ +# nocheck: mirrored-unit-test - runs in the Discourse console against User and its +# password validators; the models only exist once the application has booted + +email = ENV.fetch("DISCOURSE_ADMIN_EMAIL") +username = ENV.fetch("DISCOURSE_ADMIN_USERNAME") +password = ENV.fetch("DISCOURSE_ADMIN_PASSWORD") + +user = User.find_by_email(email) || User.new(email: email) +user.username = username +user.password = password unless user.confirm_password?(password) +user.active = true +user.save! + +user.grant_admin! +user.change_trust_level!(1) if user.trust_level < 1 +user.email_tokens.update_all(confirmed: true) +user.activate diff --git a/roles/web-app-discourse/files/ruby/verify_settings.rb b/roles/web-app-discourse/files/ruby/verify_settings.rb new file mode 100644 index 0000000000..202ab7300a --- /dev/null +++ b/roles/web-app-discourse/files/ruby/verify_settings.rb @@ -0,0 +1,17 @@ +# nocheck: mirrored-unit-test - reads SiteSetting and User out of the booted +# application, neither of which exists outside the Discourse console + +email = ENV.fetch("DISCOURSE_ADMIN_EMAIL") +password = ENV.fetch("DISCOURSE_ADMIN_PASSWORD") + +user = User.find_by_email(email) +puts "administrator_present=#{!user.nil?}" +puts "administrator_admin=#{user&.admin == true}" +puts "administrator_active=#{user&.active == true}" +puts "administrator_password_matches=#{user&.confirm_password?(password) == true}" + +if ENV.fetch("DISCOURSE_OIDC_ENABLED") == "true" + puts "oidc_secret_matches=#{SiteSetting.openid_connect_client_secret == ENV.fetch('DISCOURSE_OIDC_CLIENT_SECRET')}" + puts "oidc_enabled=#{SiteSetting.openid_connect_enabled == true}" + puts "oidc_host_allowed=#{SiteSetting.allowed_internal_hosts.to_s.split('|').any? { |h| SiteSetting.openid_connect_discovery_document.to_s.include?(h.strip) }}" +end diff --git a/roles/web-app-discourse/handlers/main.yml b/roles/web-app-discourse/handlers/main.yml index 524a11676f..94708c8d57 100644 --- a/roles/web-app-discourse/handlers/main.yml +++ b/roles/web-app-discourse/handlers/main.yml @@ -1,5 +1,21 @@ --- +- name: "🔨 Bootstrap the stack host image" + include_tasks: stack_host.yml + vars: + DISCOURSE_LAUNCHER_ACTION: rebuild + when: IS_STACK_HOST | bool + listen: rebuild-discourse + +- name: "📝 Record that the bootstrap already recreated the container" + ansible.builtin.set_fact: + DISCOURSE_REBUILT: true + listen: rebuild-discourse + - name: "🖥️ Handle stack host" include_tasks: stack_host.yml - when: IS_STACK_HOST | bool + vars: + DISCOURSE_LAUNCHER_ACTION: start + when: + - IS_STACK_HOST | bool + - not DISCOURSE_REBUILT | bool listen: recreate-discourse diff --git a/roles/web-app-discourse/handlers/stack_host.yml b/roles/web-app-discourse/handlers/stack_host.yml index 067ad6ac64..e6b013829d 100644 --- a/roles/web-app-discourse/handlers/stack_host.yml +++ b/roles/web-app-discourse/handlers/stack_host.yml @@ -25,9 +25,9 @@ - DISCOURSE_DATABASE_IS_SHARED | bool - DEPLOYMENT_MODE == 'compose' -- name: 🔨 rebuild discourse +- name: "🔨 {{ DISCOURSE_LAUNCHER_ACTION }} discourse" shell: > - ./launcher rebuild {{ DISCOURSE_CONTAINER }} + ./launcher {{ DISCOURSE_LAUNCHER_ACTION }} {{ DISCOURSE_CONTAINER }} {% if DOCKER_IN_CONTAINER | bool %}--skip-prereqs{% endif %} args: executable: /bin/bash diff --git a/roles/web-app-discourse/meta/addons/discourse-activity-pub.yml b/roles/web-app-discourse/meta/addons/discourse-activity-pub.yml index c7dca961bf..05bcb24e16 100644 --- a/roles/web-app-discourse/meta/addons/discourse-activity-pub.yml +++ b/roles/web-app-discourse/meta/addons/discourse-activity-pub.yml @@ -1,6 +1,13 @@ --- -# nocheck: addons-variant-pinning: compiled into the image from services.discourse.plugins, so an addon-level disable saves no deploy time and only silences the spec +# nocheck: addons-variant-pinning: compiled into the image from the addon's ref, so an addon-level disable saves no deploy time and only silences the spec enabled: true required: false mechanism: plugin source: upstream +update: + monitored: true + catalog: discourse-meta + upstream_id: discourse-activity-pub +config: + repository: https://github.com/discourse/discourse-activity-pub.git + ref: main diff --git a/roles/web-app-discourse/meta/addons/discourse-akismet.yml b/roles/web-app-discourse/meta/addons/discourse-akismet.yml index c7dca961bf..770e3fa21d 100644 --- a/roles/web-app-discourse/meta/addons/discourse-akismet.yml +++ b/roles/web-app-discourse/meta/addons/discourse-akismet.yml @@ -1,6 +1,13 @@ --- -# nocheck: addons-variant-pinning: compiled into the image from services.discourse.plugins, so an addon-level disable saves no deploy time and only silences the spec +# nocheck: addons-variant-pinning: compiled into the image from the addon's ref, so an addon-level disable saves no deploy time and only silences the spec enabled: true required: false mechanism: plugin source: upstream +update: + monitored: true + catalog: discourse-meta + upstream_id: discourse-akismet +config: + repository: https://github.com/discourse/discourse-akismet.git + ref: main diff --git a/roles/web-app-discourse/meta/addons/docker_manager.yml b/roles/web-app-discourse/meta/addons/docker_manager.yml index c7dca961bf..8af7b2512d 100644 --- a/roles/web-app-discourse/meta/addons/docker_manager.yml +++ b/roles/web-app-discourse/meta/addons/docker_manager.yml @@ -1,6 +1,13 @@ --- -# nocheck: addons-variant-pinning: compiled into the image from services.discourse.plugins, so an addon-level disable saves no deploy time and only silences the spec +# nocheck: addons-variant-pinning: compiled into the image from the addon's ref, so an addon-level disable saves no deploy time and only silences the spec enabled: true required: false mechanism: plugin source: upstream +update: + monitored: true + catalog: discourse-meta + upstream_id: docker_manager +config: + repository: https://github.com/discourse/docker_manager.git + ref: main diff --git a/roles/web-app-discourse/meta/services.yml b/roles/web-app-discourse/meta/services.yml index c3694d4013..3fbcfa6ea3 100644 --- a/roles/web-app-discourse/meta/services.yml +++ b/roles/web-app-discourse/meta/services.yml @@ -1,10 +1,10 @@ --- # nocheck: seaweedfs-required — Discourse's aws-sdk-s3 defaults a custom endpoint to virtual-host addressing and exposes no force_path_style option, incompatible with SeaweedFS path-style without a global SeaweedFS virtual-host/wildcard-DNS change affecting all consumers; uploads stay on the local volume -# nocheck: playwright-service-flag - shared gateway consumer; inference wiring is covered by the gateway role +# nocheck: playwright-service-flag, dynamic-flag - shared gateway consumer; inference wiring is covered by the gateway role, and the gateway is a single shared instance litellm: bond: 0.5 - enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}" - shared: "{{ 'svc-ai-litellm' in group_names }}" + enabled: "{{ LITELLM_USABLE }}" + shared: true # nocheck: playwright-service-flag — static-asset provider, no Playwright surface asset: bond: 1 @@ -69,17 +69,9 @@ discourse: ref: main min_storage: 15GB repository: https://github.com/discourse/discourse_docker.git - plugins: - docker_manager: - enabled: true - discourse-activity-pub: - enabled: true - discourse-akismet: - enabled: true - discourse-ldap-auth: - enabled: "{{ lookup('config', 'web-app-discourse', 'services.ldap.enabled') | bool }}" - repository: https://github.com/jonmbake/discourse-ldap-auth.git - ref: master + application: + repository: https://github.com/discourse/discourse.git + ref: v2026.8.0 ports: local: http: 8020 diff --git a/roles/web-app-discourse/meta/variants.yml b/roles/web-app-discourse/meta/variants.yml index 397fa8ca4d..d36f140319 100644 --- a/roles/web-app-discourse/meta/variants.yml +++ b/roles/web-app-discourse/meta/variants.yml @@ -22,7 +22,6 @@ shared: true litellm: enabled: true - shared: true sso: enabled: true shared: true @@ -59,7 +58,6 @@ shared: false # nocheck: variants-static-override backup shared stays literally true litellm: enabled: false - shared: false sso: enabled: false shared: false # nocheck: variants-static-override backup shared stays literally true diff --git a/roles/web-app-discourse/tasks/00_core.yml b/roles/web-app-discourse/tasks/00_core.yml index ffa18be27b..00f0e35171 100644 --- a/roles/web-app-discourse/tasks/00_core.yml +++ b/roles/web-app-discourse/tasks/00_core.yml @@ -9,14 +9,6 @@ - name: "🔧 Setup '{{ application_id }}' docker" include_tasks: 02_docker.yml -- name: "🔧 Setup '{{ application_id }}' network" - include_tasks: 04_network.yml - when: - - IS_STACK_HOST | bool - - lookup('database', application_id, 'shared') - -- name: "🤖 Bind the native AI surface to the platform gateway" - include_tasks: addons/discourse-ai.yml - when: - - IS_STACK_HOST | bool - - DISCOURSE_LITELLM_ENABLED | bool +- name: "🖥️ Setup what only the stack host owns" + include_tasks: 04_stack_host.yml + when: IS_STACK_HOST | bool diff --git a/roles/web-app-discourse/tasks/03_launcher.yml b/roles/web-app-discourse/tasks/03_launcher.yml index 6a692add06..f816df8bcc 100644 --- a/roles/web-app-discourse/tasks/03_launcher.yml +++ b/roles/web-app-discourse/tasks/03_launcher.yml @@ -1,4 +1,8 @@ --- +- name: "🧹 Clear the bootstrap marker of an earlier play" + ansible.builtin.set_fact: + DISCOURSE_REBUILT: false + - name: ⬇️ Pull Discourse docker repository git: repo: "{{ DISCOURSE_REPOSITORY_URL }}" @@ -10,7 +14,7 @@ retries: 12 delay: 30 until: discourse_repo_pull is succeeded - notify: recreate-discourse + notify: rebuild-discourse become: true - name: 🔧 set chmod 700 for '{{ DISCOURSE_CONTAINERS_DIR }}' @@ -26,6 +30,13 @@ mode: "0640" notify: recreate-discourse +- name: "🔑 Record the digest of everything the bootstrap consumes" + stack_host_copy: + content: "{{ lookup('template', 'config.yml.j2') | from_yaml | dict2items | rejectattr('key', 'eq', 'env') | items2dict | to_json | hash('sha256') }}" + dest: "{{ DISCOURSE_BOOTSTRAP_DIGEST_DEST }}" + mode: "0640" + notify: rebuild-discourse + - name: "✅ Verify that '{{ DISCOURSE_CONTAINER }}' is running" ansible.builtin.command: argv: @@ -40,7 +51,7 @@ register: docker_ps changed_when: docker_ps.stdout.strip() == "" failed_when: docker_ps.rc != 0 - notify: recreate-discourse + notify: rebuild-discourse - name: 📂 Load discourse handlers explicitly include_tasks: utils/load/handlers.yml diff --git a/roles/web-app-discourse/tasks/04_stack_host.yml b/roles/web-app-discourse/tasks/04_stack_host.yml new file mode 100644 index 0000000000..bbf7175342 --- /dev/null +++ b/roles/web-app-discourse/tasks/04_stack_host.yml @@ -0,0 +1,11 @@ +--- +- name: "🔧 Setup '{{ application_id }}' network" + include_tasks: stack_host/01_network.yml + when: lookup('database', application_id, 'shared') + +- name: "🔑 Apply the credentials the bootstrap must not carry" + include_tasks: stack_host/02_credentials.yml + +- name: "🤖 Bind the native AI surface to the platform gateway" + include_tasks: addons/discourse-ai.yml + when: DISCOURSE_LITELLM_ENABLED | bool diff --git a/roles/web-app-discourse/tasks/04_network.yml b/roles/web-app-discourse/tasks/stack_host/01_network.yml similarity index 100% rename from roles/web-app-discourse/tasks/04_network.yml rename to roles/web-app-discourse/tasks/stack_host/01_network.yml diff --git a/roles/web-app-discourse/tasks/stack_host/02_credentials.yml b/roles/web-app-discourse/tasks/stack_host/02_credentials.yml new file mode 100644 index 0000000000..5e6efbb6c2 --- /dev/null +++ b/roles/web-app-discourse/tasks/stack_host/02_credentials.yml @@ -0,0 +1,87 @@ +--- +- name: "⚙️ Apply the site settings to the running instance" + ansible.builtin.command: + argv: + - container + - exec + - -i + - -e + - "DISCOURSE_FORCE_HTTPS={{ DISCOURSE_TLS_ENABLED | bool | string | lower }}" + - -e + - "DISCOURSE_LOCAL_LOGINS={{ DISCOURSE_LOCAL_LOGIN_ENABLED | bool | string | lower }}" + - -e + - "DISCOURSE_LDAP_SITE_SETTINGS={{ (DISCOURSE_PLUGINS['discourse-ldap-auth'].config.site_settings if DISCOURSE_LDAP_ENABLED | bool else {}) | to_json }}" + - -e + - "DISCOURSE_OIDC_ENABLED={{ DISCOURSE_OIDC_ENABLED | bool | string | lower }}" + - -e + - "DISCOURSE_OIDC_VERBOSE={{ MODE_DEBUG | bool | string | lower }}" + - -e + - "DISCOURSE_OIDC_DISCOVERY_DOCUMENT={{ OIDC.CLIENT.DISCOVERY_DOCUMENT if DISCOURSE_OIDC_ENABLED | bool else '' }}" + - -e + - "DISCOURSE_OIDC_CLIENT_ID={{ OIDC.CLIENT.ID if DISCOURSE_OIDC_ENABLED | bool else '' }}" + - -e + - "DISCOURSE_OIDC_CLIENT_SECRET={{ OIDC.CLIENT.SECRET if DISCOURSE_OIDC_ENABLED | bool else '' }}" + - -e + - "DISCOURSE_OIDC_LOGOUT_REDIRECT={{ DISCOURSE_OIDC_LOGOUT_REDIRECT_URL if DISCOURSE_OIDC_ENABLED | bool else '' }}" + - "{{ DISCOURSE_CONTAINER }}" # nocheck: container-exec-resolver the launcher names the container itself, so the bare name resolves in both modes + - rails + - r + - "{{ lookup('file', 'ruby/apply_settings.rb') }}" + changed_when: false + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + +- name: "🔑 Ensure the administrator account carries the current password" + ansible.builtin.command: + argv: + - container + - exec + - -i + - -e + - "DISCOURSE_ADMIN_EMAIL={{ lookup('users', 'administrator').email }}" + - -e + - "DISCOURSE_ADMIN_USERNAME={{ lookup('users', 'administrator').username }}" + - -e + - "DISCOURSE_ADMIN_PASSWORD={{ lookup('users', 'administrator').password }}" + - "{{ DISCOURSE_CONTAINER }}" # nocheck: container-exec-resolver the launcher names the container itself, so the bare name resolves in both modes + - rails + - r + - "{{ lookup('file', 'ruby/ensure_admin_user.rb') }}" + changed_when: false + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + +- name: "🔎 Read the applied settings back out of the running instance" + ansible.builtin.command: + argv: + - container + - exec + - -i + - -e + - "DISCOURSE_ADMIN_EMAIL={{ lookup('users', 'administrator').email }}" + - -e + - "DISCOURSE_ADMIN_PASSWORD={{ lookup('users', 'administrator').password }}" + - -e + - "DISCOURSE_OIDC_ENABLED={{ DISCOURSE_OIDC_ENABLED | bool | string | lower }}" + - -e + - "DISCOURSE_OIDC_CLIENT_SECRET={{ OIDC.CLIENT.SECRET if DISCOURSE_OIDC_ENABLED | bool else '' }}" + - "{{ DISCOURSE_CONTAINER }}" # nocheck: container-exec-resolver the launcher names the container itself, so the bare name resolves in both modes + - rails + - r + - "{{ lookup('file', 'ruby/verify_settings.rb') }}" + register: discourse_settings_readback + changed_when: false + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + +- name: "🛑 Refuse an instance the rotated credentials did not reach" + ansible.builtin.assert: + that: + - "'administrator_present=true' in discourse_settings_readback.stdout_lines" + - "'administrator_admin=true' in discourse_settings_readback.stdout_lines" + - "'administrator_active=true' in discourse_settings_readback.stdout_lines" + - "'administrator_password_matches=true' in discourse_settings_readback.stdout_lines" + - "not DISCOURSE_OIDC_ENABLED | bool or 'oidc_secret_matches=true' in discourse_settings_readback.stdout_lines" + - "not DISCOURSE_OIDC_ENABLED | bool or 'oidc_enabled=true' in discourse_settings_readback.stdout_lines" + - "not DISCOURSE_OIDC_ENABLED | bool or 'oidc_host_allowed=true' in discourse_settings_readback.stdout_lines" + fail_msg: >- + The administrator account, the OIDC client secret or the internal-host allowlist + of the running Discourse does not match the inventory, so a rotated credential + or a site setting never reached it. diff --git a/roles/web-app-discourse/templates/config.yml.j2 b/roles/web-app-discourse/templates/config.yml.j2 index 1cca0ac788..f09ede8f90 100644 --- a/roles/web-app-discourse/templates/config.yml.j2 +++ b/roles/web-app-discourse/templates/config.yml.j2 @@ -15,8 +15,7 @@ templates: ## which TCP/IP ports should this container expose? ## If you want Discourse to share a port with another webserver like Apache or nginx, ## see https://meta.discourse.org/t/17247 for details -expose: - - "{{ DOCKER_BIND_HOST }}:{{ lookup('config', application_id, 'services.discourse.ports.local.http') }}:80" # http +{{ lookup('container_ports', ['discourse', 'http', DOCKER_BIND_HOST], key='expose') }} params: db_default_text_search_config: "pg_catalog.english" @@ -29,7 +28,7 @@ params: #db_work_mem: "40MB" ## Which Git revision should this container use? (default: tests-passed) - #version: tests-passed + version: {{ DISCOURSE_VERSION }} env: LC_ALL: "{{ DISCOURSE_LOCAL }}" @@ -138,89 +137,16 @@ hooks: cmd: {% for plugin_name, plugin_config in DISCOURSE_PLUGINS.items() %} {% if plugin_config.enabled and plugin_config.source != 'bundled' %} - - git clone --depth=1 https://github.com/discourse/{{ plugin_name }}.git + - git clone --depth=1 --branch {{ plugin_config.config.ref }} {{ plugin_config.config.repository }} {% endif %} {% endfor %} -{% if DISCOURSE_LDAP_ENABLED | bool %} - - git clone --depth=1 --branch {{ DISCOURSE_LDAP_AUTH_PLUGIN_REF }} {{ DISCOURSE_LDAP_AUTH_PLUGIN_REPOSITORY }} -{% endif %} - ## Any custom commands to run after building run: - exec: echo "Beginning of custom commands" - - exec: rails r "SiteSetting.force_https = {{ DISCOURSE_TLS_ENABLED | bool | string | lower }}" {% if DISCOURSE_TLS_SELFSIGNED_ENABLED | bool %} - exec: {{ BIN_CA_TRUST }} {% endif %} - ## If you want to set the 'From' email address for your first registration, uncomment and change: - ## After getting the first signup email, re-comment the line. It only needs to run once. - #- exec: rails r "SiteSetting.notification_email='info@unconfigured.discourse.org'" - - exec: rails r "SiteSetting.enable_local_logins = {{ DISCOURSE_LOCAL_LOGIN_ENABLED | bool | string | lower }}" -{% if DISCOURSE_OIDC_ENABLED | bool %} - # Deactivate Default Login - - exec: rails r "SiteSetting.openid_connect_verbose_logging = {{ MODE_DEBUG | bool | string | lower }}" - - exec: rails r "SiteSetting.enable_passkeys = false" # https://meta.discourse.org/t/passwordless-login-using-passkeys/285589 - - - exec: rails r "SiteSetting.username_change_period = 0" # Deactivate changing of username - - # OIDC Activation - - exec: rails r "SiteSetting.openid_connect_discovery_document = '{{OIDC.CLIENT.DISCOVERY_DOCUMENT}}'" - - exec: rails r "SiteSetting.openid_connect_client_id = '{{ OIDC.CLIENT.ID }}'" - - exec: rails r "SiteSetting.openid_connect_client_secret = '{{ OIDC.CLIENT.SECRET }}'" - - exec: rails r "SiteSetting.openid_connect_enabled = true" - - exec: rails r "SiteSetting.openid_connect_rp_initiated_logout_redirect = '{{ DISCOURSE_OIDC_LOGOUT_REDIRECT_URL }}'" - - exec: rails r "SiteSetting.openid_connect_allow_association_change = false" - - exec: rails r "SiteSetting.openid_connect_rp_initiated_logout = true" - - # ---- FinalDestination allowlist (IMPORTANT) ---- - # Discourse blocks requests to internal/private IPs unless the host is allowlisted. - # Extract host from discovery document URL and append to allowed_internal_hosts. - - exec: > - rails r "url=SiteSetting.openid_connect_discovery_document.to_s.strip; - host=url.sub(%r{\Ahttps?://}i,'').split('/').first.to_s.strip; - host=host.split(':').first.to_s.strip; - list=SiteSetting.allowed_internal_hosts.to_s.split('|').map{|x| x.strip}.reject{|x| x.empty?}; - if !host.empty? && host =~ /\A[a-z0-9.-]+\z/i && !list.include?(host) - list << host; - SiteSetting.allowed_internal_hosts = list.join('|'); - end; - puts \"allowed_internal_hosts=#{SiteSetting.allowed_internal_hosts}\";" - - # Clear OIDC discovery cache so the next login attempt refetches with the new allowlist - - exec: > - rails r "url=SiteSetting.openid_connect_discovery_document.to_s.strip; - key=\"openid-connect-discovery-#{url}\"; - Discourse.cache.delete(key); - puts \"deleted_cache_key=#{key}\";" - -{% endif %} - - # Activate Administrator User (always yes, non-interactive) - - exec: | - cat <<'EOF' | rake admin:create - {{ lookup('users', 'administrator').email }} - {{ lookup('users', 'administrator').password }} - {{ lookup('users', 'administrator').password }} - Y - Y - EOF - - - exec: > - rails r "User.find_by_email('{{ lookup('users', 'administrator').email }}')&.update!(username: '{{ lookup('users', 'administrator').username }}')" - -{% if DISCOURSE_LDAP_ENABLED | bool %} -{% for setting_key, setting_value in DISCOURSE_PLUGINS['discourse-ldap-auth'].config.site_settings.items() %} -{% if setting_value is boolean %} - - exec: rails r "SiteSetting.{{ setting_key }} = {{ setting_value | string | lower }}" -{% elif setting_value is string %} - - exec: rails r "SiteSetting.{{ setting_key }} = '{{ setting_value }}'" -{% else %} - - exec: rails r "SiteSetting.{{ setting_key }} = {{ setting_value }}" -{% endif %} -{% endfor %} -{% endif %} - - exec: echo "End of custom commands" docker_args: diff --git a/roles/web-app-discourse/vars/main.yml b/roles/web-app-discourse/vars/main.yml index 909fb515cc..8e423d3f92 100644 --- a/roles/web-app-discourse/vars/main.yml +++ b/roles/web-app-discourse/vars/main.yml @@ -17,8 +17,7 @@ DISCOURSE_REDIS_PORT: "{{ lookup('engine', 'redis', application_id, 'port') }}" DISCOURSE_REDIS_PASSWORD: "{{ lookup('engine', 'redis', application_id, 'password') }}" DISCOURSE_REPOSITORY_URL: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.repository') }}" DISCOURSE_REF: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.ref') }}" -DISCOURSE_LDAP_AUTH_PLUGIN_REPOSITORY: "{{ lookup('config', application_id, 'addons.discourse-ldap-auth.config.repository') }}" -DISCOURSE_LDAP_AUTH_PLUGIN_REF: "{{ lookup('config', application_id, 'addons.discourse-ldap-auth.config.ref') }}" +DISCOURSE_VERSION: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.application.ref') }}" DISCOURSE_DATABASE: "{{ lookup('database', application_id ) }}" DISCOURSE_DATABASE_TYPE: "{{ DISCOURSE_DATABASE.type }}" @@ -30,6 +29,8 @@ DISCOURSE_CONTAINERS_DIR: "{{ [ DISCOURSE_REPOSITORY_DIR, 'containers' ] | path_ DISCOURSE_APPLICATION_YML_DEST: "{{ [ DISCOURSE_CONTAINERS_DIR, DISCOURSE_CONTAINER ~ '.yml' ] | path_join }}" +DISCOURSE_BOOTSTRAP_DIGEST_DEST: "{{ [ DISCOURSE_CONTAINERS_DIR, DISCOURSE_CONTAINER ~ '.bootstrap.sha256' ] | path_join }}" + DISCOURSE_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') }}" DISCOURSE_LITELLM_ENABLED: "{{ lookup('config', application_id, 'services.litellm.enabled') | bool }}" diff --git a/roles/web-app-erpnext/meta/services.yml b/roles/web-app-erpnext/meta/services.yml index ae0a6e536b..fbddf587c1 100644 --- a/roles/web-app-erpnext/meta/services.yml +++ b/roles/web-app-erpnext/meta/services.yml @@ -65,7 +65,7 @@ erpnext: enabled: true shared: true image: frappe/erpnext - version: "v16.34.2" + version: "v16.35.0" name: erpnext min_storage: 1017MB ports: diff --git a/roles/web-app-fediwall/meta/services.yml b/roles/web-app-fediwall/meta/services.yml index 58ebaf38ed..91af39c490 100644 --- a/roles/web-app-fediwall/meta/services.yml +++ b/roles/web-app-fediwall/meta/services.yml @@ -52,7 +52,7 @@ fediwall: swarm: enabled: true image: nginx - version: alpine + version: 1.31.6-alpine custom: true upstream: version: "1.4.0" diff --git a/roles/web-app-fider/meta/services.yml b/roles/web-app-fider/meta/services.yml index 08814c2566..072028c8e7 100644 --- a/roles/web-app-fider/meta/services.yml +++ b/roles/web-app-fider/meta/services.yml @@ -36,7 +36,7 @@ fider: swarm: enabled: true image: getfider/fider - version: stable + version: stable # nocheck: docker-version upstream released v0.36.1 on GitHub but never pushed that tag; its build ships only as `stable`, and v0.36.0 predates ALLOW_PRIVATE_NETWORK_TARGETS, without which the OAuth token exchange against an internally resolving Keycloak is refused. TODO: pin the semver tag once upstream publishes one. name: fider min_storage: 400MB backup: diff --git a/roles/web-app-flowise/meta/services.yml b/roles/web-app-flowise/meta/services.yml index f668766d59..fd68bc35fe 100644 --- a/roles/web-app-flowise/meta/services.yml +++ b/roles/web-app-flowise/meta/services.yml @@ -28,11 +28,11 @@ sso: ldap: enabled: false shared: false -# nocheck: playwright-service-flag - shared gateway consumer; inference wiring is covered by the gateway role +# nocheck: playwright-service-flag, dynamic-flag - shared gateway consumer; inference wiring is covered by the gateway role, and the gateway is a single shared instance litellm: bond: 1 - enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}" - shared: "{{ 'svc-ai-litellm' in group_names }}" + enabled: "{{ LITELLM_USABLE }}" + shared: true # nocheck: playwright-service-flag - DB engine, covered by role-local integration tests qdrant: bond: 1 @@ -60,12 +60,12 @@ flowise: no_stop_required: false image: node # nocheck: docker-version - version: "24-bookworm-slim" + version: "24-slim" npm_version: 3.1.4 sqlite_version: 12.11.1 custom: true name: flowise - # nocheck: min-storage-headroom Reason: the deployed image is built here from node:24-bookworm-slim and measures 4.8 GB, so the base image transfer is not its footprint + # nocheck: min-storage-headroom Reason: the deployed image is built here from node:24-slim and measures 4.8 GB, so the base image transfer is not its footprint min_storage: 6GB cpus: "1.0" mem_reservation: 1g diff --git a/roles/web-app-flowise/meta/variants.yml b/roles/web-app-flowise/meta/variants.yml index 2ee01b19b7..6464f2d6b0 100644 --- a/roles/web-app-flowise/meta/variants.yml +++ b/roles/web-app-flowise/meta/variants.yml @@ -28,7 +28,6 @@ shared: true litellm: enabled: true - shared: true qdrant: enabled: true shared: true @@ -68,7 +67,6 @@ shared: false # nocheck: variants-static-override backup shared stays literally true litellm: enabled: false - shared: false # nocheck: variants-static-override backup shared stays literally true qdrant: enabled: false shared: false # nocheck: variants-static-override backup shared stays literally true @@ -108,7 +106,6 @@ shared: false # nocheck: variants-static-override backup shared stays literally true litellm: enabled: false - shared: false # nocheck: variants-static-override backup shared stays literally true qdrant: enabled: false shared: false # nocheck: variants-static-override backup shared stays literally true diff --git a/roles/web-app-funkwhale/meta/services.yml b/roles/web-app-funkwhale/meta/services.yml index d102779090..92cd7265dc 100644 --- a/roles/web-app-funkwhale/meta/services.yml +++ b/roles/web-app-funkwhale/meta/services.yml @@ -11,7 +11,7 @@ funkwhale: # nocheck: default-placement-manager embedded typesense index; node-local in swarm placement: manager image: funkwhale/front - version: 2.0.10 + version: 2.0.11 min_storage: 224MB ports: internal: @@ -35,7 +35,7 @@ api: flavor: curl path: api/v1/instance/nodeinfo/2.0 image: funkwhale/api - version: 2.0.10 + version: 2.0.11 min_storage: 588MB ports: internal: diff --git a/roles/web-app-funkwhale/templates/compose.yml.j2 b/roles/web-app-funkwhale/templates/compose.yml.j2 index 5d91f8e574..813ccd6228 100644 --- a/roles/web-app-funkwhale/templates/compose.yml.j2 +++ b/roles/web-app-funkwhale/templates/compose.yml.j2 @@ -30,8 +30,9 @@ {{ lookup('container_image', application_id, 'api') }} command: gunicorn {{ lookup('container_volumes', service_name) | indent(4) }} - ports: - - "{{ FUNKWHALE_DOCKER_API_PORT }}" + {{ lookup('container_ports', + {'service': 'api', 'protocol': 'http', + 'publish': 'ephemeral'}) | indent(4) }} {{ lookup('container_depends_on', application_id, extra={ 'migrate': {'condition': 'service_completed_successfully'}, }) | indent(4) }} diff --git a/roles/web-app-fusiondirectory/meta/variants.yml b/roles/web-app-fusiondirectory/meta/variants.yml index bf19c2930c..ff10fee77b 100644 --- a/roles/web-app-fusiondirectory/meta/variants.yml +++ b/roles/web-app-fusiondirectory/meta/variants.yml @@ -60,30 +60,3 @@ shared: false # nocheck: variants-static-override backup shared stays literally true tor: enabled: true -- services: - container_backup: - enabled: false - shared: false # nocheck: variants-static-override backup shared stays literally true - sso: - enabled: false - shared: false # nocheck: variants-static-override backup shared stays literally true - css: - enabled: false - shared: false # nocheck: variants-static-override backup shared stays literally true - dashboard: - enabled: false - shared: false # nocheck: variants-static-override backup shared stays literally true - email: - enabled: false - shared: false # nocheck: variants-static-override backup shared stays literally true - logout: - enabled: false - shared: false # nocheck: variants-static-override backup shared stays literally true - matomo: - enabled: false - shared: false # nocheck: variants-static-override backup shared stays literally true - prometheus: - enabled: false - shared: false # nocheck: variants-static-override backup shared stays literally true - tor: - enabled: true diff --git a/roles/web-app-gitea/meta/services.yml b/roles/web-app-gitea/meta/services.yml index 751acb0134..d9d25c94e3 100644 --- a/roles/web-app-gitea/meta/services.yml +++ b/roles/web-app-gitea/meta/services.yml @@ -76,6 +76,9 @@ gitea: http: 8002 public: ssh: 2201 + onion: + # nocheck: onion-flag git over the onion runs on the HTTP transport the proxy already serves on port 80; a second sshd on the onion would need its own host key policy + ssh: false run_after: - web-app-matomo - web-app-keycloak diff --git a/roles/web-app-gitlab/meta/services.yml b/roles/web-app-gitlab/meta/services.yml index 0eaf633988..146427958e 100644 --- a/roles/web-app-gitlab/meta/services.yml +++ b/roles/web-app-gitlab/meta/services.yml @@ -63,6 +63,9 @@ gitlab: internal: http: 8181 ssh: 2222 + onion: + # nocheck: onion-flag GitLab Shell is published for clearnet git clients and authenticates them against the internal API by host key; onion clients clone over the HTTP transport on port 80 instead + ssh: false proxy: service_key: workhorse run_after: diff --git a/roles/web-app-hermes/meta/services.yml b/roles/web-app-hermes/meta/services.yml index 883cbff9ff..eeb8442dc1 100644 --- a/roles/web-app-hermes/meta/services.yml +++ b/roles/web-app-hermes/meta/services.yml @@ -60,11 +60,11 @@ logout: enabled: false shared: false -# nocheck: playwright-service-flag +# nocheck: playwright-service-flag, dynamic-flag - the gateway is a single shared instance, never deployed per consumer litellm: bond: 1 - enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}" - shared: "{{ 'svc-ai-litellm' in group_names }}" + enabled: "{{ LITELLM_USABLE }}" + shared: true dashboard: bond: 1 diff --git a/roles/web-app-hermes/meta/variants.yml b/roles/web-app-hermes/meta/variants.yml index ca2f16924d..676b2d63da 100644 --- a/roles/web-app-hermes/meta/variants.yml +++ b/roles/web-app-hermes/meta/variants.yml @@ -10,7 +10,6 @@ shared: true litellm: enabled: true - shared: true dashboard: enabled: true shared: true @@ -34,7 +33,6 @@ shared: true litellm: enabled: false - shared: false # nocheck: variants-static-override variant pins a literal off-switch dashboard: enabled: false shared: false # nocheck: variants-static-override variant pins a literal off-switch @@ -58,7 +56,6 @@ shared: false # nocheck: variants-static-override variant pins a literal off-switch litellm: enabled: false - shared: false # nocheck: variants-static-override variant pins a literal off-switch dashboard: enabled: false shared: false # nocheck: variants-static-override variant pins a literal off-switch diff --git a/roles/web-app-jenkins/meta/services.yml b/roles/web-app-jenkins/meta/services.yml index b87b3cee70..5de40828aa 100644 --- a/roles/web-app-jenkins/meta/services.yml +++ b/roles/web-app-jenkins/meta/services.yml @@ -46,7 +46,7 @@ jenkins: # nocheck: single-replica replicas: 1 image: jenkins/jenkins - version: lts + version: 2.568.3-lts custom: true name: jenkins ports: diff --git a/roles/web-app-jenkins/templates/casc.yaml.j2 b/roles/web-app-jenkins/templates/casc.yaml.j2 index b40e863375..e66a916a60 100644 --- a/roles/web-app-jenkins/templates/casc.yaml.j2 +++ b/roles/web-app-jenkins/templates/casc.yaml.j2 @@ -5,7 +5,7 @@ # Keycloak group / LDAP group memberships onto Jenkins authorities. jenkins: - systemMessage: "Managed by Infinito.Nexus." + systemMessage: "Managed by {{ SOFTWARE_NAME }}." numExecutors: 2 mode: NORMAL diff --git a/roles/web-app-jitsi/meta/services.yml b/roles/web-app-jitsi/meta/services.yml index b105470533..21051d1b6c 100644 --- a/roles/web-app-jitsi/meta/services.yml +++ b/roles/web-app-jitsi/meta/services.yml @@ -53,7 +53,7 @@ jitsi: swarm: enabled: true image: jitsi/web - version: "stable-9646" + version: "stable-11031" min_storage: 315MB # nocheck: default-placement-manager meta/volumes.yml uses type:bind config dirs staged on the stack host; a worker-scheduled task lacks the source paths (unpinning requires migrating them to nfs-backed named volumes) placement: manager @@ -87,7 +87,7 @@ prosody: port_key: http name: jitsi-prosody image: jitsi/prosody - version: "stable-9646" + version: "stable-11031" ports: internal: xmpp: 5222 @@ -102,7 +102,7 @@ prosody: jicofo: name: jitsi-jicofo image: jitsi/jicofo - version: "stable-9646" + version: "stable-11031" cpus: "1.0" mem_reservation: 512m mem_limit: 1g @@ -112,7 +112,7 @@ jicofo: jvb: name: jitsi-jvb image: jitsi/jvb - version: "stable-9646" + version: "stable-11031" cpus: "2.0" mem_reservation: 1g mem_limit: 2g diff --git a/roles/web-app-jitsi/templates/compose.yml.j2 b/roles/web-app-jitsi/templates/compose.yml.j2 index b893265663..d69c1d1fca 100644 --- a/roles/web-app-jitsi/templates/compose.yml.j2 +++ b/roles/web-app-jitsi/templates/compose.yml.j2 @@ -28,8 +28,9 @@ {{ lookup('compose_only', 'container_name', JITSI_JVB_CONTAINER) }} {% include 'roles/sys-svc-container/templates/base.yml.j2' %} {{ lookup('depends_on', {JITSI_PROSODY_SERVICE: 'service_started'}) }} - ports: - - "{{ JITSI_JVB_MEDIA_PORT }}:{{ JITSI_JVB_MEDIA_PORT }}/udp" # nocheck: compose-ports-must-use-container-ports + {{ lookup('container_ports', + {'service': 'jitsi', 'protocol': 'media', + 'transport': 'udp', 'container': 'same'}) | indent(4) }} {{ lookup('container_volumes', service_name) | indent(4) }} {{ lookup('container_networks') | indent(4) }} diff --git a/roles/web-app-joomla/tasks/main.yml b/roles/web-app-joomla/tasks/main.yml index d26681fb8d..0c2adc2ad8 100644 --- a/roles/web-app-joomla/tasks/main.yml +++ b/roles/web-app-joomla/tasks/main.yml @@ -4,7 +4,7 @@ name: sys-stk-front-proxy loop: "{{ JOOMLA_DOMAINS }}" loop_control: - loop_var: domain + loop_var: domain # nocheck: loop-var-shadow sys-stk-front-proxy takes the vhost through this name, see its tasks/main.yml front_proxy_domain vars: http_port: "{{ lookup('config', application_id, 'services.joomla.ports.local.http') }}" webserver_client_max_body_size: "{{ JOOMLA_POST_MAX_SIZE }}" diff --git a/roles/web-app-keycloak/meta/services.yml b/roles/web-app-keycloak/meta/services.yml index 7366782b3a..9625e85359 100644 --- a/roles/web-app-keycloak/meta/services.yml +++ b/roles/web-app-keycloak/meta/services.yml @@ -51,7 +51,7 @@ keycloak: shared: true provides: sso image: quay.io/keycloak/keycloak - version: "26.7.3" + version: "26.7.4" name: keycloak min_storage: 468MB cpus: "2.0" diff --git a/roles/web-app-keycloak/templates/sso_proxy/container.yml.j2 b/roles/web-app-keycloak/templates/sso_proxy/container.yml.j2 index 9ca1d59d3b..806fb3cd63 100644 --- a/roles/web-app-keycloak/templates/sso_proxy/container.yml.j2 +++ b/roles/web-app-keycloak/templates/sso_proxy/container.yml.j2 @@ -8,6 +8,7 @@ labels: infinito.sso-proxy.config-sha256: "{{ lookup('template', lookup('path_absolute', 'roles/web-app-keycloak/templates/sso_proxy/oauth2-proxy-keycloak.cfg.j2'), template_vars={'sso_proxy_application_id': application_id}) | hash('sha256') }}" ports: + # nocheck: compose-ports-must-use-container-ports the published port belongs to the consuming role's own entity and the container port to web-app-keycloak's sso_proxy, so the mapping spans two application_ids and no single container_ports call can read both - {{ DOCKER_BIND_HOST }}:{{ lookup('config', application_id, 'services.' ~ (application_id | get_entity_name) ~ '.ports.local.sso') }}:{{ lookup('config', 'web-app-keycloak', 'services.sso_proxy.ports.internal.http') }}/tcp volumes: - "{{ lookup('container', application_id, 'directories.volumes') }}{{ lookup('config', 'web-app-keycloak', 'services.sso_proxy.configuration_file') }}:/oauth2-proxy.cfg" diff --git a/roles/web-app-kix/templates/zzz_auth_ldap.xml.j2 b/roles/web-app-kix/templates/zzz_auth_ldap.xml.j2 index a5cd11055c..209028dcf8 100644 --- a/roles/web-app-kix/templates/zzz_auth_ldap.xml.j2 +++ b/roles/web-app-kix/templates/zzz_auth_ldap.xml.j2 @@ -1,12 +1,12 @@ - Infinito.Nexus LDAP backend (svc-db-openldap) for the web-app-kix-administrator role group. Authenticates KIX agents via Auth::LDAP and provisions the contact via Auth::Sync::LDAP. + {{ SOFTWARE_NAME }} LDAP backend (svc-db-openldap) for the web-app-kix-administrator role group. Authenticates KIX agents via Auth::LDAP and provisions the contact via Auth::Sync::LDAP. Framework [ { - "Name": "Infinito.Nexus svc-db-openldap (Agent, web-app-kix-administrator)", + "Name": "{{ SOFTWARE_NAME }} svc-db-openldap (Agent, web-app-kix-administrator)", "Enabled": 1, "UsageContext": "Agent", "Module": "Kernel::System::Auth::LDAP", @@ -57,12 +57,12 @@ - Second Infinito.Nexus LDAP backend for the web-app-kix-user role group. KIX iterates over Authentication entries; if the administrator backend (###100) refuses (user not in admin group), this one tries the user group. The OAuth2-proxy `allowed_groups` filter still lets only members of either group through, so this backend never sees a user without an Infinito.Nexus role. + Second {{ SOFTWARE_NAME }} LDAP backend for the web-app-kix-user role group. KIX iterates over Authentication entries; if the administrator backend (###100) refuses (user not in admin group), this one tries the user group. The OAuth2-proxy `allowed_groups` filter still lets only members of either group through, so this backend never sees a user without an {{ SOFTWARE_NAME }} role. Framework [ { - "Name": "Infinito.Nexus svc-db-openldap (Agent, web-app-kix-user)", + "Name": "{{ SOFTWARE_NAME }} svc-db-openldap (Agent, web-app-kix-user)", "Enabled": 1, "UsageContext": "Agent", "Module": "Kernel::System::Auth::LDAP", diff --git a/roles/web-app-lam/meta/csp.yml b/roles/web-app-lam/meta/csp.yml index 8f479faa84..c7abf2597a 100644 --- a/roles/web-app-lam/meta/csp.yml +++ b/roles/web-app-lam/meta/csp.yml @@ -3,7 +3,7 @@ flags: style-src-attr: unsafe-inline: true # nocheck: csp-unsafe Reason: historical, TODO: re-verify it is really required and does not open attack surface script-src-elem: - unsafe-inline: true # nocheck: csp-unsafe Reason: LAM renders the progress driver of its asynchronous tools as an inline script; with this off, LDAP import/export and multi edit post their job, display "Status: in progress" and never poll it, so both tools hang for every user + unsafe-inline: true # nocheck: csp-unsafe Reason: LAM emits inline