diff --git a/cli/meta/env/__main__.py b/cli/meta/env/__main__.py index 9ab0ca4b71..d4f7e3764f 100644 --- a/cli/meta/env/__main__.py +++ b/cli/meta/env/__main__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import sys from utils.env.builder import build_env @@ -10,6 +11,8 @@ from utils.env.writer import write_dotenv from . import PROJECT_ROOT as REPO_ROOT +HOST_OWNED_KEY = "INFINITO_DOTENV_HOST_OWNED" # nocheck: dotenv-host-owned-marker Reason: set only by compose.yml inside the runner container; not a configuration key, a .env entry could freeze the host .env + def main() -> int: static_path = REPO_ROOT / "default.env" @@ -19,6 +22,14 @@ def main() -> int: print(f"ERROR: missing {static_path}", file=sys.stderr) return 2 + if out_path.is_file() and os.environ.get(HOST_OWNED_KEY) == "true": + print( + f"Kept {out_path.relative_to(REPO_ROOT)}: the host checkout owns it " + f"({HOST_OWNED_KEY})", + file=sys.stderr, + ) + return 0 + static, static_comments = parse_static_env_with_comments(static_path) eb = build_env(static, repo_root=REPO_ROOT, comments=static_comments) write_dotenv(eb, out_path) diff --git a/compose.yml b/compose.yml index 3bb9f3e536..8d12571046 100644 --- a/compose.yml +++ b/compose.yml @@ -78,6 +78,7 @@ services: INFINITO_DIR_VAR_LIB: ${INFINITO_DIR_VAR_LIB} INFINITO_DNS_IP: ${INFINITO_DNS_IP} INFINITO_DOMAIN: ${INFINITO_DOMAIN} + INFINITO_DOTENV_HOST_OWNED: "true" INFINITO_GHCR_MIRROR_PREFIX: ${INFINITO_GHCR_MIRROR_PREFIX} INFINITO_IMAGE_TAG: ${INFINITO_IMAGE_TAG} INFINITO_IP4: ${INFINITO_IP4} diff --git a/package-lock.json b/package-lock.json index 1d40cf4db5..19db992e0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "devDependencies": { "@eslint/js": "^10.0.1", - "@playwright/test": "^1.63.0", + "@playwright/test": "^1.64.0", "eslint": "^10.12.0", "eslint-plugin-playwright": "^2.12.1", "globals": "^17.13.0", @@ -484,13 +484,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", - "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.63.0" + "playwright": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -1817,13 +1817,13 @@ } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -1833,9 +1833,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index d52cb43baa..529ad81295 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@playwright/test": "^1.63.0", + "@playwright/test": "^1.64.0", "eslint": "^10.12.0", "eslint-plugin-playwright": "^2.12.1", "globals": "^17.13.0", diff --git a/plugins/lookup/network_driver.py b/plugins/lookup/network_driver.py new file mode 100644 index 0000000000..801ba651f0 --- /dev/null +++ b/plugins/lookup/network_driver.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import json +from typing import Any + +from ansible.errors import AnsibleError +from ansible.plugins.lookup import LookupBase + +from utils.roles.entity.name import get_entity_name + + +class LookupModule(LookupBase): + """ + Usage: + {{ lookup('network_driver', probe, network_role_id) }} + + Args: + probe: the registered result of + `container network ls --filter name= --format '{{json .}}'`. + network_role_id: the role whose entity name names the network. + + Returns: + The driver of the network named exactly after that entity, '' when none exists. + """ + + def run(self, terms, variables: dict[str, Any] | None = None, **kwargs): + if not terms or len(terms) != 2: + raise AnsibleError( + "lookup('network_driver', probe, network_role_id) expects exactly 2 terms" + ) + + probe, network_role_id = terms + name = get_entity_name(str(network_role_id)) + lines = probe.get("stdout_lines") if isinstance(probe, dict) else None + for line in lines or []: + try: + network = json.loads(line) + except json.JSONDecodeError as exc: + raise AnsibleError( + f"lookup('network_driver'): unparsable probe line {line!r}" + ) from exc + if network.get("Name") == name: + return [str(network.get("Driver", ""))] + return [""] diff --git a/roles/svc-ai-ollama/meta/services.yml b/roles/svc-ai-ollama/meta/services.yml index cba1342a36..77fc9801ed 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.2 + version: 0.40.3 name: ollama min_storage: 3473MB cpus: "4.0" diff --git a/roles/sys-ctl-hlth-csp/meta/services.yml b/roles/sys-ctl-hlth-csp/meta/services.yml index a6e9695221..2cff885e8c 100644 --- a/roles/sys-ctl-hlth-csp/meta/services.yml +++ b/roles/sys-ctl-hlth-csp/meta/services.yml @@ -8,4 +8,4 @@ csp: categories: [web] csp-checker: image: ghcr.io/kevinveenbirkenbach/csp-checker - version: 3.0.4 + version: 3.0.5 diff --git a/roles/sys-svc-compose/tasks/utils/network/create.yml b/roles/sys-svc-compose/tasks/utils/network/create.yml index 3e6c89ac78..92f7f3f68e 100644 --- a/roles/sys-svc-compose/tasks/utils/network/create.yml +++ b/roles/sys-svc-compose/tasks/utils/network/create.yml @@ -1,8 +1,16 @@ --- - name: "🔍 Probe existing overlay network for '{{ network_role_id }}' (swarm scope)" - community.docker.docker_network_info: # nocheck: docker-module-uses-container-cli read-only info module; returns structured network facts the wrapper's raw JSON would need parsed - name: "{{ network_role_id | get_entity_name }}" + ansible.builtin.command: + argv: + - container + - network + - ls + - --filter + - "name={{ network_role_id | get_entity_name }}" + - --format + - "{{ '{{json .}}' }}" register: sys_svc_compose_overlay_probe + changed_when: false when: (SWARM_CLUSTER_AVAILABLE | bool) and not (lookup('config', network_role_id, 'networks.local.force_bridge', false) | bool) run_once: true delegate_to: "{{ STACK_HOST }}" @@ -11,12 +19,11 @@ ansible.builtin.fail: msg: >- Network '{{ network_role_id | get_entity_name }}' exists with driver - '{{ sys_svc_compose_overlay_probe.network.Driver | default('unknown') }}' but 'overlay' is required. + '{{ lookup('network_driver', sys_svc_compose_overlay_probe, network_role_id) }}' but 'overlay' is required. Remove the stale network or revert the networks.local.force_bridge change. when: - (SWARM_CLUSTER_AVAILABLE | bool) and not (lookup('config', network_role_id, 'networks.local.force_bridge', false) | bool) - - sys_svc_compose_overlay_probe.exists | default(false) | bool - - (sys_svc_compose_overlay_probe.network.Driver | default('')) != 'overlay' + - lookup('network_driver', sys_svc_compose_overlay_probe, network_role_id) not in ['', 'overlay'] run_once: true delegate_to: "{{ STACK_HOST }}" @@ -44,27 +51,34 @@ - "{{ network_role_id | get_entity_name }}" when: - (SWARM_CLUSTER_AVAILABLE | bool) and not (lookup('config', network_role_id, 'networks.local.force_bridge', false) | bool) - - not (sys_svc_compose_overlay_probe.exists | default(false) | bool) + - lookup('network_driver', sys_svc_compose_overlay_probe, network_role_id) == '' run_once: true delegate_to: "{{ STACK_HOST }}" changed_when: true - name: "🔍 Probe existing bridge network for '{{ network_role_id }}' (compose)" - community.docker.docker_network_info: # nocheck: docker-module-uses-container-cli read-only info module; returns structured network facts the wrapper's raw JSON would need parsed - name: "{{ network_role_id | get_entity_name }}" + ansible.builtin.command: + argv: + - container + - network + - ls + - --filter + - "name={{ network_role_id | get_entity_name }}" + - --format + - "{{ '{{json .}}' }}" register: sys_svc_compose_bridge_probe + changed_when: false when: (not (SWARM_CLUSTER_AVAILABLE | bool)) or (lookup('config', network_role_id, 'networks.local.force_bridge', false) | bool) - name: "🛑 Fail on network driver drift for '{{ network_role_id }}' (compose)" ansible.builtin.fail: msg: >- Network '{{ network_role_id | get_entity_name }}' exists with driver - '{{ sys_svc_compose_bridge_probe.network.Driver | default('unknown') }}' but 'bridge' is required. + '{{ lookup('network_driver', sys_svc_compose_bridge_probe, network_role_id) }}' but 'bridge' is required. Remove the stale network or revert the networks.local.force_bridge change. when: - (not (SWARM_CLUSTER_AVAILABLE | bool)) or (lookup('config', network_role_id, 'networks.local.force_bridge', false) | bool) - - sys_svc_compose_bridge_probe.exists | default(false) | bool - - (sys_svc_compose_bridge_probe.network.Driver | default('')) != 'bridge' + - lookup('network_driver', sys_svc_compose_bridge_probe, network_role_id) not in ['', 'bridge'] - name: "✅ Ensure docker bridge network for '{{ network_role_id }}' exists (compose)" ansible.builtin.command: @@ -81,5 +95,5 @@ - "{{ network_role_id | get_entity_name }}" when: - (not (SWARM_CLUSTER_AVAILABLE | bool)) or (lookup('config', network_role_id, 'networks.local.force_bridge', false) | bool) - - not (sys_svc_compose_bridge_probe.exists | default(false) | bool) + - lookup('network_driver', sys_svc_compose_bridge_probe, network_role_id) == '' changed_when: true diff --git a/roles/web-app-bigbluebutton/meta/services.yml b/roles/web-app-bigbluebutton/meta/services.yml index 1391350f6f..887ca0d0da 100644 --- a/roles/web-app-bigbluebutton/meta/services.yml +++ b/roles/web-app-bigbluebutton/meta/services.yml @@ -267,7 +267,7 @@ bbb-webrtc-recorder: # nocheck: dynamic-flag: fixed upstream-compose helper, not group-gated dockerize: image: ghcr.io/jwilder/dockerize - version: "v0.15.1" + version: "v0.15.2" enabled: false # nocheck: playwright-service-flag, dynamic-flag infrastructure backup consumer diff --git a/roles/web-app-gitlab/meta/services.yml b/roles/web-app-gitlab/meta/services.yml index 018c3f952a..27ac8c52a4 100644 --- a/roles/web-app-gitlab/meta/services.yml +++ b/roles/web-app-gitlab/meta/services.yml @@ -76,7 +76,7 @@ gitlab: lifecycle: beta webservice: image: registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ce - version: v19.4.1 + version: v19.4.2 name: gitlab-webservice healthcheck: test: ["CMD", "/scripts/healthcheck"] @@ -95,7 +95,7 @@ sidekiq: flavor: tcp path: liveness image: registry.gitlab.com/gitlab-org/build/cng/gitlab-sidekiq-ce - version: v19.4.1 + version: v19.4.2 name: gitlab-sidekiq cpus: "1.0" mem_reservation: 2g @@ -106,7 +106,7 @@ sidekiq: http: 3808 workhorse: image: registry.gitlab.com/gitlab-org/build/cng/gitlab-workhorse-ce - version: v19.4.1 + version: v19.4.2 name: gitlab-workhorse healthcheck: test: ["CMD", "/scripts/healthcheck"] @@ -118,7 +118,7 @@ gitaly: # nocheck: single-replica replicas: 1 image: registry.gitlab.com/gitlab-org/build/cng/gitaly - version: v19.4.1 + version: v19.4.2 healthcheck: test: ["CMD", "/scripts/healthcheck"] name: gitlab-gitaly @@ -131,7 +131,7 @@ gitaly: pids_limit: 1024 shell: image: registry.gitlab.com/gitlab-org/build/cng/gitlab-shell - version: v19.4.1 + version: v19.4.2 name: gitlab-shell ports: internal: @@ -144,7 +144,7 @@ migrations: # nocheck: single-replica replicas: 1 image: registry.gitlab.com/gitlab-org/build/cng/gitlab-rails-ce - version: v19.4.1 + version: v19.4.2 name: gitlab-migrations cpus: "1.0" mem_reservation: 1g diff --git a/roles/web-app-n8n/meta/services.yml b/roles/web-app-n8n/meta/services.yml index 156c60abc5..64aa57e29f 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: "2.42.3" + version: "2.43.4" update: type: registry_tags image: docker.n8n.io/n8nio/n8n diff --git a/roles/web-app-odoo/files/playwright/_shared.js b/roles/web-app-odoo/files/playwright/_shared.js index 926cd7152b..86e16edf88 100644 --- a/roles/web-app-odoo/files/playwright/_shared.js +++ b/roles/web-app-odoo/files/playwright/_shared.js @@ -10,6 +10,10 @@ const env = { adminPassword: decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || ""), }; +const onionOptions = /\bhttps?:\/\/[^/,\s"']+\.onion\b/i.test(env.odooBaseUrl) + ? { channel: "chromium", serviceWorkers: "block" } + : {}; + function baseUrl() { return env.odooBaseUrl.replace(/\/$/, ""); } @@ -152,6 +156,7 @@ async function openModule(page, modulePath) { module.exports = { env, + onionOptions, baseUrl, clickOdooSsoButton, loginToOdoo, diff --git a/roles/web-app-odoo/files/playwright/addons/account.spec.js b/roles/web-app-odoo/files/playwright/addons/account.spec.js index 49344827ed..39a712fabf 100644 --- a/roles/web-app-odoo/files/playwright/addons/account.spec.js +++ b/roles/web-app-odoo/files/playwright/addons/account.spec.js @@ -3,6 +3,8 @@ const { resolveTimeout } = require("../timeouts"); const { skipUnlessAddonEnabled } = require("../addon-gating"); const shared = require("../_shared"); +test.use(shared.onionOptions); + test("addon account: Accounting/Invoicing module is installed and serves its customer-invoices action", async ({ browser }) => { skipUnlessAddonEnabled("account"); diff --git a/roles/web-app-odoo/files/playwright/addons/contacts.spec.js b/roles/web-app-odoo/files/playwright/addons/contacts.spec.js index 7a20720884..05a65d4c7d 100644 --- a/roles/web-app-odoo/files/playwright/addons/contacts.spec.js +++ b/roles/web-app-odoo/files/playwright/addons/contacts.spec.js @@ -3,6 +3,8 @@ const { resolveTimeout } = require("../timeouts"); const { skipUnlessAddonEnabled } = require("../addon-gating"); const shared = require("../_shared"); +test.use(shared.onionOptions); + test("addon contacts: contacts module UI renders", async ({ browser }) => { skipUnlessAddonEnabled("contacts"); diff --git a/roles/web-app-odoo/files/playwright/addons/crm.spec.js b/roles/web-app-odoo/files/playwright/addons/crm.spec.js index d52797a5dc..d5d4ee7733 100644 --- a/roles/web-app-odoo/files/playwright/addons/crm.spec.js +++ b/roles/web-app-odoo/files/playwright/addons/crm.spec.js @@ -3,6 +3,8 @@ const { resolveTimeout } = require("../timeouts"); const { skipUnlessAddonEnabled } = require("../addon-gating"); const shared = require("../_shared"); +test.use(shared.onionOptions); + test("addon crm: CRM module is installed and its pipeline feature renders", async ({ browser }) => { skipUnlessAddonEnabled("crm"); diff --git a/roles/web-app-odoo/files/playwright/addons/project.spec.js b/roles/web-app-odoo/files/playwright/addons/project.spec.js index 458561fdcf..4e00fb836d 100644 --- a/roles/web-app-odoo/files/playwright/addons/project.spec.js +++ b/roles/web-app-odoo/files/playwright/addons/project.spec.js @@ -3,6 +3,8 @@ const { resolveTimeout } = require("../timeouts"); const { skipUnlessAddonEnabled } = require("../addon-gating"); const shared = require("../_shared"); +test.use(shared.onionOptions); + test("addon project: project module UI renders", async ({ browser }) => { skipUnlessAddonEnabled("project"); diff --git a/roles/web-app-odoo/files/playwright/addons/sale_management.spec.js b/roles/web-app-odoo/files/playwright/addons/sale_management.spec.js index 8092266219..9ab5980bde 100644 --- a/roles/web-app-odoo/files/playwright/addons/sale_management.spec.js +++ b/roles/web-app-odoo/files/playwright/addons/sale_management.spec.js @@ -3,6 +3,8 @@ const { resolveTimeout } = require("../timeouts"); const { skipUnlessAddonEnabled } = require("../addon-gating"); const shared = require("../_shared"); +test.use(shared.onionOptions); + test("addon sale_management: Sales module is installed and serves its quotations action", async ({ browser }) => { skipUnlessAddonEnabled("sale_management"); diff --git a/roles/web-app-odoo/files/playwright/addons/stock.spec.js b/roles/web-app-odoo/files/playwright/addons/stock.spec.js index 28192446a0..202114cf1c 100644 --- a/roles/web-app-odoo/files/playwright/addons/stock.spec.js +++ b/roles/web-app-odoo/files/playwright/addons/stock.spec.js @@ -3,6 +3,8 @@ const { resolveTimeout } = require("../timeouts"); const { skipUnlessAddonEnabled } = require("../addon-gating"); const shared = require("../_shared"); +test.use(shared.onionOptions); + test("addon stock: Inventory module is installed and serves its warehouse operations action", async ({ browser }) => { skipUnlessAddonEnabled("stock"); diff --git a/roles/web-app-odoo/files/playwright/addons/website.spec.js b/roles/web-app-odoo/files/playwright/addons/website.spec.js index 5a541bcc86..598f8d95c5 100644 --- a/roles/web-app-odoo/files/playwright/addons/website.spec.js +++ b/roles/web-app-odoo/files/playwright/addons/website.spec.js @@ -3,6 +3,8 @@ const { resolveTimeout } = require("../timeouts"); const { skipUnlessAddonEnabled } = require("../addon-gating"); const shared = require("../_shared"); +test.use(shared.onionOptions); + test("addon website: website module UI renders", async ({ browser }) => { skipUnlessAddonEnabled("website"); diff --git a/roles/web-app-odoo/files/playwright/playwright.spec.js b/roles/web-app-odoo/files/playwright/playwright.spec.js index f0dc42b44e..a67e38b230 100644 --- a/roles/web-app-odoo/files/playwright/playwright.spec.js +++ b/roles/web-app-odoo/files/playwright/playwright.spec.js @@ -3,8 +3,10 @@ const { resolveTimeout } = require("./timeouts"); const { decodeDotenvQuotedValue, isVisible, performKeycloakLoginForm, runAdminFlow, runBiberFlow, runGuestFlow, gotoOnion } = require("./personas"); const { isServiceEnabled } = require("./service-gating"); +const { onionOptions } = require("./_shared"); test.use({ - ignoreHTTPSErrors: true + ignoreHTTPSErrors: true, + ...onionOptions }); const oidcEnabled = isServiceEnabled("sso"); diff --git a/roles/web-app-socialhome/meta/services.yml b/roles/web-app-socialhome/meta/services.yml index 43f68c9d7a..d3ff755d73 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.8" + version: "2026.10.11" ports: local: http: 8089 diff --git a/roles/web-app-stalwart/meta/services.yml b/roles/web-app-stalwart/meta/services.yml index fd83fddb34..ff0cd9269d 100644 --- a/roles/web-app-stalwart/meta/services.yml +++ b/roles/web-app-stalwart/meta/services.yml @@ -42,7 +42,7 @@ stalwart: # nocheck: default-placement-manager mail edge at a known IP: MX A-record, PTR and SPF bind to the stack host IP (a floating task's egress IP breaks deliverability); the mail store is NFS-hostile and IS the app — same rationale as web-app-mailu placement: manager image: stalwartlabs/stalwart - version: "v0.16.25" + version: "v0.16.26" webui_version: "v1.0.11" update: key: webui_version diff --git a/roles/web-app-suitecrm/files/session.yaml b/roles/web-app-suitecrm/files/session.yaml new file mode 100644 index 0000000000..0edf781b3d --- /dev/null +++ b/roles/web-app-suitecrm/files/session.yaml @@ -0,0 +1,29 @@ +--- +framework: + session: + storage_factory_id: infinito.session.storage.factory.php_handler + handler_id: infinito.session.handler.php_handler + +services: + infinito.session.handler.php_native: + class: SessionHandler + + infinito.session.metadata_bag: + class: Symfony\Component\HttpFoundation\Session\Storage\MetadataBag + arguments: + - "%session.metadata.storage_key%" + - "%session.metadata.update_threshold%" + + infinito.session.handler.php_handler: + class: App\Security\Session\LegacyBridgeSessionHandler + arguments: + - "@infinito.session.handler.php_native" + - "@App\\Engine\\LegacyHandler\\DefaultLegacyHandler" + + infinito.session.storage.factory.php_handler: + class: App\Security\Session\LegacyBridgeSessionStorageFactory + arguments: + - "@App\\Engine\\LegacyHandler\\DefaultLegacyHandler" + - "@infinito.session.handler.php_handler" + - "@infinito.session.metadata_bag" + - "false" diff --git a/roles/web-app-suitecrm/files/shell/docker-entrypoint.sh b/roles/web-app-suitecrm/files/shell/docker-entrypoint.sh index 2c2d5c954a..1d96c0a4c1 100644 --- a/roles/web-app-suitecrm/files/shell/docker-entrypoint.sh +++ b/roles/web-app-suitecrm/files/shell/docker-entrypoint.sh @@ -112,6 +112,11 @@ if [ "$_have_lock" = "1" ]; then log "SuiteCRM already installed - skipping installer." fi + if [ -d "${APP_DIR}/cache/prod" ] && [ -n "$(find "${APP_DIR}/extensions" -path '*/config/*' -type f -newer "${APP_DIR}/cache/prod" -print -quit)" ]; then + CACHE_REFRESH=1 + log "Extension config is newer than the prod cache - refreshing it." + fi + if [ "$CACHE_REFRESH" = "1" ] || [ ! -d "${APP_DIR}/cache/prod" ]; then log "Clearing Symfony cache..." php bin/console cache:clear --no-warmup || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error diff --git a/roles/web-app-suitecrm/meta/volumes.yml b/roles/web-app-suitecrm/meta/volumes.yml index 5a1f48a29c..652e82b8a6 100644 --- a/roles/web-app-suitecrm/meta/volumes.yml +++ b/roles/web-app-suitecrm/meta/volumes.yml @@ -14,6 +14,14 @@ php_config: - service: suitecrm target: "/usr/local/etc/php/conf.d/{{ SOFTWARE_DOMAIN | replace('.', '-') }}.ini" +session_config: + type: config + source: "{{ [lookup('container', 'web-app-suitecrm', 'directories.config'), 'session.yaml'] | path_join }}" + mode: "0444" + mounts: + - service: suitecrm + target: "{{ ['/var/www/html/extensions', SOFTWARE_DOMAIN | replace('.', '-'), 'config/packages/session.yaml'] | path_join }}" + ldap_config: type: config source: "{{ [lookup('container', 'web-app-suitecrm', 'directories.config'), 'ldap.yaml'] | path_join }}" diff --git a/roles/web-app-suitecrm/tasks/01_manager_ops.yml b/roles/web-app-suitecrm/tasks/01_manager_ops.yml index e1801b6936..373126537c 100644 --- a/roles/web-app-suitecrm/tasks/01_manager_ops.yml +++ b/roles/web-app-suitecrm/tasks/01_manager_ops.yml @@ -14,6 +14,14 @@ notify: - compose-up +- name: "📄 Deploy SuiteCRM session handler wiring" + stack_host_copy: + src: "session.yaml" + dest: "{{ SUITECRM_SESSION_CONFIG_HOST }}" + mode: "0444" + notify: + - compose-up + - name: "🚀 Deploy '{{ SUITECRM_ENTRYPOINT_SCRIPT_HOST_ABS }}'" copy: src: "{{ SUITECRM_ENTRYPOINT_SCRIPT_SRC }}" diff --git a/roles/web-app-suitecrm/vars/main.yml b/roles/web-app-suitecrm/vars/main.yml index aaf9602ea6..ec66f96ded 100644 --- a/roles/web-app-suitecrm/vars/main.yml +++ b/roles/web-app-suitecrm/vars/main.yml @@ -32,6 +32,7 @@ SUITECRM_OIDC_SOCKS_PROXY: "{{ lookup('tor_socks_proxy') if SUITECRM_OIDC_ONION_ SUITECRM_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') }}" SUITECRM_LDAP_CONFIG_HOST: "{{ [ lookup('container', application_id, 'directories.config'), 'ldap.yaml'] | path_join }}" +SUITECRM_SESSION_CONFIG_HOST: "{{ [ lookup('container', application_id, 'directories.config'), 'session.yaml'] | path_join }}" SUITECRM_LDAP_EXTENSION_DIR: "{{ [SUITECRM_APP_DIR, 'extensions', SUITECRM_WELL_SOFTWARE_NAME, 'config/services/ldap'] | path_join }}" SUITECRM_ENTRYPOINT_SCRIPT_FILE: "docker-entrypoint.sh" diff --git a/tests/unit/python/cli/meta/env/__init__.py b/tests/unit/python/cli/meta/env/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/cli/meta/env/test_main.py b/tests/unit/python/cli/meta/env/test_main.py new file mode 100644 index 0000000000..d2cc44c42f --- /dev/null +++ b/tests/unit/python/cli/meta/env/test_main.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import cli.meta.env.__main__ as env_main + + +class TestDotenvHostOwned(unittest.TestCase): + def _run(self, *, dotenv_exists: bool, host_owned: str | None) -> tuple[int, bool]: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "default.env").write_text("INFINITO_DISTRO=debian\n") + if dotenv_exists: + (root / ".env").write_text("INFINITO_RUNNING_ON_GITHUB=true\n") + environ = ( + {} if host_owned is None else {env_main.HOST_OWNED_KEY: host_owned} + ) + with ( + patch.object(env_main, "REPO_ROOT", root), + patch.dict("os.environ", environ, clear=True), + patch.object( + env_main, "parse_static_env_with_comments", return_value=({}, {}) + ), + patch.object(env_main, "build_env") as build_env, + patch.object(env_main, "write_dotenv") as write_dotenv, + ): + build_env.return_value.values = {} + rc = env_main.main() + return rc, write_dotenv.called + + def test_a_host_owned_dotenv_is_kept(self) -> None: + self.assertEqual(self._run(dotenv_exists=True, host_owned="true"), (0, False)) + + def test_a_missing_dotenv_is_generated_even_when_host_owned(self) -> None: + self.assertEqual(self._run(dotenv_exists=False, host_owned="true"), (0, True)) + + def test_without_the_marker_the_dotenv_is_regenerated(self) -> None: + self.assertEqual(self._run(dotenv_exists=True, host_owned=None), (0, True)) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/unit/python/plugins/lookup/test_network_driver.py b/tests/unit/python/plugins/lookup/test_network_driver.py new file mode 100644 index 0000000000..7df4b95c5f --- /dev/null +++ b/tests/unit/python/plugins/lookup/test_network_driver.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import importlib.util +import json +import unittest + +from ansible.errors import AnsibleError + +from . import PROJECT_ROOT + + +def _load_lookup(): + spec = importlib.util.spec_from_file_location( + "lookup_network_driver", + str(PROJECT_ROOT / "plugins/lookup/network_driver.py"), + ) + mod = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(mod) + return mod.LookupModule + + +def _probe(*networks): + return {"rc": 0, "stdout_lines": [json.dumps(network) for network in networks]} + + +class TestNetworkDriverLookup(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.LookupModule = _load_lookup() + + def _driver(self, probe): + return self.LookupModule().run([probe, "web-app-n8n"]) + + def test_the_network_named_after_the_entity_gives_its_driver(self): + probe = _probe({"Name": "n8n", "Driver": "overlay"}) + self.assertEqual(self._driver(probe), ["overlay"]) + + def test_a_sibling_listed_first_does_not_shadow_the_exact_name(self): + probe = _probe( + {"Name": "n8n-worker", "Driver": "bridge"}, + {"Name": "n8n", "Driver": "overlay"}, + ) + self.assertEqual(self._driver(probe), ["overlay"]) + + def test_only_a_sibling_containing_the_name_means_no_network(self): + probe = _probe({"Name": "n8n-worker", "Driver": "overlay"}) + self.assertEqual(self._driver(probe), [""]) + + def test_an_empty_listing_means_no_network(self): + self.assertEqual(self._driver(_probe()), [""]) + + def test_a_skipped_probe_means_no_network(self): + self.assertEqual(self._driver({"skipped": True, "changed": False}), [""]) + + def test_an_unparsable_line_is_rejected(self): + with self.assertRaises(AnsibleError): + self._driver({"rc": 0, "stdout_lines": ["not json"]}) + + def test_a_wrong_term_count_is_rejected(self): + with self.assertRaises(AnsibleError): + self.LookupModule().run([_probe()]) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/unit/python/scripts/tests/deploy/test_distros.py b/tests/unit/python/scripts/tests/deploy/test_distros.py index 0abb3c7479..4b6cb76aee 100644 --- a/tests/unit/python/scripts/tests/deploy/test_distros.py +++ b/tests/unit/python/scripts/tests/deploy/test_distros.py @@ -29,6 +29,7 @@ STRIPPED = ( "INFINITO_DISTRO", "INFINITO_CI_DISTRO_BUDGET_SECONDS", "INFINITO_SWARM_STEP_TIMEOUT_MINUTES", + "INFINITO_ENV_LOADED", # nocheck: env-loader-internal-guard )