diff --git a/.bake.toml b/.bake.toml new file mode 100644 index 0000000000..2adc00dfd2 --- /dev/null +++ b/.bake.toml @@ -0,0 +1,2 @@ +[formatter] +ensure_final_newline = true diff --git a/roles/svc-net-tor/files/test/onion_ports.py b/roles/svc-net-tor/files/test/onion_ports.py index adb6a81c66..7b06449a63 100755 --- a/roles/svc-net-tor/files/test/onion_ports.py +++ b/roles/svc-net-tor/files/test/onion_ports.py @@ -25,12 +25,13 @@ Env (rendered into test.env from templates/test.env.j2): ONION_HOST the node onion host ONION_PORTS space-separated forwarded ports; empty means there is nothing to check - ONION_PORT_TIMEOUT seconds per connect attempt. 60 for the same reason the - domain probe uses it: Tor's SocksTimeout default is 120s - and abandoning earlier is destructive, because the next - attempt opens a fresh SOCKS connection and throws away - the descriptor fetches and rendezvous retries the last - one was still working through + ONION_PORT_TIMEOUT seconds per connect attempt, above Tor's SocksTimeout + (120s by default; torrc does not set it). Every attempt + builds a fresh rendezvous, which Tor keeps working on + until SocksTimeout and then ends itself with reply 0x06 + and a "Giving up" notice in its log. An attempt abandoned + earlier throws away a rendezvous Tor may still complete + and leaves Tor's log without a trace of the failure ONION_PORT_RETRIES passes over the failing ports. Below the domain probe's RETRIES because this phase multiplies by the port count, and meta/tests.yml caps the whole CLI test @@ -148,7 +149,7 @@ def main() -> int: retries = int(_required("ONION_PORT_RETRIES", "passes over the failing ports")) sleep_seconds = float(_required("SLEEP_SECONDS", "wait between passes")) timeout = float( - _required("ONION_PORT_TIMEOUT", "seconds per connect, flavor-dependent") + _required("ONION_PORT_TIMEOUT", "seconds per connect, above Tor's SocksTimeout") ) ports = [int(entry) for entry in os.environ.get("ONION_PORTS", "").split()] diff --git a/roles/svc-net-tor/meta/tests.yml b/roles/svc-net-tor/meta/tests.yml index 272c1e148d..036f2dec6c 100644 --- a/roles/svc-net-tor/meta/tests.yml +++ b/roles/svc-net-tor/meta/tests.yml @@ -1,3 +1,3 @@ --- cli: - timeout: 7200 + timeout: 9000 diff --git a/roles/svc-net-tor/templates/test.env.j2 b/roles/svc-net-tor/templates/test.env.j2 index 51288356b2..0788bce017 100644 --- a/roles/svc-net-tor/templates/test.env.j2 +++ b/roles/svc-net-tor/templates/test.env.j2 @@ -2,7 +2,7 @@ NGINX_SERVERS_DIR={{ lookup('nginx', 'directories.configuration.servers') }} ONION_HOST={{ lookup('config', 'svc-net-tor', 'services.tor.node') }} ONION_PORTS={{ lookup('tor_ports') | map(attribute='onion_port') | unique | sort | join(' ') | dotenv_quote }} ONION_PORT_RETRIES=2 -ONION_PORT_TIMEOUT=60 +ONION_PORT_TIMEOUT=130 RETRIES=5 SLEEP_SECONDS=10 TOR_CONTAINER={{ lookup('container_address', application_id, 'tor') }} diff --git a/roles/web-app-fider/tasks/01_setup_oidc.yml b/roles/web-app-fider/tasks/01_setup_oidc.yml index 2985fc2793..fe5cc89d09 100644 --- a/roles/web-app-fider/tasks/01_setup_oidc.yml +++ b/roles/web-app-fider/tasks/01_setup_oidc.yml @@ -41,19 +41,29 @@ body_format: json body: "{{ FIDER_TENANT_BOOTSTRAP_BODY }}" status_code: [200, 201] + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" when: - DEPLOYMENT_MODE == 'compose' - (fider_tenant_count.rows[0][0] | int) == 0 -- name: "🥾 Fider | Bootstrap initial tenant via API (swarm, routing-mesh port)" - uri_retry: - url: "http://127.0.0.1:{{ lookup('config', application_id, 'services.fider.ports.local.http') }}/_api/tenants" - method: POST - body_format: json - body: "{{ FIDER_TENANT_BOOTSTRAP_BODY }}" - status_code: [200, 201] +- name: "🥾 Fider | Bootstrap initial tenant via API (swarm in-container)" + ansible.builtin.shell: + cmd: >- # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node + container exec -i {{ FIDER_LOCAL_CID | quote }} + timeout {{ lookup('timeout', 30) | int }} bash -c + 'set -e; body=$(cat); length=$(printf %s "$body" | wc -c); + exec 3<>"/dev/tcp/127.0.0.1/$1" || exit 75; + printf "POST /_api/tenants HTTP/1.0\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s" "$length" "$body" >&3; + read -r _ status _ <&3; printf %s "$status"; + case $status in 5*) exit 75;; esac' _ {{ FIDER_INTERNAL_PORT | quote }} + stdin: "{{ FIDER_TENANT_BOOTSTRAP_BODY | to_json }}" register: fider_tenant_bootstrap changed_when: true + failed_when: fider_tenant_bootstrap.rc != 0 or (fider_tenant_bootstrap.stdout | trim) not in ['200', '201'] + retries: 30 + delay: 2 + until: fider_tenant_bootstrap.rc != 75 + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" delegate_to: "{{ FIDER_HOST_NODE }}" when: - DEPLOYMENT_MODE == 'swarm' diff --git a/roles/web-app-fider/vars/main.yml b/roles/web-app-fider/vars/main.yml index 3cd44bb98f..2297ea2a8e 100644 --- a/roles/web-app-fider/vars/main.yml +++ b/roles/web-app-fider/vars/main.yml @@ -3,6 +3,7 @@ application_id: "web-app-fider" FIDER_CONTAINER: "{{ lookup('config', application_id, 'services.fider.name') }}" +FIDER_INTERNAL_PORT: "{{ lookup('config', application_id, 'services.fider.ports.internal.http') }}" FIDER_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') }}" @@ -20,7 +21,7 @@ FIDER_MCP_PORT: "{{ lookup('config', application_id, 'services.fidermcp.ports.in FIDER_MCP_BEARER: "{{ lookup('config', application_id, 'secrets.credentials.mcp_bearer') }}" FIDER_MCP_INSTANCE_DIR: "{{ [lookup('container', application_id, 'directories.instance'), 'mcp'] | path_join }}" FIDER_MCP_UPSTREAM_HOST: "{{ lookup('config', application_id, 'services.fider.name') }}" -FIDER_MCP_UPSTREAM_PORT: "{{ lookup('config', application_id, 'services.fider.ports.internal.http') }}" +FIDER_MCP_UPSTREAM_PORT: "{{ FIDER_INTERNAL_PORT }}" FIDER_MCP_UPSTREAM_URL: "http://{{ FIDER_MCP_UPSTREAM_HOST }}:{{ FIDER_MCP_UPSTREAM_PORT }}" FIDER_MCP_CONTRACT: provider: "{{ application_id }}" diff --git a/roles/web-app-gitea/tasks/01_manager_ops.yml b/roles/web-app-gitea/tasks/01_manager_ops.yml index 2eb113d931..9c4754fe17 100644 --- a/roles/web-app-gitea/tasks/01_manager_ops.yml +++ b/roles/web-app-gitea/tasks/01_manager_ops.yml @@ -1,11 +1,4 @@ --- -- name: ⏳ Wait for Gitea HTTP endpoint - wait_for: - host: "{{ DOCKER_REACH_HOST }}" - port: "{{ lookup('config', application_id, 'services.gitea.ports.local.http') }}" - delay: 5 - timeout: "{{ lookup('timeout', 300) | int }}" - - name: "🔍 Resolve {{ application_id }} hosting node + container id" ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}" vars: @@ -13,6 +6,17 @@ resolve_output_prefix: GITEA resolve_compose_cid: "{{ GITEA_CONTAINER_ADDRESS }}" +- name: ⏳ Wait for Gitea HTTP endpoint + ansible.builtin.shell: >- # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node + container exec {{ GITEA_LOCAL_CID | quote }} + {{ 10 | curl(retries=0) }} -S -o /dev/null {{ GITEA_LOOPBACK_VERSION_URL | quote }} + delegate_to: "{{ GITEA_HOST_NODE }}" + register: gitea_http_ready + changed_when: false + retries: "{{ ((lookup('timeout', 300) | int) / 5) | round(0, 'ceil') | int }}" + delay: 5 + until: gitea_http_ready.rc == 0 + - name: "⏳ Wait for Gitea app.ini to be present after redeploy" shell: | # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node container exec -i --user {{ GITEA_USER }} {{ GITEA_LOCAL_CID | quote }} \ @@ -48,13 +52,13 @@ failed_when: create_admin.rc != 0 and 'user already exists' not in create_admin.stderr - name: "⏳ Wait until Gitea setup and migrations are ready" - uri_retry: - url: "http://{{ DOCKER_REACH_HOST }}:{{ lookup('config', application_id, 'services.gitea.ports.local.http') }}/api/v1/version" - method: GET - status_code: 200 - return_content: false + ansible.builtin.shell: >- # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node + container exec {{ GITEA_LOCAL_CID | quote }} + {{ 10 | curl(retries=0) }} -fS -o /dev/null {{ GITEA_LOOPBACK_VERSION_URL | quote }} + delegate_to: "{{ GITEA_HOST_NODE }}" register: gitea_ready - until: gitea_ready.status == 200 + changed_when: false + until: gitea_ready.rc == 0 retries: 20 delay: 5 when: (GITEA_OIDC_ENABLED | bool) or (GITEA_LDAP_ENABLED | bool) diff --git a/roles/web-app-gitea/vars/main.yml b/roles/web-app-gitea/vars/main.yml index 7b36c9e8e4..9282a3dc5f 100644 --- a/roles/web-app-gitea/vars/main.yml +++ b/roles/web-app-gitea/vars/main.yml @@ -22,6 +22,7 @@ GITEA_CONTAINER: "{{ lookup('config', application_id, 'services.gitea.name') }}" GITEA_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, entity_name) }}" GITEA_USER: "git" GITEA_CONFIG: "/data/gitea/conf/app.ini" +GITEA_LOOPBACK_VERSION_URL: "http://127.0.0.1:{{ lookup('config', application_id, 'services.gitea.ports.internal.http') }}/api/v1/version" GITEA_REDIS_ENABLED: "{{ lookup('config', application_id, 'services.redis.enabled') }}" GITEA_REDIS_ADDRESS: "{{ lookup('engine', 'redis', application_id, 'host') }}:{{ lookup('engine', 'redis', application_id, 'port') }}" diff --git a/roles/web-app-shopware/tasks/02_manager_ops.yml b/roles/web-app-shopware/tasks/02_manager_ops.yml index b8b9040167..d49edced27 100644 --- a/roles/web-app-shopware/tasks/02_manager_ops.yml +++ b/roles/web-app-shopware/tasks/02_manager_ops.yml @@ -37,13 +37,6 @@ - name: "🚿 Flush compose handlers" meta: flush_handlers -- name: ⏳ Wait for Shopware HTTP endpoint - wait_for: - host: "{{ DOCKER_REACH_HOST }}" - port: "{{ lookup('config', application_id, 'services.shopware.ports.local.http') }}" - delay: 5 - timeout: "{{ lookup('timeout', 300) | int }}" - - name: "🔍 Resolve Shopware web hosting node + container id" ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}" vars: @@ -53,6 +46,18 @@ resolve_converge_retries: 120 resolve_converge_delay: 5 +- name: ⏳ Wait for Shopware HTTP endpoint + ansible.builtin.shell: >- # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node + container exec {{ SHOPWARE_WEB_LOCAL_CID | quote }} + sh -c 'timeout "$2" wget -q --spider "$1/robots.txt" || timeout "$2" wget -q --spider "$1/"' + _ {{ SHOPWARE_API_BASE | quote }} {{ lookup('timeout', 10) | int }} + delegate_to: "{{ SHOPWARE_WEB_HOST_NODE }}" + register: shopware_http_ready + changed_when: false + retries: "{{ ((lookup('timeout', 300) | int) / 5) | round(0, 'ceil') | int }}" + delay: 5 + until: shopware_http_ready.rc == 0 + - name: "✅ Ensure admin user exists with correct password" ansible.builtin.include_tasks: file: 01_admin.yml diff --git a/scripts/lint/autoformat.sh b/scripts/lint/autoformat.sh index 907e51eaee..1a4fa210a3 100755 --- a/scripts/lint/autoformat.sh +++ b/scripts/lint/autoformat.sh @@ -139,8 +139,8 @@ run_mbake() { return 0 fi local rc=0 - mbake format Makefile >/dev/null 2>&1 || rc=$? - [[ $rc -eq 0 ]] && { mbake format Makefile >/dev/null 2>&1 || rc=$?; } + mbake format --config .bake.toml Makefile >/dev/null 2>&1 || rc=$? + [[ $rc -eq 0 ]] && { mbake format --config .bake.toml Makefile >/dev/null 2>&1 || rc=$?; } if [[ $rc -eq 0 ]]; then write_status mbake OK else diff --git a/scripts/lint/makefile.sh b/scripts/lint/makefile.sh index 84e3b27f7e..07819375df 100755 --- a/scripts/lint/makefile.sh +++ b/scripts/lint/makefile.sh @@ -17,4 +17,4 @@ if ! command -v mbake >/dev/null 2>&1; then exit 127 fi -mbake format --check Makefile +mbake format --check --config .bake.toml Makefile diff --git a/tests/unit/python/roles/svc-net-tor/test_onion_port_window.py b/tests/unit/python/roles/svc-net-tor/test_onion_port_window.py new file mode 100644 index 0000000000..0072ac161e --- /dev/null +++ b/tests/unit/python/roles/svc-net-tor/test_onion_port_window.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import re +import unittest + +from utils.cache.files import read_text + +from . import PROJECT_ROOT + +ROLE = PROJECT_ROOT / "roles" / "svc-net-tor" +TEST_ENV = ROLE / "templates" / "test.env.j2" +TORRC = ROLE / "templates" / "torrc.j2" +TOR_DEFAULT_SOCKS_TIMEOUT = 120 + + +class TestOnionPortWindow(unittest.TestCase): + def test_an_attempt_outlasts_tors_socks_timeout(self) -> None: + self.assertNotRegex( + read_text(str(TORRC)), + r"(?m)^SocksTimeout\b", + "torrc now sets SocksTimeout; ONION_PORT_TIMEOUT has to be derived " + "from it instead of from Tor's default", + ) + declared = re.search( + r"(?m)^ONION_PORT_TIMEOUT=(\d+)$", read_text(str(TEST_ENV)) + ) + self.assertIsNotNone(declared, "test.env.j2 declares no ONION_PORT_TIMEOUT") + self.assertGreater( + int(declared.group(1)), + TOR_DEFAULT_SOCKS_TIMEOUT, + "a port probe attempt that ends before Tor's SocksTimeout walks away " + "from a rendezvous Tor is still building, and Tor never reaches its " + "own verdict or logs it", + ) + + +if __name__ == "__main__": + unittest.main()