mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 20:26:33 +00:00
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:
commit
8c82e5262e
42 files changed
+829
-120
No files matched your search
@@ -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.")
|
||||
|
||||
Vendored
+11
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Generated
+12
-12
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 [""]
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Whitespace-only changes.
+45
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user