diff --git a/cli/build/docs/readme/generate.py b/cli/build/docs/readme/generate.py index 201362567f..40b6b7c780 100644 --- a/cli/build/docs/readme/generate.py +++ b/cli/build/docs/readme/generate.py @@ -44,9 +44,10 @@ def _is_invokable(role_name: str) -> bool: def _render_template(ctx: dict) -> str: - env = jinja2.Environment( # noqa: S701 — renders Markdown, not HTML; escaping would corrupt it + env = jinja2.Environment( loader=jinja2.FileSystemLoader(str(schema.TEMPLATE_DIR)), keep_trailing_newline=True, + autoescape=jinja2.select_autoescape(), ) return env.get_template(schema.TEMPLATE_NAME).render(**ctx) diff --git a/cli/build/docs/readme/schema.py b/cli/build/docs/readme/schema.py index dc66b8ea89..ca7a3b3c47 100644 --- a/cli/build/docs/readme/schema.py +++ b/cli/build/docs/readme/schema.py @@ -42,9 +42,10 @@ _OPTIONAL_CTX: dict = { def _render(ctx: dict) -> str: - env = jinja2.Environment( # noqa: S701 — renders Markdown, not HTML; escaping would corrupt it + env = jinja2.Environment( loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)), keep_trailing_newline=True, + autoescape=jinja2.select_autoescape(), ) return env.get_template(TEMPLATE_NAME).render(**ctx) diff --git a/docs/requirements/037-web-app-worldmonitor.md b/docs/requirements/037-web-app-worldmonitor.md new file mode 100644 index 0000000000..d2dea65f8f --- /dev/null +++ b/docs/requirements/037-web-app-worldmonitor.md @@ -0,0 +1,219 @@ +# 037 - World Monitor Role behind the oauth2-proxy SSO Gate + +## User Story + +As a platform administrator of Infinito.Nexus, I want [World Monitor](https://github.com/koala73/worldmonitor) integrated as a `web-app-worldmonitor` role behind the platform's Keycloak SSO (via the oauth2-proxy gate) so that users reach the real-time global intelligence dashboard with their existing Infinito.Nexus identity, in compose and in swarm, without a second login. + +## Background + +World Monitor is an AGPL-3.0 dashboard that aggregates public data feeds (earthquakes, conflicts, markets, vessel tracking and more) on a map. Upstream ships a self-hosted Docker stack of four services. + +### Upstream facts established by research + +Verified against upstream `main` and release `v2.10.0` (2026-09-08). + +| Topic | Finding | Source | +|---|---|---| +| Services | `worldmonitor` (nginx + Node API under supervisord, container port `8080`), `ais-relay` (port `3004`), `redis` (`redis:7-alpine`, `6379`), `redis-rest` (Upstash-compatible REST proxy, port `80`). | [docker-compose.yml](https://github.com/koala73/worldmonitor/blob/main/docker-compose.yml) | +| Published image | Only `ghcr.io/koala73/worldmonitor` is published (semver tags, `linux/amd64` + `linux/arm64`, built from `docker/Dockerfile`). `ais-relay` (`Dockerfile.relay`) and `redis-rest` (`docker/Dockerfile.redis-rest`) have no published image. | [docker-publish.yml](https://github.com/koala73/worldmonitor/blob/main/.github/workflows/docker-publish.yml) | +| User authentication | None in self-hosted mode. `LOCAL_API_MODE=docker` has no Clerk or Convex backend, no local user accounts and no OIDC. The app mints an anonymous `wms_` browser session signed with `WM_SESSION_SECRET`. | [SELF_HOSTING.md](https://github.com/koala73/worldmonitor/blob/main/SELF_HOSTING.md) | +| Mandatory secrets | `RELAY_SHARED_SECRET`, `REDIS_PASSWORD`, `REDIS_TOKEN`, `WM_SESSION_SECRET`. Each container exits on boot when its secret is missing. | SELF_HOSTING.md | +| Admin routes | `/api/local-*` return `403` in Docker mode. Operator settings change only through environment variables plus a container recreate. | SELF_HOSTING.md | +| Reverse proxy | `WM_TRUSTED_PROXY_CIDRS` must name the fronting proxy's network, otherwise `X-Forwarded-For` is ignored and the per-IP LLM cap (50 calls per UTC day) counts the proxy as one client. Invalid values stop the container. | SELF_HOSTING.md | +| CSP | The bundled nginx emits its own `script-src` policy without `'unsafe-inline'`. | SELF_HOSTING.md | +| Data seeding | Redis starts empty. Upstream seeds with `./scripts/run-seeders.sh` run **on the host** (Node 22) against the REST proxy published on `127.0.0.1:8079`, repeated by host cron. The relay runs additional in-container seed loops when `UPSTASH_ALLOW_INSECURE_HTTP=true`. | SELF_HOSTING.md | +| Persistence | One volume, `redis-data`. All content is re-fetchable cache and seed data. | docker-compose.yml | +| LLM | Any OpenAI-compatible endpoint through `LLM_API_URL` (full `/v1/chat/completions` URL), `LLM_API_KEY`, `LLM_MODEL`. | SELF_HOSTING.md | +| MCP | `/api/mcp`, authenticated with `X-WorldMonitor-Key` against `WORLDMONITOR_VALID_KEYS`. The OAuth path is hosted-only. | SELF_HOSTING.md | +| Swarm blockers in the upstream file | `container_name`, `depends_on`, `build:`, `mem_limit`, a host-bound `127.0.0.1:8079` publish. | docker-compose.yml | + +### In-repo analogues + +- **SSO flavor is `oauth2`** (oauth2-proxy sidecar against Keycloak), as in [`web-app-openclaw`](../../roles/web-app-openclaw/) and [`web-app-checkmk`](029-web-app-checkmk.md). The Keycloak client is provisioned by the platform for every role with `sso.enabled`. +- **Multi-service compose template with a role-built image**: [`web-app-moodle`](../../roles/web-app-moodle/templates/compose.yml.j2). Dockerfile placement and `ARG` wiring follow [dockerfile.md](../contributing/artefact/files/role/dockerfile.md). +- **Shared LiteLLM gateway consumer**: `litellm` service flag plus `secrets.credentials.litellm_api_key` in [`web-app-openclaw`](../../roles/web-app-openclaw/meta/services.yml). +- **Generated credentials**: `meta/secrets.yml` as in [`web-app-openclaw`](../../roles/web-app-openclaw/meta/secrets.yml). + +## Proposed Decisions + +The operator MUST confirm or change these before implementation starts. Once confirmed they are not re-litigated during implementation. + +| # | Decision | Rationale | +|---|---|---| +| 1 | Role `web-app-worldmonitor`, canonical hostname `worldmonitor.{{ DOMAIN_PRIMARY }}`. | Standard single-host convention. | +| 2 | SSO is the **oauth2-proxy gate only**. No native OIDC, no user mapping, no in-app accounts. | Upstream self-hosted mode has no user model to map onto. A later native OIDC switch is the existing `sso.flavor` field, so nothing extra is built for it now. | +| 3 | Every authenticated Keycloak user may enter. No `allowed_groups`, no `meta/rbac.yml`. | The dashboard has no per-user state or admin surface in Docker mode. | +| 4 | The upstream internal mechanisms stay as shipped: the anonymous `wms_` session, the relay shared secret, Redis `requirepass`, the REST proxy bearer token. | They protect service-to-service traffic and are independent of the user boundary. | +| 5 | `worldmonitor` uses the published GHCR image pinned to a concrete semver tag. `ais-relay` and `redis-rest` are built from the same pinned upstream tag. | Only one image is published. No fork is maintained. | +| 6 | Redis and the REST proxy run **inside the role stack** with their own generated credentials, not on the shared `svc-db-redis`. | The app speaks only the Upstash REST dialect through the bundled proxy, and upstream runs Redis with `allkeys-lru` eviction that must not apply to other consumers. | +| 7 | Seeding runs **inside the stack** as a periodic seeder service. No host Node, no host cron, no published REST proxy port. | The upstream host-side flow needs a host-bound port and a host runtime, which breaks in swarm and violates service isolation. | +| 8 | The Redis volume persists but is **excluded from backup**. | Content is re-fetchable cache. A restore is a re-seed. | +| 9 | The LLM is wired to the shared LiteLLM gateway when `LITELLM_USABLE`. `OPENROUTER_API_KEY` is not used. | One platform gateway instead of a per-app provider key. | +| 10 | External data-source API keys are optional operator-supplied inventory values, empty when unset. | Upstream degrades gracefully per feed. | +| 11 | `/api/mcp` is **not** registered as a platform MCP server in this requirement. It stays behind the gate and `WORLDMONITOR_VALID_KEYS` stays empty. | The MCP path authenticates with a static key, not with the platform identity. Tracked separately if wanted. | + +## Target Schema + +### Role layout + +``` +roles/web-app-worldmonitor/ +├── README.md +├── files/Dockerfile +├── files/playwright/{_shared,playwright.spec,test-guest,test-oidc-login,test-isolation}.js +├── meta/{main,info,domains,networks,csp,services,secrets,variants,volumes}.yml +├── tasks/{main,00_core}.yml +├── templates/{compose.yml.j2,env.j2,playwright.env.j2} +└── vars/main.yml +``` + +### `meta/services.yml` excerpt + +```yaml +worldmonitor: + modes: + compose: { enabled: true } + swarm: { enabled: true } + image: ghcr.io/koala73/worldmonitor + version: "X.Y.Z" + name: worldmonitor + ports: + internal: { http: 8080 } + local: { http: , sso: } + lifecycle: alpha +sso: + bond: 1 + enabled: "{{ 'web-app-keycloak' in group_names }}" + shared: "{{ 'web-app-keycloak' in group_names }}" + flavor: oauth2 + oauth2: + origin: { host: worldmonitor, port: 8080 } +litellm: + bond: 1 + enabled: "{{ LITELLM_USABLE }}" + shared: true +``` + +### `meta/secrets.yml` credentials + +`relay_shared_secret`, `redis_password`, `redis_token`, `session_secret`, `litellm_api_key`. + +## Acceptance Criteria + +### Role layout & images + +- [ ] `roles/web-app-worldmonitor/` exists per the [Target Schema](#role-layout) and all `meta/*.yml` pass the role-meta lint. +- [ ] `worldmonitor` runs `ghcr.io/koala73/worldmonitor` pinned to a concrete semver tag whose existence in GHCR was verified. No `:latest`. +- [ ] `ais-relay` and `redis-rest` are built from the same pinned upstream tag, with every external value passed as a Docker `ARG` per [dockerfile.md](../contributing/artefact/files/role/dockerfile.md). +- [ ] Every image used by the role is declared once in `meta/services.yml` and is found by the image discovery. + +### Compose + +- [ ] A compose deploy brings `worldmonitor`, `ais-relay`, `redis`, `redis-rest` and the seeder to a steady running state with no manual step. + +### Swarm + +- [ ] The same role deploys as a swarm stack and reaches the same steady state. +- [ ] The rendered swarm stack contains no `container_name`, no `mem_limit` and no host-bound port publish, and no service fails permanently when its dependencies start after it. +- [ ] The role-built images are available to every swarm node that may schedule the services. + +### Secrets + +- [ ] `RELAY_SHARED_SECRET`, `REDIS_PASSWORD`, `REDIS_TOKEN` and `WM_SESSION_SECRET` are generated through `meta/secrets.yml` and no literal secret value is committed. +- [ ] `I_UNDERSTAND_THIS_DISABLES_AUTH` and `ALLOW_UNAUTHENTICATED_RELAY` are not set anywhere in the role. +- [ ] Optional external API keys (`FINNHUB_API_KEY`, `ALPHA_VANTAGE_API_KEY`, `FRED_API_KEY`, `EIA_API_KEY`, `ACLED_*`, `NASA_FIRMS_API_KEY`, `AVIATIONSTACK_API`, `TRAVELPAYOUTS_API_TOKEN`, `AISSTREAM_API_KEY`, `CLOUDFLARE_API_TOKEN`) reach the containers that read them when the operator sets them, and the stack is healthy when all are empty. + +### Routing & TLS + +- [ ] `https://worldmonitor.{{ DOMAIN_PRIMARY }}/` resolves through `sys-svc-proxy` to the `worldmonitor` service and serves the dashboard SPA. +- [ ] `WM_TRUSTED_PROXY_CIDRS` is set to the network the fronting proxy reaches the container from, in compose and in swarm, and the app logs the real client address for a proxied request. +- [ ] The dashboard's live updates (the WebSocket and streaming endpoints it opens) work through the proxy with no console error. +- [ ] The canonical surface answers with exactly one `Content-Security-Policy` header, and the dashboard renders under it with no CSP violation in the browser console. + +### SSO (Decisions #2 to #4) + +- [ ] With SSO enabled, an unauthenticated request to any path, including `/api/*`, is redirected to the Keycloak login. +- [ ] After login the user lands on the dashboard with no further credential prompt. +- [ ] A user with an existing Keycloak session reaches the dashboard without an interactive login. +- [ ] After logout through the platform logout, a request to the dashboard is redirected to the Keycloak login again. +- [ ] With SSO disabled, the dashboard is reachable anonymously and the stack is otherwise unchanged. + +### Service isolation + +- [ ] Only the `worldmonitor` HTTP port is published, bound to the platform bind host. `redis`, `redis-rest` and `ais-relay` publish no port. +- [ ] From outside the role's network, `redis:6379`, `redis-rest:80` and `ais-relay:3004` are unreachable. +- [ ] Redis rejects an unauthenticated command, and `redis-rest` rejects a request without the bearer token. + +### Data seeding (Decision #7) + +- [ ] After a fresh deploy the dashboard shows seeded data from the key-free feeds with no command run on the host. +- [ ] Seeding repeats on a fixed interval inside the stack, and one hung feed does not block the others. + +### Persistence & backup (Decision #8) + +- [ ] Seeded data survives a recreate of every container in the stack, in compose and on the swarm storage layer. +- [ ] The Redis volume is excluded from the platform backup. + +### LLM (Decision #9) + +- [ ] When `LITELLM_USABLE` is true, `LLM_API_URL`, `LLM_API_KEY` and `LLM_MODEL` point at the shared LiteLLM gateway with the role's own virtual key, and an intelligence brief request returns generated text. +- [ ] When it is false, the three variables are empty and the stack is healthy. + +### Health checks + +- [ ] `worldmonitor` has a container healthcheck that fails when either nginx or the Node API is down. +- [ ] `redis` and `redis-rest` have container healthchecks, and the `redis-rest` check fails when Redis is unreachable. +- [ ] `ais-relay` has a container healthcheck. + +### Variants + +- [ ] `meta/variants.yml` covers SSO on and SSO off, and every variant deploys cleanly on a fresh host. + +### Playwright + +- [ ] `test-guest.js` asserts the redirect to Keycloak for the root path and for an `/api/` path. +- [ ] `test-oidc-login.js` logs in as `biber`, asserts the map renders, and ends logged out with the dashboard gated again. +- [ ] `test-isolation.js` asserts that no internal service answers on the canonical host. +- [ ] `templates/playwright.env.j2` exposes every environment variable the specs read, and `make compose-playwright role=web-app-worldmonitor` exits 0 with no stub test. +- [ ] The same specs pass against the swarm deploy. + +### Documentation & quality + +- [ ] `README.md` documents the gate-only SSO model, the retained internal secrets, the in-stack seeding, the optional API keys with their effect, the image pin and bump policy, and the AGPL-3.0 licence of the upstream. +- [ ] `make test` is green tree-wide. +- [ ] This requirement is cross-linked from the implementing PR. + +## Out of Scope + +- Native OIDC, user mapping and in-app roles (no upstream support in self-hosted mode). +- Registering `/api/mcp` as a platform MCP server. +- The hosted-only features (Clerk entitlements, premium routes, OAuth for MCP). + +## Validation Apps + +```bash +make compose-deploy apps=web-app-worldmonitor purge=true full_cycle=true +``` + +Cross-mode parity follows the [Roundtrip Loop](../agents/action/iteration/roundtrip.md). + +Smoke: visit `https://worldmonitor.{{ DOMAIN_PRIMARY }}/`, pass the Keycloak login, confirm the map shows seeded layers, log out, confirm the gate returns. + +## Prerequisites + +Before implementation, the agent MUST read [AGENTS.md](../../AGENTS.md), then [Compose Loop](../agents/action/iteration/compose.md) and the [Playwright contract](../contributing/artefact/files/role/playwright.specs.js.md). + +## Implementation Strategy + +Execute autonomously once the [Proposed Decisions](#proposed-decisions) are confirmed. Scaffold from [`roles/web-app-openclaw/`](../../roles/web-app-openclaw/) (oauth2 flavor, generated credentials, LiteLLM consumer) and take the multi-service compose shape from [`roles/web-app-moodle/`](../../roles/web-app-moodle/). Before claiming done, verify against the pinned upstream tag: the environment variable names, whether the published app image contains the seeder scripts, the health endpoint path, and the dashboard selectors used by the specs. + +## Commit Policy + +- No git commit until every Acceptance Criterion is checked off. +- When met and `make test` is green, instruct the operator to run `git-sign-push` outside the sandbox. The agent MUST NOT push. + +## Context + +- Upstream repo: +- Self-hosting guide: +- Closest in-repo analogues: [`roles/web-app-openclaw/`](../../roles/web-app-openclaw/), [`029-web-app-checkmk.md`](029-web-app-checkmk.md) diff --git a/package-lock.json b/package-lock.json index dec3a469ef..3991767cf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1902,9 +1902,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", - "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2096,9 +2096,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/roles/svc-ai-mcp-adapter/README.md b/roles/svc-ai-mcp-adapter/README.md index cfd9b2bf62..2d0d4d4bc3 100644 --- a/roles/svc-ai-mcp-adapter/README.md +++ b/roles/svc-ai-mcp-adapter/README.md @@ -26,6 +26,7 @@ Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional ( - **Client-facing authentication:** The adapter issues its own bearer. The upstream credential authenticates the adapter to the provider and says nothing about who called the adapter, which is the gap the project-owned MCP sidecars leave open. - **Allowlist on `tools/call`:** Filtering only `tools/list` would leave every unlisted operation callable by name. - **Read-only by default:** A non-`GET`/`HEAD` operation is refused unless `mutating_tools_enabled` is explicitly true. +- **Path arguments stay in their segment:** A value that fills a `{placeholder}` of the contracted path is percent-encoded, and an empty value or one made only of dots is refused, so a call cannot resolve to a path the contract does not name. - **Enforced ceilings:** Request size, response size, timeout, concurrency, page size and result count come from the contract and are applied, not merely declared. - **Bounded ranges:** A tool whose schema declares `start`, `end` and `step` has its point count checked against `result_items` and is refused with the smallest step that fits, so the upstream never scans a range whose result the adapter would discard. - **Fail-closed on drift:** A tool contract whose hash no longer matches its pinned `schema_sha256` refuses to start. diff --git a/roles/svc-ai-mcp-adapter/files/python/policy.py b/roles/svc-ai-mcp-adapter/files/python/policy.py index fc4ed18be2..b527da2b6c 100644 --- a/roles/svc-ai-mcp-adapter/files/python/policy.py +++ b/roles/svc-ai-mcp-adapter/files/python/policy.py @@ -47,6 +47,7 @@ DENY_TOO_MANY_REQUESTS = "too_many_concurrent_requests" DENY_UNKNOWN_ARGUMENT = "undeclared_argument" DENY_MISSING_ARGUMENT = "missing_required_argument" DENY_RANGE_TOO_WIDE = "range_too_wide" +DENY_PATH_ARGUMENT = "path_argument_not_a_segment" RANGE_ARGUMENTS = ("start", "end", "step") @@ -189,6 +190,17 @@ def assert_arguments( raise PermissionError(f"{DENY_MISSING_ARGUMENT}: {name!r} requires {missing}") +def assert_path_arguments( + spec: Mapping[str, Any], name: str, arguments: Mapping[str, Any] | None +) -> None: + path = str(spec["path"]) + for key, value in (arguments or {}).items(): + if "{" + key + "}" in path and not str(value).strip("."): + raise PermissionError( + f"{DENY_PATH_ARGUMENT}: {name!r} takes no {str(value)!r} as {key!r}" + ) + + def parse_instant(value: Any) -> float: """Return a range boundary in unix seconds. @@ -217,10 +229,14 @@ def parse_duration(value: Any) -> float: return float(text) except ValueError: pass - parts = re.findall(r"(\d+)(ms|[smhdwy])", text) - if not parts or "".join(n + u for n, u in parts) != text: + *tokens, rest = re.split(r"(ms|[smhdwy])", text) + numbers, units = tokens[::2], tokens[1::2] + if rest or not numbers or not all(number.isdecimal() for number in numbers): raise ValueError(f"not a duration: {text!r}") - return sum(int(n) * DURATION_UNITS[u] for n, u in parts) + return sum( + int(number) * DURATION_UNITS[unit] + for number, unit in zip(numbers, units, strict=True) + ) def assert_range( @@ -341,6 +357,7 @@ def authorize_call( ) assert_arguments(spec, name, arguments) + assert_path_arguments(spec, name, arguments) assert_range(spec, name, arguments, contract["limits"]["result_items"]) return method, str(spec["path"]) diff --git a/roles/svc-ai-ollama/meta/services.yml b/roles/svc-ai-ollama/meta/services.yml index b60dc775cc..10de306f04 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.40.0 + version: 0.40.1 name: ollama min_storage: 3473MB cpus: "4.0" diff --git a/roles/svc-runner/meta/services.yml b/roles/svc-runner/meta/services.yml index 3307cb8781..17dddd36b2 100644 --- a/roles/svc-runner/meta/services.yml +++ b/roles/svc-runner/meta/services.yml @@ -30,7 +30,7 @@ buildkit: enabled: false shared: false image: moby/buildkit - version: "v0.33.1" + version: "v0.34.0" # nocheck: dynamic-flag act-runner: enabled: false diff --git a/roles/sys-svc-compose/handlers/compose.yml b/roles/sys-svc-compose/handlers/compose.yml index 416639c3e8..97aa1f7421 100644 --- a/roles/sys-svc-compose/handlers/compose.yml +++ b/roles/sys-svc-compose/handlers/compose.yml @@ -81,7 +81,11 @@ args: chdir: "{{ lookup('container', application_id, 'directories.instance') }}" executable: /bin/bash + register: compose_rebuild timeout: "{{ lookup('timeout', 3600) | int }}" + retries: 3 + delay: 30 + until: compose_rebuild is succeeded when: compose_build is failed listen: - compose-build diff --git a/roles/sys-svc-compose/handlers/swarm.yml b/roles/sys-svc-compose/handlers/swarm.yml index facdcd8727..20e8e3f08e 100644 --- a/roles/sys-svc-compose/handlers/swarm.yml +++ b/roles/sys-svc-compose/handlers/swarm.yml @@ -32,7 +32,11 @@ args: chdir: "{{ lookup('container', application_id, 'directories.instance') }}" executable: /bin/bash + register: _swarm_pre_deploy_rebuild timeout: "{{ lookup('timeout', 3600) | int }}" + retries: 3 + delay: 30 + until: _swarm_pre_deploy_rebuild is succeeded when: _swarm_pre_deploy_build is failed listen: - compose-up diff --git a/roles/test-e2e-cli/files/shared/mcp/contract.py b/roles/test-e2e-cli/files/shared/mcp/contract.py index 4af0b34781..f7997a1f7a 100644 --- a/roles/test-e2e-cli/files/shared/mcp/contract.py +++ b/roles/test-e2e-cli/files/shared/mcp/contract.py @@ -45,6 +45,7 @@ Environment: from __future__ import annotations +import contextlib import json import os import queue @@ -76,7 +77,7 @@ SESSION_TIMEOUT = 20 WRONG_CREDENTIAL = "Bearer " + "0" * 40 SESSION_HEADER = "Mcp-Session-Id" -SESSION = {"id": ""} +SESSION = {"id": "", "protocol": ""} def unguarded_url(): @@ -120,6 +121,8 @@ def rpc(method, params=None, authorization=None, url=None, notification=False): request.add_header("Authorization", authorization) if SESSION["id"]: request.add_header(SESSION_HEADER, SESSION["id"]) + if SESSION["protocol"]: + request.add_header("MCP-Protocol-Version", SESSION["protocol"]) try: with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310 fixed internal http origin session = response.headers.get(SESSION_HEADER) @@ -320,21 +323,20 @@ class SseSession: self.content_type = self._response.headers.get("Content-Type", "") event = None try: - for raw in self._response: - line = raw.decode(errors="replace").rstrip("\r\n") - if line.startswith("event:"): - event = line.split(":", 1)[1].strip() - elif line.startswith("data:"): - data = line.split(":", 1)[1].strip() - if event == "endpoint": - self.endpoint = urllib.parse.urljoin(self.url, data) - self._announced.set() - else: - self._messages.put(data) - elif not line: - event = None - except (OSError, ValueError): - pass + with contextlib.suppress(OSError, ValueError): + for raw in self._response: + line = raw.decode(errors="replace").rstrip("\r\n") + if line.startswith("event:"): + event = line.split(":", 1)[1].strip() + elif line.startswith("data:"): + data = line.split(":", 1)[1].strip() + if event == "endpoint": + self.endpoint = urllib.parse.urljoin(self.url, data) + self._announced.set() + else: + self._messages.put(data) + elif not line: + event = None finally: self._announced.set() @@ -463,6 +465,8 @@ def main(): if status != 200: reject(f"authenticated initialize answered {status}: {body[:200]}") reject_error("authenticated initialize", rpc_error(body)) + result = json.loads(body).get("result") or {} + SESSION["protocol"] = result.get("protocolVersion", "") rpc("notifications/initialized", authorization=AUTH, notification=True) diff --git a/roles/web-app-bigbluebutton/meta/services.yml b/roles/web-app-bigbluebutton/meta/services.yml index a76a7ddfcf..4423853444 100644 --- a/roles/web-app-bigbluebutton/meta/services.yml +++ b/roles/web-app-bigbluebutton/meta/services.yml @@ -100,7 +100,7 @@ prometheus: # nocheck: dynamic-flag: fixed upstream-compose sub-service, not group-gated bbb-web: image: alangecker/bbb-docker-web - version: "v3.0.23" + version: "v3.0.39" enabled: false # nocheck: playwright-service-flag: prebuilt upstream-compose image, mirror pre-pull only # nocheck: dynamic-flag: fixed upstream-compose sub-service, not group-gated @@ -130,25 +130,25 @@ bbb-pads: # nocheck: dynamic-flag: fixed upstream-compose sub-service, not group-gated bbb-export-annotations: image: alangecker/bbb-docker-bbb-export-annotations - version: "v3.0.23" + version: "v3.0.39" enabled: false # nocheck: playwright-service-flag: prebuilt upstream-compose image, mirror pre-pull only # nocheck: dynamic-flag: fixed upstream-compose sub-service, not group-gated webrtc-sfu: image: alangecker/bbb-docker-webrtc-sfu - version: "v2.22.0" + version: "v2.24.0" enabled: false # nocheck: playwright-service-flag: prebuilt upstream-compose image, mirror pre-pull only # nocheck: dynamic-flag: fixed upstream-compose sub-service, not group-gated fsesl-akka: image: alangecker/bbb-docker-fsesl-akka - version: "v3.0.23" + version: "v3.0.39" enabled: false # nocheck: playwright-service-flag: prebuilt upstream-compose image, mirror pre-pull only # nocheck: dynamic-flag: fixed upstream-compose sub-service, not group-gated apps-akka: image: alangecker/bbb-docker-apps-akka - version: "v3.0.23" + version: "v3.0.39" enabled: false # nocheck: playwright-service-flag: prebuilt upstream-compose image, mirror pre-pull only # nocheck: dynamic-flag: fixed upstream-compose sub-service, not group-gated @@ -159,19 +159,19 @@ bbb-graphql-server: timeout: 10s retries: 5 image: alangecker/bbb-docker-graphql-server - version: "v3.0.23" + version: "v3.0.39" enabled: false # nocheck: playwright-service-flag: prebuilt upstream-compose image, mirror pre-pull only # nocheck: dynamic-flag: fixed upstream-compose sub-service, not group-gated bbb-graphql-actions: image: alangecker/bbb-docker-graphql-actions - version: "v3.0.23" + version: "v3.0.39" enabled: false # nocheck: playwright-service-flag: prebuilt upstream-compose image, mirror pre-pull only # nocheck: dynamic-flag: fixed upstream-compose sub-service, not group-gated bbb-graphql-middleware: image: alangecker/bbb-docker-graphql-middleware - version: "v3.0.23" + version: "v3.0.39" enabled: false # nocheck: playwright-service-flag: prebuilt upstream-compose image, mirror pre-pull only # nocheck: dynamic-flag: fixed upstream-compose sub-service, not group-gated @@ -183,7 +183,7 @@ periodic: # nocheck: dynamic-flag: fixed upstream-compose sub-service, not group-gated recordings: image: alangecker/bbb-docker-recordings - version: "v3.0.23" + version: "v3.0.39" enabled: false # nocheck: playwright-service-flag: prebuilt upstream-compose image, mirror pre-pull only # nocheck: dynamic-flag: fixed upstream-compose sub-service, not group-gated diff --git a/roles/web-app-chess/meta/services.yml b/roles/web-app-chess/meta/services.yml index dc6daa65c6..fd94f70819 100644 --- a/roles/web-app-chess/meta/services.yml +++ b/roles/web-app-chess/meta/services.yml @@ -31,7 +31,7 @@ chess: swarm: enabled: true image: ghcr.io/kevinveenbirkenbach/fedi-chess - version: v1.0.1 + version: v1.2.2 name: web-app-chess min_storage: 633MB backup: diff --git a/roles/web-app-flowise/meta/services.yml b/roles/web-app-flowise/meta/services.yml index 0a6448c40e..df6ccc0bd4 100644 --- a/roles/web-app-flowise/meta/services.yml +++ b/roles/web-app-flowise/meta/services.yml @@ -69,7 +69,7 @@ flowise: - key: sqlite_version type: npm package: better-sqlite3 - sqlite_version: 12.11.1 + sqlite_version: 13.0.3 custom: true name: flowise # 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 diff --git a/roles/web-app-moodle/README.md b/roles/web-app-moodle/README.md index a0a39431e2..fd795b0944 100644 --- a/roles/web-app-moodle/README.md +++ b/roles/web-app-moodle/README.md @@ -121,7 +121,7 @@ docker run --rm -it \ ## MCP server -The role exposes a Model Context Protocol surface through the `webservice_mcp` protocol plugin, baked into the image at `webservice/mcp` and served by the NGINX sidecar. +The role exposes a Model Context Protocol surface through the `webservice_mcp` protocol plugin, baked into the image at `public/webservice/mcp` and served by the NGINX sidecar. | Property | Value | | --- | --- | @@ -173,7 +173,13 @@ This is an accepted trade-off, not an oversight. Moodle offers no allowlist, so ## Image source -This role builds its own Moodle image from upstream Moodle source on top of the official `php:8.3-fpm` base. +This role builds its own Moodle image from upstream Moodle source on top of the official `php:8.3-fpm` base and installs the Composer dependencies that source declares. The NGINX sidecar serves the `public` directory of the code volume and hands every other path to Moodle's router. + +## Updates + +The image carries the Moodle release pinned as `services.moodle.app_version` in `meta/services.yml`. On start the entrypoint compares that release with the one recorded in the code volume. When the image is newer, it copies the image's tree next to the old one and swaps them once the copy is complete. An image that is older than the recorded release refuses to start. `tasks/01_manager_ops.yml` then runs `admin/cli/upgrade.php`, which migrates the database. + +Plugins installed through the web interface live only in the code volume and are not carried over to the next release. Declare a plugin under `meta/addons/` so the image ships it. ## Further Resources diff --git a/roles/web-app-moodle/files/Dockerfile b/roles/web-app-moodle/files/Dockerfile index dc36c5be3a..f6aa1f92c2 100644 --- a/roles/web-app-moodle/files/Dockerfile +++ b/roles/web-app-moodle/files/Dockerfile @@ -96,6 +96,8 @@ COPY build/php-conf/opcache-recommended.ini /usr/local/etc/php/conf.d/opcache-re COPY build/php-conf/moodle-runtime.ini /usr/local/etc/php/conf.d/moodle-runtime.ini COPY build/php-conf/zz-moodle-pool.conf /usr/local/etc/php-fpm.d/zz-moodle-pool.conf +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + COPY build/install/moodle-source.sh /tmp/install-moodle-source.sh RUN bash /tmp/install-moodle-source.sh && rm /tmp/install-moodle-source.sh diff --git a/roles/web-app-moodle/files/build/config.php b/roles/web-app-moodle/files/build/config.php index 5434affc26..5e66fb1fcd 100644 --- a/roles/web-app-moodle/files/build/config.php +++ b/roles/web-app-moodle/files/build/config.php @@ -40,6 +40,7 @@ $CFG->dataroot = moodle_env('MOODLE_DATAROOT'); $CFG->localcachedir = moodle_env('MOODLE_LOCALCACHEDIR'); $CFG->admin = 'admin'; $CFG->directorypermissions = 02770; +$CFG->routerconfigured = true; $CFG->reverseproxy = moodle_env_bool('MOODLE_REVERSEPROXY'); $CFG->sslproxy = moodle_env_bool('MOODLE_SSLPROXY'); diff --git a/roles/web-app-moodle/files/build/install/moodle-source.sh b/roles/web-app-moodle/files/build/install/moodle-source.sh index 8674cbc69f..17e5fa8df1 100644 --- a/roles/web-app-moodle/files/build/install/moodle-source.sh +++ b/roles/web-app-moodle/files/build/install/moodle-source.sh @@ -26,6 +26,8 @@ tar -xzf "${TARBALL_PATH}" --strip-components=1 -C "${MOODLE_SOURCE_DIR}" test -d "${MOODLE_SOURCE_DIR}/${MOODLE_AUTH_SUBDIR}" test -f "${MOODLE_SOURCE_DIR}/${MOODLE_VERSION_FILE}" +COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --classmap-authoritative --no-interaction --working-dir="${MOODLE_SOURCE_DIR}" + mkdir -p "${MOODLE_CODE_DIR}" "${MOODLE_DATA_DIR}" chown -R "${MOODLE_RUNTIME_USER}:${MOODLE_RUNTIME_USER}" \ "${MOODLE_SOURCE_DIR}" "${MOODLE_CODE_DIR}" "${MOODLE_DATA_DIR}" diff --git a/roles/web-app-moodle/files/build/moodle-entrypoint.sh b/roles/web-app-moodle/files/build/moodle-entrypoint.sh index 8474a21407..30083ff2ff 100644 --- a/roles/web-app-moodle/files/build/moodle-entrypoint.sh +++ b/roles/web-app-moodle/files/build/moodle-entrypoint.sh @@ -1,6 +1,6 @@ #!/bin/bash # Moodle container entrypoint. Bootstrap the persistent code volume -# from ${MOODLE_SOURCE_DIR} on first start, ensure ownership on the +# from ${MOODLE_SOURCE_DIR}, ensure ownership on the # data dir, then drop privileges (unless launching php-fpm — its # master process must keep root so the FPM error log can write to # /proc/self/fd/2; workers fork to ${MOODLE_RUNTIME_USER} per the pool). @@ -14,9 +14,11 @@ set -euo pipefail : "${MOODLE_SOURCE_DIR:?required}" : "${MOODLE_RUNTIME_USER:?required}" : "${MOODLE_VERSION_FILE:?required}" +: "${MOODLE_RELEASE:?required}" MOODLE_BOOTSTRAP_SENTINEL="${MOODLE_CODE_DIR}/.bootstrap.done" MOODLE_BOOTSTRAP_LOCK="${MOODLE_CODE_DIR}/.bootstrap.lock" +MOODLE_BOOTSTRAP_STAGE="${MOODLE_CODE_DIR}/.bootstrap.stage" mkdir -p "${MOODLE_DATA_DIR}" chown -R "${MOODLE_RUNTIME_USER}:${MOODLE_RUNTIME_USER}" "${MOODLE_DATA_DIR}" || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error @@ -26,17 +28,36 @@ if [ "$(id -u)" -eq 0 ]; then chown -R "${MOODLE_RUNTIME_USER}:${MOODLE_RUNTIME_USER}" "${MOODLE_LOCAL_CACHE_DIR}" fi +moodle_code_dir_is_current() { + [ "$(cat "${MOODLE_BOOTSTRAP_SENTINEL}" 2>/dev/null)" = "${MOODLE_RELEASE}" ] +} + +moodle_code_dir_is_newer() { + local recorded newest + recorded="$(cat "${MOODLE_BOOTSTRAP_SENTINEL}" 2>/dev/null)" || return 1 + [ -n "${recorded}" ] || return 1 + newest="$(printf '%s\n' "${recorded}" "${MOODLE_RELEASE}" | sort -V | tail -n 1)" + [ "${newest}" = "${recorded}" ] && [ "${recorded}" != "${MOODLE_RELEASE}" ] +} + moodle_bootstrap_code_dir() { - if [ -f "${MOODLE_BOOTSTRAP_SENTINEL}" ]; then + if moodle_code_dir_is_current; then return 0 fi - if [ -d "${MOODLE_SOURCE_DIR}" ]; then - cp -an "${MOODLE_SOURCE_DIR}/." "${MOODLE_CODE_DIR}/" || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error + if moodle_code_dir_is_newer; then + echo "The code volume holds Moodle $(cat "${MOODLE_BOOTSTRAP_SENTINEL}"), this image ships the older ${MOODLE_RELEASE}; refusing to replace it." >&2 + exit 1 fi + rm -rf "${MOODLE_BOOTSTRAP_STAGE}" + mkdir "${MOODLE_BOOTSTRAP_STAGE}" + cp -a "${MOODLE_SOURCE_DIR}/." "${MOODLE_BOOTSTRAP_STAGE}/" + find "${MOODLE_CODE_DIR}" -mindepth 1 -maxdepth 1 ! -name "${MOODLE_BOOTSTRAP_LOCK##*/}" ! -name "${MOODLE_BOOTSTRAP_STAGE##*/}" -exec rm -rf {} + + find "${MOODLE_BOOTSTRAP_STAGE}" -mindepth 1 -maxdepth 1 -exec mv -t "${MOODLE_CODE_DIR}" {} + + rmdir "${MOODLE_BOOTSTRAP_STAGE}" chown -R "${MOODLE_RUNTIME_USER}:${MOODLE_RUNTIME_USER}" "${MOODLE_CODE_DIR}" || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error find "${MOODLE_CODE_DIR}" -type d -exec chmod 0755 {} + || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error find "${MOODLE_CODE_DIR}" -type f -exec chmod 0644 {} + || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error - touch "${MOODLE_BOOTSTRAP_SENTINEL}" + printf '%s' "${MOODLE_RELEASE}" > "${MOODLE_BOOTSTRAP_SENTINEL}" } moodle_refresh_config() { @@ -51,7 +72,7 @@ moodle_refresh_config() { } mkdir -p "${MOODLE_CODE_DIR}" -while [ ! -f "${MOODLE_BOOTSTRAP_SENTINEL}" ]; do +until moodle_code_dir_is_current; do exec 9>>"${MOODLE_BOOTSTRAP_LOCK}" if flock -w 30 9; then moodle_bootstrap_code_dir diff --git a/roles/web-app-moodle/files/php/provision/ai_gateway.php b/roles/web-app-moodle/files/php/provision/ai_gateway.php new file mode 100644 index 0000000000..cfebaf1d8e --- /dev/null +++ b/roles/web-app-moodle/files/php/provision/ai_gateway.php @@ -0,0 +1,44 @@ +get_provider_instances(['provider' => $class, 'name' => $name]); +$provider = $instances + ? reset($instances) + : $manager->create_provider_instance(classname: $class, name: $name); + +$actionconfig = $provider->actionconfig; +foreach ($textactions as $action) { + $actionconfig[$action]['enabled'] = true; + $actionconfig[$action]['settings']['endpoint'] = (string) getenv('AI_CHAT_ENDPOINT'); + $actionconfig[$action]['settings']['model'] = (string) getenv('AI_CHAT_MODEL'); +} +$actionconfig[$imageaction]['enabled'] = false; +$actionconfig[$imageaction]['settings']['endpoint'] = (string) getenv('AI_IMAGE_ENDPOINT'); + +$provider = $manager->update_provider_instance( + provider: $provider, + config: array_merge($provider->config, ['apikey' => (string) getenv('AI_API_KEY')]), + actionconfig: $actionconfig, +); +$manager->enable_provider_instance($provider); + +$stored = $manager->get_provider_instances(['id' => $provider->id]); +echo json_encode([ + 'id' => (int) $provider->id, + 'endpoints' => array_map( + fn(array $action): string => (string) ($action['settings']['endpoint'] ?? ''), + reset($stored)->actionconfig, + ), +]), PHP_EOL; diff --git a/roles/web-app-moodle/files/php/provision_mcp.php b/roles/web-app-moodle/files/php/provision/mcp.php similarity index 100% rename from roles/web-app-moodle/files/php/provision_mcp.php rename to roles/web-app-moodle/files/php/provision/mcp.php diff --git a/roles/web-app-moodle/files/playwright/test-litellm-gateway.js b/roles/web-app-moodle/files/playwright/test-litellm-gateway.js index 038d3c3c70..6c7cec5be5 100644 --- a/roles/web-app-moodle/files/playwright/test-litellm-gateway.js +++ b/roles/web-app-moodle/files/playwright/test-litellm-gateway.js @@ -5,7 +5,10 @@ const { skipUnlessServiceEnabled } = require("./service-gating"); const adminNativePassword = decodeDotenvQuotedValue(process.env.ADMIN_NATIVE_PASSWORD || ""); -const ENDPOINT_FIELD = "#id_s_aiprovider_openai_action_generate_text_endpoint"; +const providerName = decodeDotenvQuotedValue(process.env.MOODLE_AI_PROVIDER_NAME || ""); + +const ENDPOINT_FIELD = "#id_endpoint"; +const GENERATE_TEXT_ACTION = "core_ai\\aiactions\\generate_text"; const USER_MENU = ".usermenu, [data-region='user-menu-toggle'], a[href*='profile.php']"; const GATEWAY_PROMPT = "Reply with the single word: pong"; @@ -80,22 +83,47 @@ exports.register = function (shared) { await loginAsSiteAdmin(page, shared); + expect( + providerName, + "MOODLE_AI_PROVIDER_NAME must name the provider instance the deploy creates; without it the test cannot tell that instance from one an administrator added", + ).toBeTruthy(); + + await gotoOnion(page, `${shared.env.moodleBaseUrl}/admin/settings.php?section=aiprovider`, { + waitUntil: "domcontentloaded", + timeout: resolveTimeout(60_000), + }); + + const providerLink = page + .locator("tr", { hasText: providerName }) + .locator("a[href*='/ai/configure.php']") + .first(); + await expect( + providerLink, + `the AI providers page must list the instance "${providerName}"; its absence means the deploy created no provider instance or the session lacks moodle/site:config`, + ).toBeVisible({ timeout: resolveTimeout(60_000) }); + + const providerId = new URL( + await providerLink.getAttribute("href"), + shared.env.moodleBaseUrl, + ).searchParams.get("id"); + await gotoOnion( page, - `${shared.env.moodleBaseUrl}/admin/settings.php?section=aiprovider_openai_generate_text`, + `${shared.env.moodleBaseUrl}/ai/configure_actions.php` + + `?provider=aiprovider_openai&action=${encodeURIComponent(GENERATE_TEXT_ACTION)}&providerid=${providerId}`, { waitUntil: "domcontentloaded", timeout: resolveTimeout(60_000) }, ); const endpointField = page.locator(ENDPOINT_FIELD); await expect( endpointField, - "the aiprovider_openai generate_text settings page must render its API endpoint field; its absence means the provider was never configured or the session lacks moodle/site:config", + "the generate_text settings page of the provider instance must render its API endpoint field; its absence means the instance carries no generate_text action", ).toBeVisible({ timeout: resolveTimeout(60_000) }); const configuredEndpoint = ((await endpointField.inputValue()) || "").trim(); expect( configuredEndpoint, - "aiprovider_openai/action_generate_text_endpoint must be an http(s) URL written by the deploy, not the upstream api.openai.com default", + "the generate_text endpoint of the provider instance must be an http(s) URL written by the deploy, not the upstream api.openai.com default", ).toMatch(/^https?:\/\/.+/i); const endpointHost = new URL(configuredEndpoint).hostname; diff --git a/roles/web-app-moodle/meta/addons/auth_oidc.yml b/roles/web-app-moodle/meta/addons/auth_oidc.yml index 1e33ee4776..5d68112121 100644 --- a/roles/web-app-moodle/meta/addons/auth_oidc.yml +++ b/roles/web-app-moodle/meta/addons/auth_oidc.yml @@ -3,11 +3,11 @@ enabled: "{{ lookup('config', 'web-app-moodle', 'services.sso.enabled') | bool } required: false mechanism: plugin source: upstream -version: "v4.5.8" +version: "v5.2.1" bridges: - sso update: monitored: true catalog: github-releases upstream_id: microsoft/moodle-auth_oidc - match: "v4.5." + match: "v" diff --git a/roles/web-app-moodle/meta/server.yml b/roles/web-app-moodle/meta/server.yml new file mode 100644 index 0000000000..f365eb8102 --- /dev/null +++ b/roles/web-app-moodle/meta/server.yml @@ -0,0 +1,5 @@ +--- +status_codes: + default: + - 200 + - 303 diff --git a/roles/web-app-moodle/meta/services.yml b/roles/web-app-moodle/meta/services.yml index 908536fd0f..9c89c98c08 100644 --- a/roles/web-app-moodle/meta/services.yml +++ b/roles/web-app-moodle/meta/services.yml @@ -45,7 +45,7 @@ moodle: bond: 1 enabled: true # nocheck: dynamic-flag shared: true - app_version: 4.5.11 + app_version: 5.3.0 update: key: app_version type: git_tags diff --git a/roles/web-app-moodle/tasks/01_manager_ops.yml b/roles/web-app-moodle/tasks/01_manager_ops.yml index 078f32c1ca..93cd1d5e97 100644 --- a/roles/web-app-moodle/tasks/01_manager_ops.yml +++ b/roles/web-app-moodle/tasks/01_manager_ops.yml @@ -48,6 +48,13 @@ - "'Database tables already present' not in moodle_install.stderr" no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" +- name: "⬆️ Upgrade the Moodle database to the release in the code volume" + shell: > + container exec --user {{ MOODLE_RUNTIME_USER }} {{ MOODLE_CONTAINER_ADDRESS }} + php {{ MOODLE_CLI_DIR }}/upgrade.php --non-interactive + register: moodle_upgrade + changed_when: "'No upgrade needed' not in moodle_upgrade.stderr" + - name: "🔑 Realign the administrator password Moodle persisted at install" when: not (MOODLE_OIDC_ENABLED | bool) # nocheck: password-quote the token is upstream's CLI filename, not a credential diff --git a/roles/web-app-moodle/tasks/04_oidc.yml b/roles/web-app-moodle/tasks/04_oidc.yml index e150b4d5ed..2166ccec42 100644 --- a/roles/web-app-moodle/tasks/04_oidc.yml +++ b/roles/web-app-moodle/tasks/04_oidc.yml @@ -93,7 +93,7 @@ - { name: "field_map_lastnamephonetic", value: "name_phonetic_family" } - { name: "field_lock_lastnamephonetic", value: "locked" } - { name: "field_updatelocal_lastnamephonetic", value: "oneveryloginupdate" } - - { name: "alternateloginurl", value: "{{ lookup('tls', application_id, 'url.base') }}{{ MOODLE_OIDC_PLUGIN_RELPATH }}/" } + - { name: "alternateloginurl", value: "{{ lookup('tls', application_id, 'url.base') }}{{ MOODLE_OIDC_PLUGIN_URL_PATH }}/" } loop_control: label: "{{ item.name }}" shell: > diff --git a/roles/web-app-moodle/tasks/utils/ai_gateway.yml b/roles/web-app-moodle/tasks/utils/ai_gateway.yml index ef74562a9b..55832a8079 100644 --- a/roles/web-app-moodle/tasks/utils/ai_gateway.yml +++ b/roles/web-app-moodle/tasks/utils/ai_gateway.yml @@ -2,18 +2,31 @@ - name: "🔐 Verify the gateway contract for {{ application_id }}" ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/svc-ai-litellm/tasks/utils/consumer_contract.yml') }}" -- name: "🤖 Point the Moodle AI subsystem at the platform gateway" +- name: "🤖 Point the Moodle AI provider instance at the platform gateway" + ansible.builtin.shell: > + container exec -i --user {{ MOODLE_RUNTIME_USER }} + -e MOODLE_CONFIG={{ MOODLE_CONFIG | quote }} + -e AI_PROVIDER_NAME={{ MOODLE_LITELLM_PROVIDER_NAME | quote }} + -e AI_API_KEY={{ MOODLE_LITELLM_API_KEY | quote }} + -e AI_CHAT_ENDPOINT={{ MOODLE_LITELLM_CHAT_ENDPOINT | quote }} + -e AI_CHAT_MODEL={{ LITELLM_CHAT_MODEL | quote }} + -e AI_IMAGE_ENDPOINT={{ MOODLE_LITELLM_IMAGE_ENDPOINT | quote }} + {{ MOODLE_CONTAINER_ADDRESS }} php + args: + stdin: "{{ lookup('file', 'php/provision/ai_gateway.php') }}" + register: moodle_ai_provider + changed_when: false + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + +- name: "🤖 Enable the Moodle AI placements" ansible.builtin.shell: > container exec --user {{ MOODLE_RUNTIME_USER }} {{ MOODLE_CONTAINER_ADDRESS }} php {{ [MOODLE_CLI_DIR, 'cfg.php'] | path_join }} - --component={{ item.component | quote }} - --name={{ item.name | quote }} - --set={{ item.value | quote }} - loop: "{{ MOODLE_LITELLM_SETTINGS }}" - loop_control: - label: "{{ item.component }}/{{ item.name }}" + --component={{ item | quote }} + --name=enabled + --set=1 + loop: "{{ MOODLE_LITELLM_PLACEMENTS }}" changed_when: false - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - name: "🧹 Purge Moodle caches so the provider picks up its endpoint" ansible.builtin.shell: > @@ -21,20 +34,14 @@ php {{ [MOODLE_CLI_DIR, 'purge_caches.php'] | path_join }} changed_when: false -- name: "🔎 Read back the configured chat endpoint" - ansible.builtin.shell: > - container exec --user {{ MOODLE_RUNTIME_USER }} {{ MOODLE_CONTAINER_ADDRESS }} - php {{ [MOODLE_CLI_DIR, 'cfg.php'] | path_join }} - --component=aiprovider_openai - --name=action_generate_text_endpoint - register: moodle_ai_endpoint - changed_when: false - - name: "🛑 Refuse a Moodle AI provider that does not answer through the gateway" ansible.builtin.assert: that: - - (moodle_ai_endpoint.stdout | trim) == MOODLE_LITELLM_CHAT_ENDPOINT + - moodle_ai_endpoints | length > 0 + - moodle_ai_endpoints | difference([MOODLE_LITELLM_CHAT_ENDPOINT, MOODLE_LITELLM_IMAGE_ENDPOINT]) | length == 0 fail_msg: >- - aiprovider_openai still resolves generate_text to - '{{ moodle_ai_endpoint.stdout | trim }}' instead of + The Moodle AI provider instance resolves actions to + {{ moodle_ai_endpoints | join(', ') }} instead of {{ MOODLE_LITELLM_CHAT_ENDPOINT }}, so prompts would leave the deployment. + vars: + moodle_ai_endpoints: "{{ (moodle_ai_provider.stdout_lines | last | from_json).endpoints.values() | unique | list }}" diff --git a/roles/web-app-moodle/tasks/utils/mcp/provision.yml b/roles/web-app-moodle/tasks/utils/mcp/provision.yml index 20ff563c06..725661b800 100644 --- a/roles/web-app-moodle/tasks/utils/mcp/provision.yml +++ b/roles/web-app-moodle/tasks/utils/mcp/provision.yml @@ -14,7 +14,7 @@ -e MCP_ROLE_CAPABILITIES={{ MOODLE_MCP_ROLE_CAPABILITIES | join(',') | quote }} \ {{ MOODLE_MCP_LOCAL_CID | quote }} php args: - stdin: "{{ lookup('file', 'php/provision_mcp.php') }}" + stdin: "{{ lookup('file', 'php/provision/mcp.php') }}" register: moodle_mcp_provision changed_when: false delegate_to: "{{ MOODLE_MCP_HOST_NODE }}" diff --git a/roles/web-app-moodle/templates/nginx-moodle.conf.j2 b/roles/web-app-moodle/templates/nginx-moodle.conf.j2 index 477b89cd1f..ca7b6a2a03 100644 --- a/roles/web-app-moodle/templates/nginx-moodle.conf.j2 +++ b/roles/web-app-moodle/templates/nginx-moodle.conf.j2 @@ -7,7 +7,7 @@ server { listen [::]:{{ MOODLE_NGINX_PORT }}; server_name _; - root {{ MOODLE_CODE_DIR }}; + root {{ MOODLE_PUBLIC_DIR }}; index index.php; client_max_body_size 256M; @@ -16,16 +16,19 @@ server { set $moodle_upstream {{ MOODLE_CONTAINER }}; location / { - try_files $uri $uri/ /index.php?$query_string; + try_files $uri $uri/ /r.php$is_args$args; } location ~ [^/]\.php(/|$) { - fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_split_path_info ^(.+\.php)(/.*)?$; + set $path_info $fastcgi_path_info; + try_files $fastcgi_script_name $fastcgi_script_name/ /r.php$is_args$args; fastcgi_index index.php; fastcgi_pass $moodle_upstream:{{ MOODLE_FPM_PORT }}; include fastcgi_params; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $realpath_root; + fastcgi_param PATH_INFO $path_info; fastcgi_param HTTP_PROXY ""; fastcgi_read_timeout 300; } diff --git a/roles/web-app-moodle/templates/playwright.env.j2 b/roles/web-app-moodle/templates/playwright.env.j2 index 02eb6ebd3c..6fbea79405 100644 --- a/roles/web-app-moodle/templates/playwright.env.j2 +++ b/roles/web-app-moodle/templates/playwright.env.j2 @@ -21,6 +21,7 @@ PERSONA_ADMINISTRATOR_BLOCKED=true MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }} LITELLM_SERVICE_ENABLED={{ lookup('config', application_id, 'services.litellm.enabled') | string | lower }} +MOODLE_AI_PROVIDER_NAME={{ MOODLE_LITELLM_PROVIDER_NAME | dotenv_quote }} SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }} LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }} MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }} diff --git a/roles/web-app-moodle/vars/main.yml b/roles/web-app-moodle/vars/main.yml index 75cfd20cbc..fffdb5f0db 100644 --- a/roles/web-app-moodle/vars/main.yml +++ b/roles/web-app-moodle/vars/main.yml @@ -19,15 +19,18 @@ MOODLE_BUILD_FILES: opcache_ini: "php-conf/opcache-recommended.ini" runtime_ini: "php-conf/moodle-runtime.ini" fpm_pool: "php-conf/zz-moodle-pool.conf" -MOODLE_VERSION_FILE: "version.php" # bootstrap-detection sentinel -MOODLE_AUTH_SUBDIR: "auth" # top-level auth/ tree -MOODLE_WEBSERVICE_SUBDIR: "webservice" +MOODLE_PUBLIC_SUBDIR: "public" +MOODLE_PUBLIC_DIR: "{{ [MOODLE_CODE_DIR, MOODLE_PUBLIC_SUBDIR] | path_join }}" +MOODLE_VERSION_FILE: "{{ [MOODLE_PUBLIC_SUBDIR, 'version.php'] | path_join }}" +MOODLE_AUTH_SUBDIR: "{{ [MOODLE_PUBLIC_SUBDIR, 'auth'] | path_join }}" +MOODLE_WEBSERVICE_SUBDIR: "{{ [MOODLE_PUBLIC_SUBDIR, 'webservice'] | path_join }}" MOODLE_CLI_RELPATH: "admin/cli" # admin CLI script dir MOODLE_OIDC_PLUGIN_VERSION: "{{ lookup('config', application_id, 'addons.auth_oidc.version') }}" MOODLE_OIDC_PLUGIN_UPSTREAM: "{{ lookup('config', application_id, 'addons.auth_oidc.update.upstream_id') }}" MOODLE_OIDC_PLUGIN_ARCHIVE_URL: "https://github.com/{{ MOODLE_OIDC_PLUGIN_UPSTREAM }}/archive/refs/tags/{{ MOODLE_OIDC_PLUGIN_VERSION }}.zip" -MOODLE_OIDC_PLUGIN_RELPATH: "{{ [MOODLE_AUTH_SUBDIR, 'oidc'] | path_join }}" +MOODLE_OIDC_PLUGIN_URL_PATH: "auth/oidc" +MOODLE_OIDC_PLUGIN_RELPATH: "{{ [MOODLE_PUBLIC_SUBDIR, MOODLE_OIDC_PLUGIN_URL_PATH] | path_join }}" MOODLE_OIDC_PLUGIN_DIR: "{{ [MOODLE_CODE_DIR, MOODLE_OIDC_PLUGIN_RELPATH] | path_join }}" MOODLE_CLI_DIR: "{{ [MOODLE_CODE_DIR, MOODLE_CLI_RELPATH] | path_join }}" MOODLE_CONFIG: "{{ [MOODLE_CODE_DIR, 'config.php'] | path_join }}" @@ -60,17 +63,10 @@ MOODLE_LITELLM_ENABLED: "{{ lookup('config', application_id, 'services.litellm.e MOODLE_LITELLM_API_KEY: "{{ lookup('config', application_id, 'secrets.credentials.litellm_api_key') }}" MOODLE_LITELLM_CHAT_ENDPOINT: "{{ LITELLM_OPENAI_BASE_LOCAL_URL }}/chat/completions" MOODLE_LITELLM_IMAGE_ENDPOINT: "{{ LITELLM_OPENAI_BASE_LOCAL_URL }}/images/generations" -MOODLE_LITELLM_SETTINGS: - - { component: aiprovider_openai, name: enabled, value: "1" } - - { component: aiprovider_openai, name: apikey, value: "{{ MOODLE_LITELLM_API_KEY }}" } - - { component: aiprovider_openai, name: action_generate_text_endpoint, value: "{{ MOODLE_LITELLM_CHAT_ENDPOINT }}" } - - { component: aiprovider_openai, name: action_generate_text_model, value: "{{ LITELLM_CHAT_MODEL }}" } - - { component: aiprovider_openai, name: action_summarise_text_endpoint, value: "{{ MOODLE_LITELLM_CHAT_ENDPOINT }}" } - - { component: aiprovider_openai, name: action_summarise_text_model, value: "{{ LITELLM_CHAT_MODEL }}" } - - { component: aiprovider_openai, name: action_generate_image_endpoint, value: "{{ MOODLE_LITELLM_IMAGE_ENDPOINT }}" } - - { component: aiprovider_openai, name: generate_image, value: "0" } - - { component: aiplacement_courseassist, name: enabled, value: "1" } - - { component: aiplacement_editor, name: enabled, value: "1" } +MOODLE_LITELLM_PROVIDER_NAME: "LiteLLM gateway" +MOODLE_LITELLM_PLACEMENTS: + - aiplacement_courseassist + - aiplacement_editor MOODLE_EGRESS_BLOCKED_HOSTS: - "0.0.0.0" diff --git a/roles/web-app-n8n/files/javascript/hooks.js b/roles/web-app-n8n/files/javascript/hooks.js index 9b70bddff5..80e3f06db6 100644 --- a/roles/web-app-n8n/files/javascript/hooks.js +++ b/roles/web-app-n8n/files/javascript/hooks.js @@ -48,10 +48,12 @@ module.exports = { this.logger?.info(`SSO middleware initializing with header: ${headerName}`); - const Layer = require('router/lib/layer'); + const { createRequire } = require('module'); const { dirname, resolve } = require('path'); const { randomBytes } = require('crypto'); - const { hash } = require('bcryptjs'); + const n8nRequire = createRequire(require.resolve('n8n')); + const Layer = createRequire(n8nRequire.resolve('express'))('router/lib/layer'); + const { hash } = n8nRequire('bcryptjs'); const { issueCookie } = require(resolve(dirname(require.resolve('n8n')), 'auth/jwt')); // Trust the proxy for correct X-Forwarded-* handling and rate limiting @@ -114,18 +116,17 @@ module.exports = { this.logger?.info(`SSO auto-login attempt for email: ${userEmail}`); - // 1) Try to fetch the user (n8n 1.95.3 stores 'role' as a plain string column) let user = await UserRepo.findOne({ where: { email: userEmail }, + relations: ['role'], }); - // 2) If not found — create the user (with 'global:member' role) and a project if (!user) { const hashed = await hash(randomBytes(16).toString('hex'), 10); const userData = { email: userEmail, - role: 'global:member', // string-based role is valid for createUserWithProject + role: { slug: 'global:member' }, password: hashed, }; if (userFirstName) userData.firstName = userFirstName; @@ -136,7 +137,6 @@ module.exports = { this.logger?.info(`Created new user: ${userEmail} (${userFirstName} ${userLastName}) via SSO`); } else { - // 3) Update first/last name if they changed upstream let changed = false; if (userFirstName && user.firstName !== userFirstName) { user.firstName = userFirstName; @@ -154,7 +154,6 @@ module.exports = { } } - // 4) Ensure 'user.role' exists (plain string column, e.g. 'global:member') if (!user.role) { this.logger?.error(`User ${userEmail} has no valid role; cannot issue cookie.`); res.statusCode = 401; @@ -162,10 +161,8 @@ module.exports = { return; } - // 5) Issue n8n auth cookie issueCookie(res, user); - // 6) Attach context for downstream middleware/routes req.user = user; req.userId = user.id; diff --git a/roles/web-app-n8n/meta/services.yml b/roles/web-app-n8n/meta/services.yml index a7ad1c3b40..156c60abc5 100644 --- a/roles/web-app-n8n/meta/services.yml +++ b/roles/web-app-n8n/meta/services.yml @@ -67,7 +67,7 @@ n8n: backup: no_stop_required: false image: docker.n8n.io/n8nio/n8n - version: "1.100.1" + version: "2.42.3" update: type: registry_tags image: docker.n8n.io/n8nio/n8n diff --git a/roles/web-app-opentalk/meta/services.yml b/roles/web-app-opentalk/meta/services.yml index 5509c681aa..f78205a7a3 100644 --- a/roles/web-app-opentalk/meta/services.yml +++ b/roles/web-app-opentalk/meta/services.yml @@ -96,7 +96,7 @@ controller: livekit: name: opentalk-livekit image: livekit/livekit-server - version: v1.13.8 + version: v1.13.9 min_storage: 238MB cpus: "2.0" mem_reservation: 512m diff --git a/roles/web-app-pixelfed/meta/services.yml b/roles/web-app-pixelfed/meta/services.yml index 4bf143436b..72dc71d410 100644 --- a/roles/web-app-pixelfed/meta/services.yml +++ b/roles/web-app-pixelfed/meta/services.yml @@ -49,7 +49,7 @@ pixelfed: shared: true enabled: true # nocheck: dynamic-flag image: zknt/pixelfed - version: "20260917" + version: "20261007" name: pixelfed min_storage: 588MB backup: diff --git a/roles/web-app-prometheus/meta/services.yml b/roles/web-app-prometheus/meta/services.yml index cc973d1273..462bb19bc7 100644 --- a/roles/web-app-prometheus/meta/services.yml +++ b/roles/web-app-prometheus/meta/services.yml @@ -94,7 +94,7 @@ blackbox-exporter: flavor: wget path: -/healthy image: prom/blackbox-exporter - version: v0.28.0 + version: v0.29.0 name: prometheus-blackbox cpus: "0.1" mem_reservation: 16m @@ -106,7 +106,7 @@ blackbox-exporter: http: 9115 cadvisor: image: gcr.io/cadvisor/cadvisor - version: v0.52.1 + version: v0.55.1 update: type: registry_tags image: gcr.io/cadvisor/cadvisor diff --git a/roles/web-app-semaphore/meta/services.yml b/roles/web-app-semaphore/meta/services.yml index f12e6790d8..523e6513bc 100644 --- a/roles/web-app-semaphore/meta/services.yml +++ b/roles/web-app-semaphore/meta/services.yml @@ -55,7 +55,7 @@ semaphore: pids_limit: 1024 min_storage: 530MB image: semaphoreui/semaphore - version: "v2.19.14" + version: "v2.19.16" backup: no_stop_required: true name: semaphore diff --git a/roles/web-app-socialhome/meta/services.yml b/roles/web-app-socialhome/meta/services.yml index b2252ea28f..77098a50d5 100644 --- a/roles/web-app-socialhome/meta/services.yml +++ b/roles/web-app-socialhome/meta/services.yml @@ -13,7 +13,7 @@ socialhome: # nocheck: default-placement-manager embedded sqlite in a node-local volume; no clustered storage placement: manager image: ghcr.io/social-home-io/socialhome - version: "2026.10.6" + version: "2026.10.7" ports: local: http: 8089 diff --git a/roles/web-app-suitecrm/files/shell/docker-entrypoint.sh b/roles/web-app-suitecrm/files/shell/docker-entrypoint.sh index 32fc38fdd1..2c2d5c954a 100644 --- a/roles/web-app-suitecrm/files/shell/docker-entrypoint.sh +++ b/roles/web-app-suitecrm/files/shell/docker-entrypoint.sh @@ -129,6 +129,8 @@ if [ "$_have_lock" = "1" ]; then # cannot write into root-owned cache dirs -> permanent 500 without this. chown -R "$WEB_USER:$WEB_GROUP" "${APP_DIR}/cache" "${APP_DIR}/public/legacy/cache" 2>/dev/null || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error + chown -R "$WEB_USER:$WEB_GROUP" "${APP_DIR}/logs" + rmdir "$BOOT_LOCK" 2>/dev/null || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error trap - EXIT TERM INT else diff --git a/roles/web-app-suitecrm/tasks/01_manager_ops.yml b/roles/web-app-suitecrm/tasks/01_manager_ops.yml index 39f5fdcad8..e1801b6936 100644 --- a/roles/web-app-suitecrm/tasks/01_manager_ops.yml +++ b/roles/web-app-suitecrm/tasks/01_manager_ops.yml @@ -24,3 +24,20 @@ - name: "🐳 Docker Compose Up for '{{ application_id }}'" meta: flush_handlers + +- name: "⏳ Wait until the SuiteCRM container is healthy" + shell: > + container inspect --type container --format '{% raw %}{{.State.Health.Status}}{% endraw %}' {{ SUITECRM_CONTAINER_ADDRESS }} + register: suitecrm_health + changed_when: false + until: suitecrm_health.stdout.strip() == "healthy" + retries: 300 + delay: 5 + +- name: "🔎 List SuiteCRM log paths the web user cannot write" + shell: > + container exec --user {{ SUITECRM_WEB_USER }} {{ SUITECRM_CONTAINER_ADDRESS }} + find {{ SUITECRM_APP_DIR }}/logs ! -writable + register: suitecrm_unwritable_logs + changed_when: false + failed_when: suitecrm_unwritable_logs.rc != 0 or suitecrm_unwritable_logs.stdout | length > 0 diff --git a/roles/web-app-suitecrm/vars/main.yml b/roles/web-app-suitecrm/vars/main.yml index fca35e9cd1..2b1708dfdd 100644 --- a/roles/web-app-suitecrm/vars/main.yml +++ b/roles/web-app-suitecrm/vars/main.yml @@ -13,6 +13,7 @@ SUITECRM_BASE_VERSION: "{{ lookup('config', application_id, 'services.' ~ entity SUITECRM_APP_VERSION: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.app_version') }}" SUITECRM_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.name') }}" +SUITECRM_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, entity_name) }}" SUITECRM_SERVICE: "{{ entity_name }}" SUITECRM_URL: "{{ lookup('tls', application_id, 'url.base') }}" diff --git a/roles/web-app-wordpress/meta/addons/mcp-adapter.yml b/roles/web-app-wordpress/meta/addons/mcp-adapter.yml index 60fd5c6322..a56aa4270f 100644 --- a/roles/web-app-wordpress/meta/addons/mcp-adapter.yml +++ b/roles/web-app-wordpress/meta/addons/mcp-adapter.yml @@ -3,7 +3,7 @@ enabled: "{{ lookup('config', 'web-app-wordpress', 'mcp.enabled') | bool }}" required: true mechanism: plugin source: upstream -version: v0.5.0 +version: v0.7.0 update: monitored: true catalog: github-releases diff --git a/tests/integration/roles/svc-bkp-remote-2-local/test_ssh_wrapper_rsync_e2e.py b/tests/integration/roles/svc-bkp-remote-2-local/test_ssh_wrapper_rsync_e2e.py index f7269a5a3c..e97744353b 100644 --- a/tests/integration/roles/svc-bkp-remote-2-local/test_ssh_wrapper_rsync_e2e.py +++ b/tests/integration/roles/svc-bkp-remote-2-local/test_ssh_wrapper_rsync_e2e.py @@ -22,7 +22,7 @@ import tempfile import unittest from pathlib import Path -from jinja2 import Template +from jinja2 import Template, select_autoescape from utils.cache.files import read_text @@ -62,7 +62,10 @@ class TestSshWrapperRsyncE2E(unittest.TestCase): (self.version_dir / "data" / "file2.txt").write_text("second") self.wrapper = self.work / "wrapper.sh" - rendered = Template(read_text(str(WRAPPER_TEMPLATE))).render( + rendered = Template( + read_text(str(WRAPPER_TEMPLATE)), + autoescape=select_autoescape(default_for_string=False), + ).render( DIR_BACKUPS=str(self.backups), BACKUP_REPOSITORIES=[BACKUP_TYPE], ) diff --git a/tests/integration/roles/svc-net-tor/test_container_dns_wiring.py b/tests/integration/roles/svc-net-tor/test_container_dns_wiring.py index 4ed72e9dd0..e69326de26 100644 --- a/tests/integration/roles/svc-net-tor/test_container_dns_wiring.py +++ b/tests/integration/roles/svc-net-tor/test_container_dns_wiring.py @@ -1,7 +1,7 @@ import unittest from ansible.module_utils.parsing.convert_bool import boolean as ansible_boolean -from jinja2 import Environment, FileSystemLoader +from jinja2 import Environment, FileSystemLoader, select_autoescape from tests.integration.roles import PROJECT_ROOT from utils.cache.files import read_text @@ -37,7 +37,7 @@ def _render_dnsmasq(**overrides): variables.update(overrides) env = Environment( loader=FileSystemLoader(str(PROJECT_ROOT)), - autoescape=False, # noqa: S701 - dnsmasq config, html escaping would corrupt it + autoescape=select_autoescape(), ) template = env.get_template("roles/svc-net-tor/templates/dnsmasq-tor-onion.conf.j2") return template.render(**variables) @@ -146,7 +146,7 @@ class TestStaticBindSurvivesBoot(unittest.TestCase): self.assertLess(names.index(DROPIN_BLOCK), names.index(START_TASK)) reload_expression = start["ansible.builtin.systemd"]["daemon_reload"] - env = Environment(autoescape=False) # noqa: S701 - renders a boolean, not markup + env = Environment(autoescape=select_autoescape(default_for_string=False)) env.filters["bool"] = ansible_boolean env.tests["changed"] = lambda result: bool(result.get("changed", False)) @@ -192,7 +192,7 @@ class TestHostResolver(unittest.TestCase): task = next(t for t in tasks if t.get("name") == RESOLV_TASK) env = Environment( loader=FileSystemLoader(str(PROJECT_ROOT)), - autoescape=False, # noqa: S701 - resolv.conf, html escaping would corrupt it + autoescape=select_autoescape(), ) template = env.get_template( f"roles/svc-net-tor/templates/{task['ansible.builtin.template']['src']}" diff --git a/tests/lint/ansible/jinja/test_shell_templates_are_valid.py b/tests/lint/ansible/jinja/test_shell_templates_are_valid.py index 921e159c9e..be0ac4d734 100644 --- a/tests/lint/ansible/jinja/test_shell_templates_are_valid.py +++ b/tests/lint/ansible/jinja/test_shell_templates_are_valid.py @@ -100,7 +100,7 @@ class TestShellTemplatesAreValid(unittest.TestCase): environment = jinja2.Environment( undefined=_Placeholder, keep_trailing_newline=True, - autoescape=False, # noqa: S701 shell, not markup + autoescape=jinja2.select_autoescape(default_for_string=False), ) environment.filters = _PassthroughFilters(environment.filters) environment.filters["quote"] = shlex.quote diff --git a/tests/lint/ansible/roles/meta/test_role_subnet_pool.py b/tests/lint/ansible/roles/meta/test_role_subnet_pool.py index b18f584947..d45f12251f 100644 --- a/tests/lint/ansible/roles/meta/test_role_subnet_pool.py +++ b/tests/lint/ansible/roles/meta/test_role_subnet_pool.py @@ -37,7 +37,9 @@ def _declared_space() -> tuple[list[ipaddress.IPv4Network], list[str]]: data = load_yaml_any(str(NETWORKS_FILE), default_if_missing={}) or {} pool = data.get("NETWORK_ROLE_SUBNET_POOL") if isinstance(pool, str) and "{{" in pool: - pool = jinja2.Template(pool).render(**data) + pool = jinja2.Template( + pool, autoescape=jinja2.select_autoescape(default_for_string=False) + ).render(**data) roles = data.get("NETWORK_ROLE_SUBNET_EXCEPTION_ROLES") or [] if not isinstance(pool, str) or not isinstance(roles, list): raise TypeError( diff --git a/tests/lint/ansible/roles/meta/test_sso_access_token_optin.py b/tests/lint/ansible/roles/meta/test_sso_access_token_optin.py index 4ad33f9029..f2778df020 100644 --- a/tests/lint/ansible/roles/meta/test_sso_access_token_optin.py +++ b/tests/lint/ansible/roles/meta/test_sso_access_token_optin.py @@ -45,7 +45,6 @@ GATE = PROJECT_ROOT / ( ) _HEADER_RE = re.compile(r"x[-_]forwarded[-_]access[-_]token", re.IGNORECASE) _EMITS_RE = re.compile(r"proxy_set_header|auth_request_set") -_SKIP_DIRS = {".git", "node_modules", "__pycache__"} _TEXT_SUFFIXES = { ".js", ".mjs", diff --git a/tests/lint/ansible/roles/web-app/integration/test_email.py b/tests/lint/ansible/roles/web-app/integration/test_email.py index c5d3e7e2c1..7197c8451b 100644 --- a/tests/lint/ansible/roles/web-app/integration/test_email.py +++ b/tests/lint/ansible/roles/web-app/integration/test_email.py @@ -168,12 +168,6 @@ class TestWebAppRolesIntegrateEmail(unittest.TestCase): missing.append("enabled: true") if not is_explicit_truth(svc.get("shared")): missing.append("shared: true") - if missing: - rel = ( - config.relative_to(root).as_posix() - if config.is_file() - else role_path.relative_to(root).as_posix() - ) if not _role_pins_the_mail_relay(role_path): errors.append( f"[{role_path.name}]: calls lookup('email', ...) but no " diff --git a/tests/lint/repository/test_workflow_references.py b/tests/lint/repository/test_workflow_references.py index 10340731fb..0c4fc682fa 100644 --- a/tests/lint/repository/test_workflow_references.py +++ b/tests/lint/repository/test_workflow_references.py @@ -14,6 +14,9 @@ Three patterns: ``workflow_dispatch``. Both forms count: ``gh workflow run .yml`` and a REST POST to ``actions/workflows/.yml/dispatches``. +A ``github.com///...`` URL into another repository names that +repository's workflow, which this tree cannot hold, so it is never matched. + Why === Renaming a workflow silently breaks every one of those. A `uses:` line fails @@ -46,6 +49,7 @@ _WORKFLOW_DIR = PROJECT_ROOT / ".github" / "workflows" _SUFFIXES = {".md", ".yml", ".yaml", ".py", ".sh", ".j2", ".rst", ".txt"} _REFERENCE_RE = re.compile(r"(?:\.github|actions)/workflows/([A-Za-z0-9._-]+\.ya?ml)") _BARE_RE = re.compile(r"(?[A-Za-z0-9._-]+\.ya?ml)" r"|actions/workflows/(?P[A-Za-z0-9._-]+\.ya?ml)/dispatches" @@ -90,7 +94,8 @@ def missing_references() -> list[str]: for rel, lines in _candidates(): patterns = _patterns(PROJECT_ROOT / rel) for number, line in enumerate(lines, 1): - names = {name for pattern in patterns for name in pattern.findall(line)} + own = _FOREIGN_URL_RE.sub("", line) + names = {name for pattern in patterns for name in pattern.findall(own)} offenders.extend( f"{rel}:{number}: {name}" for name in sorted(names) if name not in known ) diff --git a/tests/unit/php/roles/web-app-moodle/files/provision/AiGatewayTest.php b/tests/unit/php/roles/web-app-moodle/files/provision/AiGatewayTest.php new file mode 100644 index 0000000000..17fc6b2fa6 --- /dev/null +++ b/tests/unit/php/roles/web-app-moodle/files/provision/AiGatewayTest.php @@ -0,0 +1,346 @@ + $value) { + $copy->$property = $value; + } + return $copy; + } + } + + class manager { + private function rows(): array { + $file = getenv('STUB_STATE'); + return is_file($file) ? json_decode(file_get_contents($file), true) : []; + } + + private function store(provider $provider): provider { + $rows = $this->rows(); + $rows[$provider->id] = [ + 'id' => $provider->id, + 'provider' => get_class($provider), + 'name' => $provider->name, + 'enabled' => $provider->enabled, + 'config' => $provider->config, + 'actionconfig' => $provider->actionconfig, + ]; + file_put_contents(getenv('STUB_STATE'), json_encode($rows)); + return $provider; + } + + public function get_provider_instances(?array $filter = null): array { + $found = []; + foreach ($this->rows() as $row) { + foreach ($filter ?? [] as $column => $value) { + if ($row[$column] != $value) { + continue 2; + } + } + $found[$row['id']] = new $row['provider']( + enabled: $row['enabled'], + name: $row['name'], + config: $row['config'], + actionconfig: $row['actionconfig'], + id: $row['id'], + ); + } + return $found; + } + + public function create_provider_instance( + string $classname, + string $name, + bool $enabled = false, + ?array $config = null, + ?array $actionconfig = null, + ): provider { + return $this->store(new $classname( + enabled: $enabled, + name: $name, + config: $config ?? [], + actionconfig: $actionconfig ?? $classname::initialise_action_settings(), + id: count($this->rows()) + 1, + )); + } + + public function update_provider_instance( + provider $provider, + ?array $config = null, + ?array $actionconfig = null, + ): provider { + return $this->store($provider->with( + config: $config ?? $provider->config, + actionconfig: $actionconfig ?? $provider->actionconfig, + )); + } + + public function enable_provider_instance(provider $provider): provider { + return $provider->enabled ? $provider : $this->store($provider->with(enabled: true)); + } + } + } + + namespace aiprovider_openai { + class provider extends \core_ai\provider { + public static function initialise_action_settings(): array { + $text = [ + 'enabled' => true, + 'settings' => [ + 'endpoint' => 'https://api.openai.com/v1/chat/completions', + 'model' => 'gpt-4o', + 'systeminstruction' => 'upstream default', + ], + ]; + return [ + \core_ai\aiactions\generate_text::class => $text, + \core_ai\aiactions\generate_image::class => [ + 'enabled' => true, + 'settings' => [ + 'endpoint' => 'https://api.openai.com/v1/images/generations', + 'model' => 'dall-e-3', + ], + ], + \core_ai\aiactions\summarise_text::class => $text, + \core_ai\aiactions\explain_text::class => $text, + ]; + } + } + } + + namespace core { + class di { + public static function get(string $class): object { + return new $class(); + } + } + } + PHP; + + private string $config; + + private string $state; + + protected function setUp(): void + { + $this->config = (string) tempnam(sys_get_temp_dir(), 'mdlcfg'); + $this->state = (string) tempnam(sys_get_temp_dir(), 'mdlai'); + file_put_contents($this->config, self::MOODLE); + unlink($this->state); + } + + protected function tearDown(): void + { + unlink($this->config); + if (is_file($this->state)) { + unlink($this->state); + } + } + + /** + * Store provider instances the way an earlier run or an administrator left them. + * + * @param list> $rows instances, each with its id + */ + private function seed(array $rows): void + { + file_put_contents($this->state, json_encode(array_column($rows, null, 'id'))); + } + + /** + * Run the script against the stubbed Moodle. + * + * @return array{0: array, 1: int, 2: array>} + * decoded last output line, exit code and the stored instances by id + */ + private function runScript(): array + { + $script = dirname(__DIR__, 7) . '/roles/web-app-moodle/files/php/provision/ai_gateway.php'; + $process = proc_open( + [PHP_BINARY, $script], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + null, + [ + 'MOODLE_CONFIG' => $this->config, + 'STUB_STATE' => $this->state, + 'AI_PROVIDER_NAME' => self::NAME, + 'AI_API_KEY' => 'sk-gateway', + 'AI_CHAT_ENDPOINT' => self::CHAT, + 'AI_CHAT_MODEL' => self::MODEL, + 'AI_IMAGE_ENDPOINT' => self::IMAGE, + 'PATH' => (string) getenv('PATH'), + ] + ); + $stdout = trim((string) stream_get_contents($pipes[1])); + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($process); + + $lines = explode("\n", $stdout); + $stored = is_file($this->state) + ? json_decode((string) file_get_contents($this->state), true) + : []; + + return [(array) json_decode((string) end($lines), true), $code, $stored]; + } + + public function testAFreshMoodleGetsOneEnabledInstanceWithTheGatewayKey(): void + { + [, $code, $stored] = $this->runScript(); + + $this->assertSame(0, $code); + $this->assertCount(1, $stored); + $instance = reset($stored); + $this->assertSame(self::OPENAI, $instance['provider']); + $this->assertSame(self::NAME, $instance['name']); + $this->assertTrue($instance['enabled']); + $this->assertSame('sk-gateway', $instance['config']['apikey']); + } + + public function testEveryTextActionIsEnabledAndAnswersThroughTheGateway(): void + { + [, , $stored] = $this->runScript(); + + $actions = reset($stored)['actionconfig']; + foreach (self::TEXT_ACTIONS as $action) { + $this->assertTrue($actions[$action]['enabled'], $action); + $this->assertSame(self::CHAT, $actions[$action]['settings']['endpoint'], $action); + $this->assertSame(self::MODEL, $actions[$action]['settings']['model'], $action); + $this->assertSame( + 'upstream default', + $actions[$action]['settings']['systeminstruction'], + $action + ); + } + } + + public function testImageGenerationIsSwitchedOffAndStillPointsAtTheGateway(): void + { + [, , $stored] = $this->runScript(); + + $image = reset($stored)['actionconfig'][self::IMAGE_ACTION]; + $this->assertFalse($image['enabled']); + $this->assertSame(self::IMAGE, $image['settings']['endpoint']); + } + + public function testTheOutputNamesTheInstanceAndTheStoredEndpointOfEveryAction(): void + { + [$output, , $stored] = $this->runScript(); + + $this->assertSame(array_key_first($stored), $output['id']); + $this->assertSame( + [ + self::TEXT_ACTIONS[0] => self::CHAT, + self::IMAGE_ACTION => self::IMAGE, + self::TEXT_ACTIONS[1] => self::CHAT, + self::TEXT_ACTIONS[2] => self::CHAT, + ], + $output['endpoints'] + ); + } + + public function testAnInstanceOfThatNameIsReusedAndKeepsWhatTheScriptDoesNotSet(): void + { + $this->seed([[ + 'id' => 7, + 'provider' => self::OPENAI, + 'name' => self::NAME, + 'enabled' => false, + 'config' => ['apikey' => 'sk-stale', 'orgid' => 'org-1'], + 'actionconfig' => [ + self::TEXT_ACTIONS[0] => [ + 'enabled' => false, + 'settings' => [ + 'endpoint' => 'https://api.openai.com/v1/chat/completions', + 'model' => 'gpt-4o', + 'systeminstruction' => 'answer briefly', + ], + ], + ], + ]]); + + [$output, , $stored] = $this->runScript(); + + $this->assertSame([7], array_keys($stored)); + $this->assertSame(7, $output['id']); + $this->assertTrue($stored[7]['enabled']); + $this->assertSame(['apikey' => 'sk-gateway', 'orgid' => 'org-1'], $stored[7]['config']); + $settings = $stored[7]['actionconfig'][self::TEXT_ACTIONS[0]]['settings']; + $this->assertSame(self::CHAT, $settings['endpoint']); + $this->assertSame('answer briefly', $settings['systeminstruction']); + } + + public function testAnInstanceAnAdministratorAddedIsLeftAlone(): void + { + $own = [ + 'id' => 1, + 'provider' => self::OPENAI, + 'name' => 'Our own OpenAI account', + 'enabled' => true, + 'config' => ['apikey' => 'sk-own'], + 'actionconfig' => [], + ]; + $this->seed([$own]); + + [$output, , $stored] = $this->runScript(); + + $this->assertSame([1, 2], array_keys($stored)); + $this->assertSame($own, $stored[1]); + $this->assertSame(2, $output['id']); + $this->assertSame(self::NAME, $stored[2]['name']); + } + + public function testASecondRunChangesNothing(): void + { + [, , $first] = $this->runScript(); + [, $code, $second] = $this->runScript(); + + $this->assertSame(0, $code); + $this->assertSame($first, $second); + } +} diff --git a/tests/unit/python/plugins/filter/test_dotenv_quote.py b/tests/unit/python/plugins/filter/test_dotenv_quote.py index 9d61bddbdd..f05d3e37da 100644 --- a/tests/unit/python/plugins/filter/test_dotenv_quote.py +++ b/tests/unit/python/plugins/filter/test_dotenv_quote.py @@ -68,7 +68,9 @@ class TestDotenvQuoteModeAware(unittest.TestCase): """ def setUp(self): - env = jinja2.Environment(autoescape=False) # noqa: S701 - test renders a .env value, not HTML + env = jinja2.Environment( + autoescape=jinja2.select_autoescape(default_for_string=False) + ) env.filters["dotenv_quote"] = FilterModule().filters()["dotenv_quote"] self.env = env diff --git a/tests/unit/python/plugins/lookup/test_alert_rule_roles.py b/tests/unit/python/plugins/lookup/test_alert_rule_roles.py index 09953d117e..92cf702dbb 100644 --- a/tests/unit/python/plugins/lookup/test_alert_rule_roles.py +++ b/tests/unit/python/plugins/lookup/test_alert_rule_roles.py @@ -17,7 +17,6 @@ import unittest.mock as mock from pathlib import Path import plugins.lookup.alert_rule_roles as module -from plugins.lookup.alert_rule_roles import LookupModule class _Templar: @@ -29,7 +28,7 @@ class _Templar: def _make_lookup(vars_=None): - lm = LookupModule() + lm = module.LookupModule() lm._templar = _Templar(vars_ or {}) lm._loader = mock.MagicMock() return lm diff --git a/tests/unit/python/plugins/lookup/test_compose_volumes_realign.py b/tests/unit/python/plugins/lookup/test_compose_volumes_realign.py index 4cb4765aca..28a633ccec 100644 --- a/tests/unit/python/plugins/lookup/test_compose_volumes_realign.py +++ b/tests/unit/python/plugins/lookup/test_compose_volumes_realign.py @@ -40,7 +40,9 @@ def _render(**answers) -> str: def _lookup(_plugin, _app, key): return values[key] - env = jinja2.Environment(autoescape=False) # noqa: S701 - renders SQL, HTML escaping would corrupt the literals + env = jinja2.Environment( + autoescape=jinja2.select_autoescape(default_for_string=False) + ) return env.from_string(read_text(str(_TEMPLATE))).render( lookup=_lookup, application_id="web-app-x" ) diff --git a/tests/unit/python/plugins/lookup/test_mcp_client_bearer.py b/tests/unit/python/plugins/lookup/test_mcp_client_bearer.py index cfbc4d0e3a..72afc969bf 100644 --- a/tests/unit/python/plugins/lookup/test_mcp_client_bearer.py +++ b/tests/unit/python/plugins/lookup/test_mcp_client_bearer.py @@ -10,8 +10,7 @@ from __future__ import annotations import importlib.util import unittest -from unittest import mock -from unittest.mock import patch +from unittest.mock import MagicMock, patch from ansible.errors import AnsibleError @@ -40,7 +39,7 @@ class TestMcpClientBearerLookup(unittest.TestCase): def _run(self, terms, credential, role_credentials=None, users=None): lookup = self.mod.LookupModule() lookup._templar = _DummyTemplar() - lookup._loader = mock.MagicMock() + lookup._loader = MagicMock() class _StubConfig: def run(self, terms_, variables=None, **kwargs): diff --git a/tests/unit/python/plugins/lookup/test_mcp_credential.py b/tests/unit/python/plugins/lookup/test_mcp_credential.py index fc67f6fb62..4c1b83d4b3 100644 --- a/tests/unit/python/plugins/lookup/test_mcp_credential.py +++ b/tests/unit/python/plugins/lookup/test_mcp_credential.py @@ -9,8 +9,7 @@ from __future__ import annotations import importlib.util import unittest -from unittest import mock -from unittest.mock import patch +from unittest.mock import MagicMock, patch from ansible.errors import AnsibleError @@ -39,7 +38,7 @@ class TestMcpCredentialLookup(unittest.TestCase): def _run(self, terms, owner="mcp-web-app-gitea", users=None): lookup = self.mod.LookupModule() lookup._templar = _DummyTemplar() - lookup._loader = mock.MagicMock() + lookup._loader = MagicMock() class _StubConfig: def run(self, terms_, variables=None, **kwargs): diff --git a/tests/unit/python/plugins/lookup/test_redirect_uris.py b/tests/unit/python/plugins/lookup/test_redirect_uris.py index 9a4772ea9a..ec7d98dc24 100644 --- a/tests/unit/python/plugins/lookup/test_redirect_uris.py +++ b/tests/unit/python/plugins/lookup/test_redirect_uris.py @@ -1,7 +1,7 @@ from __future__ import annotations import unittest -from unittest import mock +import unittest.mock as mock from ansible.errors import AnsibleError diff --git a/tests/unit/python/roles/agents/test_mcp_tool_filter.py b/tests/unit/python/roles/agents/test_mcp_tool_filter.py index d4d8fa0149..4a09f37952 100644 --- a/tests/unit/python/roles/agents/test_mcp_tool_filter.py +++ b/tests/unit/python/roles/agents/test_mcp_tool_filter.py @@ -20,7 +20,7 @@ import json import unittest import yaml -from jinja2 import Environment, FileSystemLoader +from jinja2 import Environment, FileSystemLoader, select_autoescape from . import PROJECT_ROOT @@ -82,7 +82,9 @@ def _yaml(text: str): def _render(directory, name, **context) -> str: - env = Environment(loader=FileSystemLoader(str(directory)), autoescape=False) # noqa: S701 - renders configuration, not markup + env = Environment( + loader=FileSystemLoader(str(directory)), autoescape=select_autoescape() + ) env.filters.update(_filters()) return env.get_template(name).render(**context) diff --git a/tests/unit/python/roles/agents/test_openclaw_model_provider.py b/tests/unit/python/roles/agents/test_openclaw_model_provider.py index d7939d8710..4ce39d8ee8 100644 --- a/tests/unit/python/roles/agents/test_openclaw_model_provider.py +++ b/tests/unit/python/roles/agents/test_openclaw_model_provider.py @@ -16,7 +16,7 @@ import importlib.util import json import unittest -from jinja2 import Environment, FileSystemLoader +from jinja2 import Environment, FileSystemLoader, select_autoescape from . import PROJECT_ROOT @@ -51,7 +51,9 @@ def _render(**overrides) -> dict: Args: overrides: context values replacing the defaults. """ - env = Environment(loader=FileSystemLoader(str(OPENCLAW)), autoescape=False) # noqa: S701 - renders configuration, not markup + env = Environment( + loader=FileSystemLoader(str(OPENCLAW)), autoescape=select_autoescape() + ) env.filters.update(_filters()) return json.loads( env.get_template("openclaw.json.j2").render(**{**CONTEXT, **overrides}) diff --git a/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py b/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py index e1728d0e72..3f21f05fe5 100644 --- a/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py +++ b/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py @@ -12,7 +12,7 @@ from __future__ import annotations import unittest import yaml -from jinja2 import Environment, StrictUndefined +from jinja2 import Environment, StrictUndefined, select_autoescape from utils.cache.files import read_text @@ -72,7 +72,10 @@ def render(*, ollama=(), lmstudio=(), keys=None): lmstudio: preload entries svc-ai-lmstudio declares; empty means not deployed. keys: env-name -> provider key for the remote backends. """ - env = Environment(undefined=StrictUndefined, autoescape=False) # noqa: S701 - YAML, not markup + env = Environment( + undefined=StrictUndefined, + autoescape=select_autoescape(default_for_string=False), + ) env.filters["bool"] = _ansible_bool rendered = env.from_string(read_text(str(TEMPLATE))).render( LITELLM_OLLAMA_BACKEND=str(bool(ollama)), diff --git a/tests/unit/python/roles/svc-ai-mcp-adapter/files/test_policy.py b/tests/unit/python/roles/svc-ai-mcp-adapter/files/test_policy.py index 376ac03ab3..c04c092e7a 100644 --- a/tests/unit/python/roles/svc-ai-mcp-adapter/files/test_policy.py +++ b/tests/unit/python/roles/svc-ai-mcp-adapter/files/test_policy.py @@ -4,6 +4,7 @@ from __future__ import annotations import importlib.util import json +import time import unittest from . import PROJECT_ROOT @@ -234,6 +235,26 @@ class TestDeclaredArguments(unittest.TestCase): self.assertEqual("GET", method) +class TestPathArguments(unittest.TestCase): + def call(self, **arguments): + return policy.authorize_call(contract(), "checkmk_get_host_status", arguments) + + def test_a_value_made_only_of_dots_is_refused(self): + for dots in range(4): + with self.subTest(dots=dots): + with self.assertRaises(PermissionError) as caught: + self.call(host="." * dots) + self.assertIn(policy.DENY_PATH_ARGUMENT, str(caught.exception)) + + def test_dots_inside_a_segment_pass(self): + for value in ("web-01.example", "..web", "web.."): + with self.subTest(value=value): + self.call(host=value) + + def test_a_query_argument_may_be_made_only_of_dots(self): + self.call(host="web-01", columns="." * 2) + + RANGE_TOOLS = { "prometheus_query_range": { "method": "GET", @@ -326,6 +347,22 @@ class TestDurationGrammar(unittest.TestCase): with self.assertRaises(ValueError): policy.parse_duration("1h30") + def test_anything_but_number_unit_pairs_is_refused(self): + for text in ("", "h", "1hh", "1.5h", "-1h", "1 h", "1sm", "abc"): + with self.subTest(text=text), self.assertRaises(ValueError): + policy.parse_duration(text) + + def test_a_long_digit_run_is_refused_in_linear_time(self): + started = time.monotonic() + with self.assertRaises(ValueError): + policy.parse_duration("0" * 20_000 + "!") + self.assertLess( + time.monotonic() - started, + 1.0, + "parsing must not rescan the digit run from every start position; " + "a backtracking pattern needs several seconds for this input", + ) + class TestAudit(unittest.TestCase): def test_the_event_names_both_sides_and_the_outcome(self): diff --git a/tests/unit/python/roles/svc-net-firewall/test_fragments.py b/tests/unit/python/roles/svc-net-firewall/test_fragments.py index 99152196b5..ecadb860ef 100644 --- a/tests/unit/python/roles/svc-net-firewall/test_fragments.py +++ b/tests/unit/python/roles/svc-net-firewall/test_fragments.py @@ -129,7 +129,7 @@ def _render(path: str, mode: str = "swarm") -> str: environment = jinja2.Environment( keep_trailing_newline=True, trim_blocks=True, - autoescape=False, # noqa: S701 nftables, not markup + autoescape=jinja2.select_autoescape(default_for_string=False), ) return environment.from_string(read_text(path)).render( DEPLOYMENT_MODE=mode, **COMMON diff --git a/tests/unit/python/roles/svc-net-tor-smtp/files/test_tor_smtp_gateway.py b/tests/unit/python/roles/svc-net-tor-smtp/files/test_tor_smtp_gateway.py index 67634ed302..a64c68528e 100644 --- a/tests/unit/python/roles/svc-net-tor-smtp/files/test_tor_smtp_gateway.py +++ b/tests/unit/python/roles/svc-net-tor-smtp/files/test_tor_smtp_gateway.py @@ -14,7 +14,7 @@ import os import sys import types import unittest -from unittest import mock +import unittest.mock as mock from . import PROJECT_ROOT diff --git a/tests/unit/python/roles/svc-net-tor/test_egress_clients.py b/tests/unit/python/roles/svc-net-tor/test_egress_clients.py index 52a558a9a9..7b04c193dd 100644 --- a/tests/unit/python/roles/svc-net-tor/test_egress_clients.py +++ b/tests/unit/python/roles/svc-net-tor/test_egress_clients.py @@ -16,7 +16,7 @@ from __future__ import annotations import importlib.util import sys import unittest -from unittest import mock +import unittest.mock as mock import jinja2 @@ -46,7 +46,9 @@ class _Templar: def template(self, value): return ( - jinja2.Environment(autoescape=False) # noqa: S701 - renders CIDRs, not markup + jinja2.Environment( + autoescape=jinja2.select_autoescape(default_for_string=False) + ) .from_string(str(value)) .render(**self.available_variables) ) diff --git a/tests/unit/python/roles/svc-net-tor/test_extra_ports.py b/tests/unit/python/roles/svc-net-tor/test_extra_ports.py index 4deb747de1..98528445a8 100644 --- a/tests/unit/python/roles/svc-net-tor/test_extra_ports.py +++ b/tests/unit/python/roles/svc-net-tor/test_extra_ports.py @@ -16,8 +16,8 @@ from __future__ import annotations import importlib.util import sys import unittest +import unittest.mock as mock from typing import Any -from unittest import mock from ansible.errors import AnsibleError diff --git a/tests/unit/python/roles/sys-svc-mail-msmtp/test_msmtprc_ca_trust.py b/tests/unit/python/roles/sys-svc-mail-msmtp/test_msmtprc_ca_trust.py index 3e7e435f11..43acfc794f 100644 --- a/tests/unit/python/roles/sys-svc-mail-msmtp/test_msmtprc_ca_trust.py +++ b/tests/unit/python/roles/sys-svc-mail-msmtp/test_msmtprc_ca_trust.py @@ -18,7 +18,7 @@ import re import unittest from typing import Any, ClassVar -from jinja2 import Environment, StrictUndefined +from jinja2 import Environment, StrictUndefined, select_autoescape from utils.cache.files import read_text @@ -46,7 +46,7 @@ def _env() -> Environment: trim_blocks=True, lstrip_blocks=True, undefined=StrictUndefined, - autoescape=False, # noqa: S701 - a config file, and Ansible's templar does not escape either + autoescape=select_autoescape(default_for_string=False), ) env.filters["bool"] = bool env.filters["on_off"] = lambda value: "on" if value else "off" diff --git a/tests/unit/python/roles/sys-svc-proxy/templates/test_mcp_edge_limits.py b/tests/unit/python/roles/sys-svc-proxy/templates/test_mcp_edge_limits.py index 8c6880baec..ee2610af0a 100644 --- a/tests/unit/python/roles/sys-svc-proxy/templates/test_mcp_edge_limits.py +++ b/tests/unit/python/roles/sys-svc-proxy/templates/test_mcp_edge_limits.py @@ -12,7 +12,7 @@ from __future__ import annotations import unittest -from jinja2 import Environment, FileSystemLoader, StrictUndefined +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape from utils.cache.applications import get_application_defaults from utils.cache.yaml import load_yaml @@ -44,7 +44,7 @@ def render(name: str, **context) -> str: env = Environment( loader=FileSystemLoader(str(TEMPLATE_DIR)), undefined=StrictUndefined, - autoescape=False, # noqa: S701 - nginx config, not markup; Ansible renders it the same way and HTML-escaping `$binary_remote_addr` would break it + autoescape=select_autoescape(), ) return env.get_template(name).render(**context) diff --git a/tests/unit/python/roles/sys-svc-proxy/templates/test_mcp_vhost_exposure.py b/tests/unit/python/roles/sys-svc-proxy/templates/test_mcp_vhost_exposure.py index cc2eaea1c3..7d2a7611e6 100644 --- a/tests/unit/python/roles/sys-svc-proxy/templates/test_mcp_vhost_exposure.py +++ b/tests/unit/python/roles/sys-svc-proxy/templates/test_mcp_vhost_exposure.py @@ -22,7 +22,7 @@ from __future__ import annotations import re import unittest -from jinja2 import Environment, FileSystemLoader, StrictUndefined +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape from utils.cache.yaml import load_yaml from utils.roles.mapping import ROLE_FILE_META_MCP @@ -80,7 +80,7 @@ def render(role: str, *, exposure: str, sso_gated: bool = False) -> str: env = Environment( loader=FileSystemLoader(str(TEMPLATE_DIR)), undefined=StrictUndefined, - autoescape=False, # noqa: S701 - nginx config, not markup; HTML-escaping would corrupt the directives + autoescape=select_autoescape(), ) env.filters["regex_replace"] = lambda value, pattern, repl="": re.sub( pattern, repl, value diff --git a/tests/unit/python/roles/test-e2e-cli/files/shared/mcp/test_contract.py b/tests/unit/python/roles/test-e2e-cli/files/shared/mcp/test_contract.py index f4fdf6163f..70086add9d 100644 --- a/tests/unit/python/roles/test-e2e-cli/files/shared/mcp/test_contract.py +++ b/tests/unit/python/roles/test-e2e-cli/files/shared/mcp/test_contract.py @@ -659,5 +659,79 @@ class TestSseSessionProbe(unittest.TestCase): self.assertIn("absent from the probed URL", stderr.getvalue()) +NEGOTIATED = "2025-03-26" + + +class StrictProvider: + def __init__(self): + self.seen = [] + self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler()) + threading.Thread(target=self._server.serve_forever, daemon=True).start() + + @property + def url(self): + host, port = self._server.server_address[:2] + return f"http://{host}:{port}/mcp" + + def stop(self): + self._server.shutdown() + self._server.server_close() + + def _handler(self): + provider = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length) or b"{}") + method = payload.get("method") + version = self.headers.get("MCP-Protocol-Version") + status, answer = 200, {"jsonrpc": "2.0", "id": payload.get("id")} + if self.headers.get("Authorization") != "Bearer real": + status, answer["error"] = 401, {"code": -32001, "message": "no"} + elif method == "initialize": + answer["result"] = {"protocolVersion": NEGOTIATED} + elif version != NEGOTIATED: + status = 400 + answer["error"] = { + "code": -32600, + "message": "MCP-Protocol-Version header is required", + } + elif method == "tools/list": + answer["result"] = { + "tools": [{"name": "a_get"}, {"name": "a_list"}] + } + else: + answer["result"] = {"isError": False, "content": []} + if method != "initialize": + provider.seen.append((method, version)) + body = json.dumps(answer).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + return Handler + + +class TestProtocolVersionHeader(unittest.TestCase): + def test_every_request_after_initialize_names_the_negotiated_version(self): + provider = StrictProvider() + self.addCleanup(provider.stop) + load({"MCP_URL": provider.url}).main() + self.assertEqual( + provider.seen, + [ + ("notifications/initialized", NEGOTIATED), + ("tools/list", NEGOTIATED), + ("tools/call", NEGOTIATED), + ], + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/python/roles/test-e2e-playwright/test_readiness_until.py b/tests/unit/python/roles/test-e2e-playwright/test_readiness_until.py index 4fb7076704..d924d3c71b 100644 --- a/tests/unit/python/roles/test-e2e-playwright/test_readiness_until.py +++ b/tests/unit/python/roles/test-e2e-playwright/test_readiness_until.py @@ -2,7 +2,7 @@ from __future__ import annotations import unittest -from jinja2 import Environment +from jinja2 import Environment, select_autoescape from utils.cache.yaml import load_yaml_any @@ -21,9 +21,9 @@ def until_expression() -> str: def settles(result: dict) -> bool: - template = Environment(autoescape=False).from_string( # noqa: S701 - ansible condition, not markup - "{{ " + until_expression() + " }}" - ) + template = Environment( + autoescape=select_autoescape(default_for_string=False) + ).from_string("{{ " + until_expression() + " }}") return template.render(_wait_result=result) == "True" diff --git a/tests/unit/python/roles/web-app-gitlab/test_smtp_settings.py b/tests/unit/python/roles/web-app-gitlab/test_smtp_settings.py index 4198036cc3..ac76ffb055 100644 --- a/tests/unit/python/roles/web-app-gitlab/test_smtp_settings.py +++ b/tests/unit/python/roles/web-app-gitlab/test_smtp_settings.py @@ -17,7 +17,7 @@ from __future__ import annotations import re import unittest -from jinja2 import Environment, StrictUndefined +from jinja2 import Environment, StrictUndefined, select_autoescape from utils.cache.files import read_text @@ -50,7 +50,7 @@ def _render(email: dict, ca_file: str = "") -> str: trim_blocks=True, lstrip_blocks=True, undefined=StrictUndefined, - autoescape=False, # noqa: S701 - a Ruby config file, and Ansible's templar does not escape either + autoescape=select_autoescape(default_for_string=False), ) env.filters["bool"] = bool env.filters["ruby_dq"] = lambda value: '"' + str(value).replace('"', '\\"') + '"' diff --git a/tests/unit/python/roles/web-app-keycloak/templates/test_realm_import_json.py b/tests/unit/python/roles/web-app-keycloak/templates/test_realm_import_json.py index 008a48bd8f..249189de47 100644 --- a/tests/unit/python/roles/web-app-keycloak/templates/test_realm_import_json.py +++ b/tests/unit/python/roles/web-app-keycloak/templates/test_realm_import_json.py @@ -5,7 +5,7 @@ import json import re import unittest -from jinja2 import Environment, FileSystemLoader, StrictUndefined +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape from . import PROJECT_ROOT @@ -102,7 +102,7 @@ def render(saml_apps: list[str], client_apps: list[str] | None = None) -> str: trim_blocks=True, keep_trailing_newline=True, undefined=StrictUndefined, - autoescape=False, # noqa: S701 - renders JSON, where HTML escaping corrupts the value + autoescape=select_autoescape(), ) env.filters["path_join"] = path_join env.filters["to_json"] = json.dumps diff --git a/tests/unit/python/roles/web-app-moodle/__init__.py b/tests/unit/python/roles/web-app-moodle/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/roles/web-app-moodle/files/__init__.py b/tests/unit/python/roles/web-app-moodle/files/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/roles/web-app-moodle/files/build/__init__.py b/tests/unit/python/roles/web-app-moodle/files/build/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/roles/web-app-moodle/files/build/test_moodle_entrypoint.py b/tests/unit/python/roles/web-app-moodle/files/build/test_moodle_entrypoint.py new file mode 100644 index 0000000000..9ed0c5bcb0 --- /dev/null +++ b/tests/unit/python/roles/web-app-moodle/files/build/test_moodle_entrypoint.py @@ -0,0 +1,124 @@ +import os +import pwd +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +from utils import PROJECT_ROOT +from utils.cache.files import read_text + +ENTRYPOINT = PROJECT_ROOT / "roles/web-app-moodle/files/build/moodle-entrypoint.sh" + +FPM_STUB = "#!/usr/bin/env bash\nexit 0\n" + +LAYOUT_4 = {"version.php": "4.5.11", "auth/oidc/version.php": "4.5.8"} +LAYOUT_5 = {"public/version.php": "5.3.0", "public/auth/oidc/version.php": "5.2.1"} + + +class TestMoodleEntrypoint(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + self.source = self.root / "source" + self.code = self.root / "code" + bin_dir = self.root / "bin" + bin_dir.mkdir() + stub = bin_dir / "php-fpm" + stub.write_text(FPM_STUB) + stub.chmod(0o755) + self.env = dict(os.environ) + self.env.update( + PATH=f"{bin_dir}{os.pathsep}{self.env['PATH']}", + MOODLE_CODE_DIR=str(self.code), + MOODLE_DATA_DIR=str(self.root / "data"), + MOODLE_LOCAL_CACHE_DIR=str(self.root / "localcache"), + MOODLE_SOURCE_DIR=str(self.source), + MOODLE_RUNTIME_USER=pwd.getpwuid(os.getuid()).pw_name, + MOODLE_VERSION_FILE="public/version.php", + ) + + def write(self, tree, files): + for name, content in files.items(): + path = tree / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + def ship(self, files): + shutil.rmtree(self.source, ignore_errors=True) + self.write(self.source, files) + + def run_entrypoint(self, release): + return subprocess.run( + ["bash", str(ENTRYPOINT), "php-fpm"], + env={**self.env, "MOODLE_RELEASE": release}, + capture_output=True, + text=True, + check=False, + ) + + def start(self, release): + done = self.run_entrypoint(release) + self.assertEqual(done.returncode, 0, done.stderr) + + def shipped_files_in_the_volume(self): + return { + name for name in {*LAYOUT_4, *LAYOUT_5} if (self.code / name).is_file() + } + + def test_the_first_start_fills_the_code_volume(self): + self.ship(LAYOUT_5) + self.start("5.3.0") + self.assertEqual(self.shipped_files_in_the_volume(), set(LAYOUT_5)) + + def test_a_restart_on_the_same_release_keeps_what_the_volume_holds(self): + self.ship(LAYOUT_5) + self.start("5.3.0") + added = {"public/local/extra/version.php": "added after the bootstrap"} + self.write(self.code, added) + self.start("5.3.0") + self.assertTrue((self.code / "public/local/extra/version.php").is_file()) + + def test_another_release_replaces_the_tree_and_drops_what_it_no_longer_ships(self): + self.ship(LAYOUT_4) + self.start("4.5.11") + self.ship(LAYOUT_5) + self.start("5.3.0") + self.assertEqual(self.shipped_files_in_the_volume(), set(LAYOUT_5)) + + def test_a_volume_the_previous_entrypoint_marked_done_is_replaced(self): + self.write(self.code, LAYOUT_4) + (self.code / ".bootstrap.done").touch() + self.ship(LAYOUT_5) + self.start("5.3.0") + self.assertEqual(self.shipped_files_in_the_volume(), set(LAYOUT_5)) + + def test_a_missing_source_tree_fails_and_leaves_the_volume_unmarked(self): + done = self.run_entrypoint("5.3.0") + self.assertNotEqual(done.returncode, 0) + self.assertFalse((self.code / ".bootstrap.done").exists()) + + def test_a_failed_copy_keeps_the_tree_the_volume_held(self): + self.ship(LAYOUT_4) + self.start("4.5.11") + shutil.rmtree(self.source) + done = self.run_entrypoint("5.3.0") + self.assertNotEqual(done.returncode, 0) + self.assertEqual(self.shipped_files_in_the_volume(), set(LAYOUT_4)) + self.assertEqual(read_text(str(self.code / ".bootstrap.done")), "4.5.11") + + def test_an_image_older_than_the_recorded_release_refuses_to_replace_it(self): + self.ship(LAYOUT_5) + self.start("5.10.0") + self.ship(LAYOUT_4) + done = self.run_entrypoint("5.9.2") + self.assertNotEqual(done.returncode, 0) + self.assertIn("5.10.0", done.stderr) + self.assertEqual(self.shipped_files_in_the_volume(), set(LAYOUT_5)) + self.assertEqual(read_text(str(self.code / ".bootstrap.done")), "5.10.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/web-app-openbao/test_credential_rotation.py b/tests/unit/python/roles/web-app-openbao/test_credential_rotation.py index 4beccfb7b3..582607a82d 100644 --- a/tests/unit/python/roles/web-app-openbao/test_credential_rotation.py +++ b/tests/unit/python/roles/web-app-openbao/test_credential_rotation.py @@ -12,7 +12,7 @@ from __future__ import annotations import unittest -from jinja2 import Environment, FileSystemLoader, StrictUndefined +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape from utils.cache.files import read_text from utils.cache.yaml import load_yaml_any @@ -44,7 +44,7 @@ def jinja_env(searchpath) -> Environment: env = Environment( loader=FileSystemLoader(str(searchpath)), undefined=StrictUndefined, - autoescape=False, # noqa: S701 - renders HCL and dotenv, not markup + autoescape=select_autoescape(default_for_string=False), ) env.filters["bool"] = bool env.filters["dotenv_quote"] = lambda value: f'"{value}"' diff --git a/tests/unit/python/roles/web-app-openwebui/files/test_provision_mcp_grants.py b/tests/unit/python/roles/web-app-openwebui/files/test_provision_mcp_grants.py index 65304f0539..97205be309 100644 --- a/tests/unit/python/roles/web-app-openwebui/files/test_provision_mcp_grants.py +++ b/tests/unit/python/roles/web-app-openwebui/files/test_provision_mcp_grants.py @@ -465,7 +465,7 @@ class TestResolveApiKey(unittest.TestCase): modules = {} backend.install(modules) with patch.dict("sys.modules", modules): - return module, asyncio.run(module.resolve_api_key()) + return asyncio.run(module.resolve_api_key()) def test_an_existing_administrator_key_is_reused_and_kept(self) -> None: backend = FakeBackend( @@ -473,7 +473,7 @@ class TestResolveApiKey(unittest.TestCase): keys={"admin1": "sk-operator"}, ) - _module, (user_id, key, minted) = self._resolve(backend) + user_id, key, minted = self._resolve(backend) self.assertEqual("admin1", user_id) self.assertEqual("sk-operator", key) @@ -483,7 +483,7 @@ class TestResolveApiKey(unittest.TestCase): def test_a_missing_key_is_minted_and_marked_for_removal(self) -> None: backend = FakeBackend([FakeUser("admin1", "admin")]) - _module, (user_id, key, minted) = self._resolve(backend) + user_id, key, minted = self._resolve(backend) self.assertEqual("admin1", user_id) self.assertTrue(key.startswith("sk-")) @@ -493,7 +493,7 @@ class TestResolveApiKey(unittest.TestCase): def test_an_instance_without_users_gets_one_administrator(self) -> None: backend = FakeBackend([]) - _module, (user_id, _key, minted) = self._resolve(backend) + user_id, _key, minted = self._resolve(backend) self.assertEqual(["admin"], [user.role for user in backend.created]) self.assertEqual(backend.created[0].id, user_id) @@ -512,9 +512,8 @@ class TestResolveApiKey(unittest.TestCase): admin_id, _key, minted = asyncio.run(module.resolve_api_key()) self.assertTrue(minted) try: - exploding_grant(_key) - except RuntimeError: - pass + with self.assertRaises(RuntimeError): + exploding_grant(_key) finally: asyncio.run(module.drop_api_key(admin_id)) diff --git a/tests/unit/python/roles/web-app-stalwart/files/test_production_facts.py b/tests/unit/python/roles/web-app-stalwart/files/test_production_facts.py index 3c57b00f2e..1ef49c2119 100644 --- a/tests/unit/python/roles/web-app-stalwart/files/test_production_facts.py +++ b/tests/unit/python/roles/web-app-stalwart/files/test_production_facts.py @@ -5,8 +5,8 @@ import struct import sys import tempfile import unittest +import unittest.mock as mock from pathlib import Path -from unittest import mock from . import PROJECT_ROOT diff --git a/tests/unit/python/roles/web-app-stalwart/test_onion_route.py b/tests/unit/python/roles/web-app-stalwart/test_onion_route.py index cf6ad49854..296133e61b 100644 --- a/tests/unit/python/roles/web-app-stalwart/test_onion_route.py +++ b/tests/unit/python/roles/web-app-stalwart/test_onion_route.py @@ -12,7 +12,7 @@ from __future__ import annotations import json import unittest -from jinja2 import Environment, StrictUndefined +from jinja2 import Environment, StrictUndefined, select_autoescape from utils.cache.files import read_text from utils.cache.yaml import load_yaml_any @@ -34,7 +34,7 @@ def _render(template_path, **variables) -> str: trim_blocks=True, lstrip_blocks=True, undefined=StrictUndefined, - autoescape=False, # noqa: S701 - JMAP request bodies, never HTML + autoescape=select_autoescape(default_for_string=False), ) env.filters["to_json"] = json.dumps env.filters["int"] = int diff --git a/tests/unit/python/roles/web-app-stalwart/test_roundcube_oauth_scheme.py b/tests/unit/python/roles/web-app-stalwart/test_roundcube_oauth_scheme.py index 164ce94b0c..8261829975 100644 --- a/tests/unit/python/roles/web-app-stalwart/test_roundcube_oauth_scheme.py +++ b/tests/unit/python/roles/web-app-stalwart/test_roundcube_oauth_scheme.py @@ -16,7 +16,7 @@ from __future__ import annotations import unittest -from jinja2 import Environment, StrictUndefined +from jinja2 import Environment, StrictUndefined, select_autoescape from utils.cache.files import read_text @@ -41,7 +41,7 @@ def _render(base_url: str, *, sso_enabled: bool = True, socks_proxy: str = "") - trim_blocks=True, lstrip_blocks=True, undefined=StrictUndefined, - autoescape=False, # noqa: S701 - a PHP config file, and Ansible's templar does not escape either + autoescape=select_autoescape(default_for_string=False), ) env.filters["bool"] = bool return env.from_string(read_text(TEMPLATE)).render( diff --git a/tests/unit/python/utils/roles/test_guide.py b/tests/unit/python/utils/roles/test_guide.py index 49c3bcf1de..387edc71fd 100644 --- a/tests/unit/python/utils/roles/test_guide.py +++ b/tests/unit/python/utils/roles/test_guide.py @@ -1,6 +1,6 @@ import unittest +import unittest.mock as mock from typing import ClassVar -from unittest import mock from utils.cache.applications import get_variants from utils.roles.guide import guide_deployable, guide_variant, smallest_variant diff --git a/tests/unit/python/utils/tests/swarm/test_matrix_run_reporting.py b/tests/unit/python/utils/tests/swarm/test_matrix_run_reporting.py index 4f27992dcb..b0773bf61e 100644 --- a/tests/unit/python/utils/tests/swarm/test_matrix_run_reporting.py +++ b/tests/unit/python/utils/tests/swarm/test_matrix_run_reporting.py @@ -1,7 +1,7 @@ import contextlib import io import unittest -from unittest import mock +import unittest.mock as mock from utils.tests.swarm import matrix