Merge branch 'main' into feature/designer

# Conflicts:
#	roles/web-app-suitecrm/files/session.yaml
#	roles/web-app-suitecrm/meta/volumes.yml
This commit is contained in:
kevinveenbirkenbach committed 2026-10-13 15:27:59 +02:00
commit 8c82e5262e
42 files changed
+829 -120

No files matched your search

+14 -12
View File
@@ -148,19 +148,21 @@ def _existing_issue(repo: str, role: str) -> int | None:
return None
def report(run_id: str, repo: str) -> int:
jobs = json.loads(
_gh(
[
"api",
"--paginate",
f"repos/{repo}/actions/runs/{run_id}/jobs",
"--jq",
"[.jobs[] | {name, conclusion}]",
]
)
or "[]"
def list_jobs(run_id: str, repo: str) -> list[dict]:
out = _gh(
[
"api",
"--paginate",
f"repos/{repo}/actions/runs/{run_id}/jobs",
"--jq",
".jobs[] | {name, conclusion}",
]
)
return [json.loads(line) for line in out.splitlines() if line.strip()]
def report(run_id: str, repo: str) -> int:
jobs = list_jobs(run_id, repo)
roles = failed_roles(jobs)
if not roles:
print("No failed deploy roles.")
+11
View File
@@ -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)
+1
View File
@@ -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}
+12 -12
View File
@@ -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": {
+1 -1
View File
@@ -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",
+44
View File
@@ -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=<entity> --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 [""]
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -15,7 +15,7 @@ tor-smtp: # nocheck: category-modes
# nocheck: default-placement-manager dials svc-net-tor's host-loopback SOCKS listener, which is manager-pinned; the gateway must share that node
placement: manager
image: python
version: 3.14-slim
version: 3.15-slim
name: tor-smtp
ports:
internal:
+1 -1
View File
@@ -8,4 +8,4 @@ csp:
categories: [web]
csp-checker:
image: ghcr.io/kevinveenbirkenbach/csp-checker
version: 3.0.4
version: 3.0.5
@@ -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
@@ -272,7 +272,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
+1 -1
View File
@@ -49,7 +49,7 @@ bridgy-fed:
swarm:
enabled: true
image: python
version: 3.14-slim
version: 3.15-slim
name: web-app-bridgy-fed
repository: https://github.com/snarfed/bridgy-fed.git
# nocheck: repository-version -- upstream publishes no tags, so the pin is a commit
+6 -6
View File
@@ -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
+1 -1
View File
@@ -51,7 +51,7 @@ keycloak:
shared: true
provides: sso
image: quay.io/keycloak/keycloak
version: "26.8.0"
version: "26.8.1"
name: keycloak
min_storage: 468MB
cpus: "2.0"
+1 -1
View File
@@ -66,7 +66,7 @@ synapse:
element:
healthcheck:
flavor: wget
version: v1.12.30
version: v1.12.31
image: vectorim/element-web
name: matrix-element
ports:
@@ -25,6 +25,7 @@ import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
BASE = os.environ.get("N8N_BASE", "").rstrip("/")
@@ -38,6 +39,7 @@ PROVIDERS = json.loads(os.environ.get("N8N_MCP_PROVIDERS", "[]"))
REST = "/rest"
PUBLIC = "/api/v1"
PAGE = 250
CREDENTIAL_TYPE = "httpBearerAuth"
BEARER_AUTH = "bearerAuth"
CLIENT_PREFIX = "infinito:mcp-client:"
@@ -90,6 +92,103 @@ def call(path, method="GET", payload=None, api_key=None):
return error.code, error.read().decode(errors="replace")
def shape(value):
"""Describe a value by its type and, for a mapping, its keys, never its content.
Args:
value: decoded response fragment.
"""
if isinstance(value, dict):
return f"a mapping with keys {sorted(map(str, value))}"
if isinstance(value, list):
return f"a list of {sorted({type(item).__name__ for item in value})}"
return f"a {type(value).__name__}"
def records(body, what):
"""Return the records of a list response.
Args:
body: decoded body: ``{"data": [...]}``, a bare list, or the paged
``{"data": {"items": [...], ...}}`` of n8n 2.
what: noun naming the collection, for the failure message.
"""
listed = body.get("data", body) if isinstance(body, dict) else body
if isinstance(listed, dict) and "items" in listed:
listed = listed["items"]
if listed is None:
return []
if not isinstance(listed, list) or not all(
isinstance(item, dict) for item in listed
):
sys.exit(f"FAILED listing {what}: expected records, got {shape(listed)}")
return listed
def record(body, what):
"""Return the single record of a create response.
Args:
body: decoded body, either ``{"data": {...}}`` or a bare mapping.
what: noun naming the record, for the failure message.
"""
created = body.get("data", body) if isinstance(body, dict) else body
if not isinstance(created, dict):
sys.exit(f"FAILED creating {what}: expected a record, got {shape(created)}")
return created
def api_keys_labelled(label):
"""Return every API key whose label n8n matches against ``label``, all pages.
Args:
label: text n8n looks for inside each label.
"""
found = []
seen = set()
while True:
query = urllib.parse.urlencode(
{"label": label, "take": PAGE, "skip": len(found)}
)
status, body = call(f"{REST}/api-keys?{query}")
if status != 200:
sys.exit(f"FAILED listing api keys: {status} {body}")
page = records(body, "api keys")
ids = {str(key.get("id")) for key in page}
if page and ids <= seen:
sys.exit(f"FAILED listing api keys: page at skip {len(found)} repeats")
seen |= ids
found.extend(page)
if len(page) < PAGE:
return found
def workflows_named(key, name):
"""Return every workflow n8n matches against ``name``, following ``nextCursor``.
Args:
key: the public-API key.
name: workflow name n8n filters on.
"""
found = []
cursors = set()
query = {"name": name, "limit": PAGE}
while True:
status, body = call(
f"{PUBLIC}/workflows?{urllib.parse.urlencode(query)}", api_key=key
)
if status != 200:
sys.exit(f"FAILED listing workflows: {status} {body}")
found.extend(records(body, "workflows"))
cursor = body.get("nextCursor") if isinstance(body, dict) else None
if not cursor:
return found
if cursor in cursors:
sys.exit("FAILED listing workflows: nextCursor repeats")
cursors.add(cursor)
query = {"name": name, "cursor": cursor}
def login():
status, body = call(
f"{REST}/login",
@@ -106,14 +205,12 @@ def api_key():
n8n returns the usable secret only once, as ``rawApiKey``, and a listed key
is redacted, so the key cannot be carried between runs. It is minted here
and revoked before the run ends; an entry left behind under the managed name
belongs to a run that died and is deleted rather than guessed at.
belongs to a run that died and is deleted rather than guessed at. n8n matches
``label`` as a substring, so the exact match happens here.
"""
status, body = call(f"{REST}/api-keys")
if status != 200:
sys.exit(f"FAILED listing api keys: {status} {body}")
existing = (body or {}).get("data") if isinstance(body, dict) else body
matches = [key for key in existing or [] if key.get("label") == KEY_NAME]
matches = [
key for key in api_keys_labelled(KEY_NAME) if key.get("label") == KEY_NAME
]
if len(matches) > 1:
sys.exit(f"FAILED: {len(matches)} api keys named {KEY_NAME}")
for key in matches:
@@ -136,7 +233,7 @@ def api_key():
)
if status not in (200, 201):
sys.exit(f"FAILED creating api key {KEY_NAME}: {status} {body}")
created = (body or {}).get("data") if isinstance(body, dict) else body
created = record(body, f"api key {KEY_NAME}")
return str(created["rawApiKey"]), str(created["id"])
@@ -174,8 +271,7 @@ def listed_credentials():
status, body = call(f"{REST}/credentials")
if status != 200:
sys.exit(f"FAILED listing credentials: {status} {body}")
existing = (body or {}).get("data") if isinstance(body, dict) else body
return list(existing or [])
return records(body, "credentials")
def credential(provider):
@@ -198,7 +294,7 @@ def credential(provider):
status, body = call(f"{REST}/credentials", method="POST", payload=payload)
if status not in (200, 201):
sys.exit(f"FAILED creating {name}: {status} {body}")
created = (body or {}).get("data") if isinstance(body, dict) else body
created = record(body, name)
return {"id": str(created["id"]), "name": name}, True
status, body = call(
@@ -314,11 +410,11 @@ def managed_workflow(key):
Args:
key: the public-API key.
"""
status, body = call(f"{PUBLIC}/workflows", api_key=key)
if status != 200:
sys.exit(f"FAILED listing workflows: {status} {body}")
existing = (body or {}).get("data") if isinstance(body, dict) else body
matches = [flow for flow in existing or [] if flow.get("name") == WORKFLOW_NAME]
matches = [
flow
for flow in workflows_named(key, WORKFLOW_NAME)
if flow.get("name") == WORKFLOW_NAME
]
if len(matches) > 1:
sys.exit(f"FAILED: {len(matches)} workflows named {WORKFLOW_NAME}")
return matches[0] if matches else None
+117 -17
View File
@@ -19,6 +19,7 @@ import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
BASE = os.environ.get("N8N_BASE", "").rstrip("/")
@@ -32,6 +33,7 @@ CREDENTIAL_NAME = os.environ.get("N8N_MCP_CREDENTIAL", "")
REST = "/rest"
PUBLIC = "/api/v1"
PAGE = 250
TRIGGER_TYPE = "@n8n/n8n-nodes-langchain.mcpTrigger"
TRIGGER_VERSION = 2
TRIGGER_NAME = "Infinito MCP Trigger"
@@ -95,6 +97,103 @@ def call(path, method="GET", payload=None, api_key=None):
return error.code, error.read().decode(errors="replace")
def shape(value):
"""Describe a value by its type and, for a mapping, its keys, never its content.
Args:
value: decoded response fragment.
"""
if isinstance(value, dict):
return f"a mapping with keys {sorted(map(str, value))}"
if isinstance(value, list):
return f"a list of {sorted({type(item).__name__ for item in value})}"
return f"a {type(value).__name__}"
def records(body, what):
"""Return the records of a list response.
Args:
body: decoded body: ``{"data": [...]}``, a bare list, or the paged
``{"data": {"items": [...], ...}}`` of n8n 2.
what: noun naming the collection, for the failure message.
"""
listed = body.get("data", body) if isinstance(body, dict) else body
if isinstance(listed, dict) and "items" in listed:
listed = listed["items"]
if listed is None:
return []
if not isinstance(listed, list) or not all(
isinstance(item, dict) for item in listed
):
sys.exit(f"FAILED listing {what}: expected records, got {shape(listed)}")
return listed
def record(body, what):
"""Return the single record of a create response.
Args:
body: decoded body, either ``{"data": {...}}`` or a bare mapping.
what: noun naming the record, for the failure message.
"""
created = body.get("data", body) if isinstance(body, dict) else body
if not isinstance(created, dict):
sys.exit(f"FAILED creating {what}: expected a record, got {shape(created)}")
return created
def api_keys_labelled(label):
"""Return every API key whose label n8n matches against ``label``, all pages.
Args:
label: text n8n looks for inside each label.
"""
found = []
seen = set()
while True:
query = urllib.parse.urlencode(
{"label": label, "take": PAGE, "skip": len(found)}
)
status, body = call(f"{REST}/api-keys?{query}")
if status != 200:
sys.exit(f"FAILED listing api keys: {status} {body}")
page = records(body, "api keys")
ids = {str(key.get("id")) for key in page}
if page and ids <= seen:
sys.exit(f"FAILED listing api keys: page at skip {len(found)} repeats")
seen |= ids
found.extend(page)
if len(page) < PAGE:
return found
def workflows_named(key, name):
"""Return every workflow n8n matches against ``name``, following ``nextCursor``.
Args:
key: the public-API key.
name: workflow name n8n filters on.
"""
found = []
cursors = set()
query = {"name": name, "limit": PAGE}
while True:
status, body = call(
f"{PUBLIC}/workflows?{urllib.parse.urlencode(query)}", api_key=key
)
if status != 200:
sys.exit(f"FAILED listing workflows: {status} {body}")
found.extend(records(body, "workflows"))
cursor = body.get("nextCursor") if isinstance(body, dict) else None
if not cursor:
return found
if cursor in cursors:
sys.exit("FAILED listing workflows: nextCursor repeats")
cursors.add(cursor)
query = {"name": name, "cursor": cursor}
def login():
status, body = call(
f"{REST}/login",
@@ -111,14 +210,12 @@ def api_key():
n8n returns the usable secret only once, as ``rawApiKey``, and a listed key
is redacted, so the key cannot be carried between runs. It is minted here
and revoked before the run ends; an entry left behind under the managed name
belongs to a run that died and is deleted rather than guessed at.
belongs to a run that died and is deleted rather than guessed at. n8n matches
``label`` as a substring, so the exact match happens here.
"""
status, body = call(f"{REST}/api-keys")
if status != 200:
sys.exit(f"FAILED listing api keys: {status} {body}")
existing = (body or {}).get("data") if isinstance(body, dict) else body
matches = [key for key in existing or [] if key.get("label") == KEY_NAME]
matches = [
key for key in api_keys_labelled(KEY_NAME) if key.get("label") == KEY_NAME
]
if len(matches) > 1:
sys.exit(f"FAILED: {len(matches)} api keys named {KEY_NAME}")
for key in matches:
@@ -134,13 +231,14 @@ def api_key():
"workflow:read",
"workflow:update",
"workflow:list",
"workflow:activate",
],
"expiresAt": None,
},
)
if status not in (200, 201):
sys.exit(f"FAILED creating api key {KEY_NAME}: {status} {body}")
created = (body or {}).get("data") if isinstance(body, dict) else body
created = record(body, f"api key {KEY_NAME}")
return str(created["rawApiKey"]), str(created["id"])
@@ -166,8 +264,11 @@ def bearer_credential():
if status != 200:
sys.exit(f"FAILED listing credentials: {status} {body}")
existing = (body or {}).get("data") if isinstance(body, dict) else body
matches = [item for item in existing or [] if item.get("name") == CREDENTIAL_NAME]
matches = [
item
for item in records(body, "credentials")
if item.get("name") == CREDENTIAL_NAME
]
if len(matches) > 1:
sys.exit(f"FAILED: {len(matches)} credentials named {CREDENTIAL_NAME}")
@@ -180,7 +281,7 @@ def bearer_credential():
status, body = call(f"{REST}/credentials", method="POST", payload=payload)
if status not in (200, 201):
sys.exit(f"FAILED creating {CREDENTIAL_NAME}: {status} {body}")
created = (body or {}).get("data") if isinstance(body, dict) else body
created = record(body, CREDENTIAL_NAME)
return {"id": str(created["id"]), "name": CREDENTIAL_NAME}, True
status, body = call(
@@ -264,12 +365,11 @@ def upsert_workflow(key, credential):
key: the public-API key.
credential: ``{"id", "name"}`` reference to the managed bearer credential.
"""
status, body = call(f"{PUBLIC}/workflows", api_key=key)
if status != 200:
sys.exit(f"FAILED listing workflows: {status} {body}")
existing = (body or {}).get("data") if isinstance(body, dict) else body
matches = [flow for flow in existing or [] if flow.get("name") == WORKFLOW_NAME]
matches = [
flow
for flow in workflows_named(key, WORKFLOW_NAME)
if flow.get("name") == WORKFLOW_NAME
]
if len(matches) > 1:
sys.exit(f"FAILED: {len(matches)} workflows named {WORKFLOW_NAME}")
+1 -1
View File
@@ -73,7 +73,7 @@ n8n:
backup:
no_stop_required: false
image: docker.n8n.io/n8nio/n8n
version: "2.42.3"
version: "2.44.0"
update:
type: registry_tags
image: docker.n8n.io/n8nio/n8n
@@ -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,
@@ -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");
@@ -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");
@@ -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");
@@ -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");
@@ -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");
@@ -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");
@@ -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");
@@ -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");
+1 -1
View File
@@ -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
+1 -1
View File
@@ -43,7 +43,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
+22 -3
View File
@@ -1,10 +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_ini:
infinito.session.handler.php_native:
class: SessionHandler
session.handler.legacy_bridge:
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_ini"
- "@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"
@@ -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
+1 -1
View File
@@ -20,7 +20,7 @@ session_config:
mode: "0444"
mounts:
- service: suitecrm
target: "{{ ['/var/www/html/extensions', SOFTWARE_DOMAIN | replace('.', '-'), 'config/session.yaml'] | path_join }}"
target: "{{ ['/var/www/html/extensions', SOFTWARE_DOMAIN | replace('.', '-'), 'config/packages/session.yaml'] | path_join }}"
ldap_config:
type: config
@@ -6,14 +6,6 @@
notify:
- compose-up
- name: "🚀 Deploy the Symfony session handler override"
copy:
src: session.yaml
dest: "{{ SUITECRM_SESSION_CONFIG_HOST }}"
mode: "0444"
notify:
- compose-up
- name: "📄 Render SuiteCRM LDAP mapping"
stack_host_template:
src: "ldap.yaml.j2"
@@ -22,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 }}"
@@ -42,16 +42,6 @@
retries: 300
delay: 5
- name: "🧹 Rebuild the Symfony container when the session handler override is newer"
shell: >
container exec --user {{ SUITECRM_WEB_USER }} {{ SUITECRM_CONTAINER_ADDRESS }}
sh -c 'cd {{ SUITECRM_APP_DIR }}
&& if [ -f cache/prod/App_KernelProdContainer.php ]
&& [ {{ SUITECRM_SESSION_CONFIG_DOCKER }} -nt cache/prod/App_KernelProdContainer.php ];
then php bin/console cache:clear --no-warmup && php bin/console cache:warmup && echo CONTAINER_REBUILT; fi'
register: suitecrm_session_container
changed_when: "'CONTAINER_REBUILT' in suitecrm_session_container.stdout"
- name: "🔎 List SuiteCRM log paths the web user cannot write"
shell: >
container exec --user {{ SUITECRM_WEB_USER }} {{ SUITECRM_CONTAINER_ADDRESS }}
+1 -2
View File
@@ -7,8 +7,6 @@ swarm_stack_converge_retries: 600
SUITECRM_WELL_SOFTWARE_NAME: "{{ SOFTWARE_DOMAIN | replace('.', '-') }}"
SUITECRM_PHP_CONFIG_HOST: "{{ [ lookup('container', application_id, 'directories.config'), 'php.ini'] | path_join }}"
SUITECRM_SESSION_CONFIG_HOST: "{{ [ lookup('container', application_id, 'directories.config'), 'session.yaml'] | path_join }}"
SUITECRM_SESSION_CONFIG_DOCKER: "{{ [SUITECRM_APP_DIR, 'extensions', SUITECRM_WELL_SOFTWARE_NAME, 'config/session.yaml'] | path_join }}"
SUITECRM_BASE_IMAGE: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.image') }}"
SUITECRM_BASE_VERSION: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.version') }}"
@@ -34,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"
+1 -1
View File
@@ -33,7 +33,7 @@ onlyoffice:
enabled: true # nocheck: dynamic-flag
shared: true
image: onlyoffice/documentserver
version: 9.4.0
version: 10.0.0
name: onlyoffice
min_storage: 1468MB
cpus: 2
@@ -3,6 +3,7 @@ from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from cli.meta.ci.report_failures import (
Failure,
@@ -10,6 +11,7 @@ from cli.meta.ci.report_failures import (
decisive_excerpt,
failed_roles,
issue_body,
list_jobs,
)
@@ -80,6 +82,19 @@ class TestReportFailures(unittest.TestCase):
artifact_name("web-app-x", Failure("compose", "0", True, "fedora", "zfs")),
)
def test_list_jobs_reads_one_job_per_line_across_pages(self) -> None:
pages = (
'{"name": "deploy a", "conclusion": "success"}\n'
'{"name": "deploy b", "conclusion": "failure"}\n'
)
with patch("cli.meta.ci.report_failures._gh", return_value=pages):
jobs = list_jobs("1", "owner/repo")
self.assertEqual([job["name"] for job in jobs], ["deploy a", "deploy b"])
def test_list_jobs_of_an_empty_listing_is_empty(self) -> None:
with patch("cli.meta.ci.report_failures._gh", return_value=""):
self.assertEqual(list_jobs("1", "owner/repo"), [])
def test_issue_body_lists_failures_and_run(self) -> None:
body = issue_body(
"web-app-xwiki",
View File
Whitespace-only changes.
+45
View File
@@ -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()
@@ -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()
@@ -8,10 +8,26 @@ import io
import json
import unittest
from unittest.mock import patch
from urllib.parse import parse_qs, urlsplit
from . import PROJECT_ROOT
SCRIPT_PATH = PROJECT_ROOT / "roles/web-app-n8n/files/python/provision/client.py"
PAGE_SIZE = 10
WORKFLOW_PAGE_SIZE = 100
MAX_PAGE_SIZE = 250
def paged(items, offset, limit):
"""Return one page the way n8n slices a listing.
Args:
items: every record the listing holds.
offset: index of the first record on the page.
limit: requested page size, capped like n8n caps it.
"""
return items[offset : offset + min(limit, MAX_PAGE_SIZE)]
PROVIDER = {
"id": "web-app-gitea",
@@ -78,16 +94,34 @@ class FakeApi:
self.deleted_credentials.append(dead)
self.credentials = [c for c in self.credentials if c["id"] != dead]
return 200, {"success": True}
if path == "/rest/api-keys" and method == "GET":
return 200, {"data": [dict(key) for key in self.keys]}
if path.startswith("/rest/api-keys?") and method == "GET":
query = parse_qs(urlsplit(path).query)
label = query["label"][0].lower()
items = [dict(key) for key in self.keys if label in key["label"].lower()]
skip = int(query.get("skip", ["0"])[0])
take = int(query.get("take", [str(PAGE_SIZE)])[0])
return 200, {"data": {"items": paged(items, skip, take)}}
if path == "/rest/api-keys" and method == "POST":
self.keys = [{"id": "k1", "label": payload["label"]}]
return 200, {"data": {"id": "k1", "rawApiKey": "n8n-raw-key"}}
if path.startswith("/rest/api-keys/") and method == "DELETE":
self.deleted_keys.append(path.rsplit("/", 1)[-1])
return 200, {"success": True}
if path == "/api/v1/workflows" and method == "GET":
return 200, {"data": [dict(flow) for flow in self.workflows]}
if path.split("?")[0] == "/api/v1/workflows" and method == "GET":
query = parse_qs(urlsplit(path).query)
name = query.get("name", [""])[0]
hits = [dict(flow) for flow in self.workflows if name in flow["name"]]
cursor = query.get("cursor", [f"0:{WORKFLOW_PAGE_SIZE}"])[0].split(":")
offset, limit = (int(cursor[0]), int(cursor[1]))
if "limit" in query:
limit = int(query["limit"][0])
page = paged(hits, offset, limit)
following = offset + len(page)
more = following < len(hits)
return 200, {
"data": page,
"nextCursor": f"{following}:{limit}" if more else None,
}
if path == "/api/v1/workflows" and method == "POST":
self.created_workflows.append(payload)
return 200, dict(payload, id="w1", active=False)
@@ -179,6 +213,100 @@ class TestProvisionClient(unittest.TestCase):
module.main()
self.assertEqual(["k1"], api.deleted_keys)
def test_a_stale_managed_key_behind_a_full_page_is_still_replaced(self) -> None:
module = load_script()
humans = [{"id": f"h{n}", "label": f"human {n}"} for n in range(PAGE_SIZE)]
api = FakeApi(keys=[*humans, {"id": "old", "label": "infinito:mcp"}])
with patch.object(module, "call", api):
module.main()
self.assertEqual(["old", "k1"], api.deleted_keys)
def test_a_stale_managed_key_behind_a_full_filtered_page_is_still_replaced(
self,
) -> None:
module = load_script()
near = [
{"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"}
for n in range(MAX_PAGE_SIZE)
]
api = FakeApi(keys=[*near, {"id": "old", "label": "infinito:mcp"}])
with patch.object(module, "call", api):
module.main()
self.assertEqual(["old", "k1"], api.deleted_keys)
def test_a_managed_workflow_on_a_later_page_is_updated_not_duplicated(
self,
) -> None:
module = load_script()
near = [
{"id": f"n{n}", "name": f"infinito:mcp-agent copy {n}"}
for n in range(MAX_PAGE_SIZE)
]
managed = {"id": "w9", "name": "infinito:mcp-agent"}
api = FakeApi(workflows=[*near, managed], credentials=[dict(REFERENCE)])
with patch.object(module, "call", api):
module.main()
self.assertEqual([], api.created_workflows)
self.assertEqual(1, len(api.updated_workflows))
def test_a_repeated_workflow_cursor_aborts_rather_than_loops(self) -> None:
module = load_script()
api = FakeApi()
listings = []
def stuck(path, method="GET", payload=None, api_key=None):
if path.startswith("/api/v1/workflows?"):
listings.append(path)
if len(listings) > 3:
raise AssertionError("the workflow listing never ends")
return 200, {"data": [], "nextCursor": "same"}
return api(path, method, payload, api_key)
with (
patch.object(module, "call", stuck),
self.assertRaises(SystemExit) as exit_,
):
module.main()
self.assertIn("nextCursor repeats", str(exit_.exception))
def test_a_key_listing_that_ignores_skip_aborts_rather_than_loops(self) -> None:
module = load_script()
api = FakeApi()
full = [
{"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"}
for n in range(MAX_PAGE_SIZE)
]
listings = []
def frozen(path, method="GET", payload=None, api_key=None):
if path.startswith("/rest/api-keys?"):
listings.append(path)
if len(listings) > 3:
raise AssertionError("the key listing never ends")
return 200, {"data": {"items": full}}
return api(path, method, payload, api_key)
with (
patch.object(module, "call", frozen),
self.assertRaises(SystemExit) as exit_,
):
module.main()
self.assertIn("repeats", str(exit_.exception))
def test_an_unknown_listing_shape_names_its_keys_but_never_a_value(self) -> None:
module = load_script()
api = FakeApi()
def odd(path, method="GET", payload=None, api_key=None):
if path == "/rest/credentials" and method == "GET":
return 200, {"data": {"token": "t0ps3cret"}}
return api(path, method, payload, api_key)
with patch.object(module, "call", odd), self.assertRaises(SystemExit) as exit_:
module.main()
self.assertIn("['token']", str(exit_.exception))
self.assertNotIn("t0ps3cret", str(exit_.exception))
def test_a_rotated_provider_token_replaces_the_stored_one(self) -> None:
module = load_script()
api = FakeApi(credentials=[dict(REFERENCE)])
@@ -8,10 +8,26 @@ import io
import json
import unittest
from unittest.mock import patch
from urllib.parse import parse_qs, urlsplit
from . import PROJECT_ROOT
SCRIPT_PATH = PROJECT_ROOT / "roles/web-app-n8n/files/python/provision/mcp.py"
PAGE_SIZE = 10
WORKFLOW_PAGE_SIZE = 100
MAX_PAGE_SIZE = 250
def paged(items, offset, limit):
"""Return one page the way n8n slices a listing.
Args:
items: every record the listing holds.
offset: index of the first record on the page.
limit: requested page size, capped like n8n caps it.
"""
return items[offset : offset + min(limit, MAX_PAGE_SIZE)]
ENV = {
"N8N_BASE": "http://n8n:5678",
@@ -51,6 +67,7 @@ class FakeApi:
self.deleted_keys = []
self.logins = 0
self.minted = 0
self.scopes = set()
def __call__(self, path, method="GET", payload=None, api_key=None):
if path == "/rest/login":
@@ -72,16 +89,35 @@ class FakeApi:
self.deleted_credentials.append(dead)
self.credentials = [c for c in self.credentials if c["id"] != dead]
return 200, {"success": True}
if path == "/rest/api-keys" and method == "GET":
return 200, {"data": [dict(key) for key in self.keys]}
if path.startswith("/rest/api-keys?") and method == "GET":
query = parse_qs(urlsplit(path).query)
label = query["label"][0].lower()
items = [dict(key) for key in self.keys if label in key["label"].lower()]
skip = int(query.get("skip", ["0"])[0])
take = int(query.get("take", [str(PAGE_SIZE)])[0])
return 200, {"data": {"items": paged(items, skip, take)}}
if path == "/rest/api-keys" and method == "POST":
self.keys = [{"id": "k1", "label": payload["label"]}]
self.scopes = set(payload["scopes"])
return 200, {"data": {"id": "k1", "rawApiKey": "n8n-raw-key"}}
if path.startswith("/rest/api-keys/") and method == "DELETE":
self.deleted_keys.append(path.rsplit("/", 1)[-1])
return 200, {"success": True}
if path == "/api/v1/workflows" and method == "GET":
return 200, {"data": [dict(flow) for flow in self.workflows]}
if path.split("?")[0] == "/api/v1/workflows" and method == "GET":
query = parse_qs(urlsplit(path).query)
name = query.get("name", [""])[0]
hits = [dict(flow) for flow in self.workflows if name in flow["name"]]
cursor = query.get("cursor", [f"0:{WORKFLOW_PAGE_SIZE}"])[0].split(":")
offset, limit = (int(cursor[0]), int(cursor[1]))
if "limit" in query:
limit = int(query["limit"][0])
page = paged(hits, offset, limit)
following = offset + len(page)
more = following < len(hits)
return 200, {
"data": page,
"nextCursor": f"{following}:{limit}" if more else None,
}
if path == "/api/v1/workflows" and method == "POST":
self.created_workflows.append(payload)
return 200, dict(payload, id="w1", active=False)
@@ -89,6 +125,8 @@ class FakeApi:
self.updated_workflows.append(payload)
return 200, payload
if path.endswith("/activate") and method == "POST":
if "workflow:activate" not in self.scopes:
return 403, '{"message":"Forbidden"}'
self.activated_workflows.append(path.split("/")[-2])
return 200, {"active": True}
if path.startswith("/api/v1/workflows/") and method == "DELETE":
@@ -184,6 +222,120 @@ class TestProvisionMcp(unittest.TestCase):
module.main()
self.assertEqual(["old", "k1"], api.deleted_keys)
def test_a_stale_managed_key_behind_a_full_page_is_still_replaced(self) -> None:
module = load_script()
humans = [{"id": f"h{n}", "label": f"human {n}"} for n in range(PAGE_SIZE)]
api = FakeApi(keys=[*humans, {"id": "old", "label": "infinito:mcp"}])
with patch.object(module, "call", api):
module.main()
self.assertEqual(["old", "k1"], api.deleted_keys)
def test_an_unknown_listing_shape_names_its_keys_but_never_a_value(self) -> None:
module = load_script()
api = FakeApi()
def odd(path, method="GET", payload=None, api_key=None):
if path.startswith("/rest/api-keys?"):
return 200, {"data": {"secret": "t0ps3cret"}}
return api(path, method, payload, api_key)
with patch.object(module, "call", odd), self.assertRaises(SystemExit) as exit_:
module.main()
self.assertIn("['secret']", str(exit_.exception))
self.assertNotIn("t0ps3cret", str(exit_.exception))
def test_an_unpaged_listing_of_n8n_1_still_finds_the_stale_key(self) -> None:
module = load_script()
api = FakeApi()
def unpaged(path, method="GET", payload=None, api_key=None):
if path.startswith("/rest/api-keys?"):
return 200, {"data": [{"id": "old", "label": "infinito:mcp"}]}
return api(path, method, payload, api_key)
with patch.object(module, "call", unpaged):
module.main()
self.assertEqual(["old", "k1"], api.deleted_keys)
def test_a_stale_managed_key_behind_a_full_filtered_page_is_still_replaced(
self,
) -> None:
module = load_script()
near = [
{"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"}
for n in range(MAX_PAGE_SIZE)
]
api = FakeApi(keys=[*near, {"id": "old", "label": "infinito:mcp"}])
with patch.object(module, "call", api):
module.main()
self.assertEqual(["old", "k1"], api.deleted_keys)
def test_a_managed_workflow_on_a_later_page_is_updated_not_duplicated(
self,
) -> None:
module = load_script()
near = [
{"id": f"n{n}", "name": f"infinito:mcp-server copy {n}"}
for n in range(MAX_PAGE_SIZE)
]
managed = {"id": "w9", "name": "infinito:mcp-server", "active": True}
api = FakeApi(workflows=[*near, managed])
with patch.object(module, "call", api):
module.main()
self.assertEqual([], api.created_workflows)
self.assertEqual(1, len(api.updated_workflows))
def test_a_repeated_workflow_cursor_aborts_rather_than_loops(self) -> None:
module = load_script()
api = FakeApi()
listings = []
def stuck(path, method="GET", payload=None, api_key=None):
if path.startswith("/api/v1/workflows?"):
listings.append(path)
if len(listings) > 3:
raise AssertionError("the workflow listing never ends")
return 200, {"data": [], "nextCursor": "same"}
return api(path, method, payload, api_key)
with (
patch.object(module, "call", stuck),
self.assertRaises(SystemExit) as exit_,
):
module.main()
self.assertIn("nextCursor repeats", str(exit_.exception))
def test_a_key_listing_that_ignores_skip_aborts_rather_than_loops(self) -> None:
module = load_script()
api = FakeApi()
full = [
{"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"}
for n in range(MAX_PAGE_SIZE)
]
listings = []
def frozen(path, method="GET", payload=None, api_key=None):
if path.startswith("/rest/api-keys?"):
listings.append(path)
if len(listings) > 3:
raise AssertionError("the key listing never ends")
return 200, {"data": {"items": full}}
return api(path, method, payload, api_key)
with (
patch.object(module, "call", frozen),
self.assertRaises(SystemExit) as exit_,
):
module.main()
self.assertIn("repeats", str(exit_.exception))
def test_a_key_that_only_contains_the_managed_name_is_left_alone(self) -> None:
module = load_script()
api = FakeApi(keys=[{"id": "near", "label": "infinito:mcp-legacy"}])
with patch.object(module, "call", api):
module.main()
self.assertEqual(["k1"], api.deleted_keys)
def test_the_run_leaves_no_api_key_behind(self) -> None:
module = load_script()
api = FakeApi()
@@ -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
)