From 95f60411f9c020411969207aea56e959b24fc777 Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Wed, 12 Aug 2026 11:04:14 +0200 Subject: [PATCH 01/16] Add deployment contract validation Catch broken workflow references and unsupported Compose syntax before deployment. Co-authored-by: Cursor --- .github/workflows/ci.yml | 32 +++++++++++ docker-compose.yml | 3 +- tests/test_repository.py | 109 ++++++++++++++++++++++++++++++++++++++ tests/validate-compose.sh | 26 +++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/test_repository.py create mode 100755 tests/validate-compose.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..78fe559 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate repository manifests + run: python3 -m unittest discover -s tests -p 'test_*.py' + + - name: Validate shell scripts + run: | + bash -n scripts/*.sh tests/*.sh + shellcheck scripts/*.sh tests/*.sh + + - name: Render Compose configuration + run: bash tests/validate-compose.sh diff --git a/docker-compose.yml b/docker-compose.yml index 008a8e8..ae7be68 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,8 +56,7 @@ services: - "${N8N_HOST_PORT:-5678}:5678" # Password hash written by ./scripts/ensure-n8n-owner.sh (bcrypt; $ escaped as $$). env_file: - - path: secrets/n8n_owner.env - required: true + - secrets/n8n_owner.env environment: GENERIC_TIMEZONE: ${GENERIC_TIMEZONE:-Europe/Zurich} TZ: ${GENERIC_TIMEZONE:-Europe/Zurich} diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..40d9eb4 --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import json +import re +import unittest +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_DIR = ROOT / "n8n" / "workflows" +CREDENTIAL_DIR = ROOT / "n8n" / "credentials" + + +def load_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def workflow_reference(node: dict[str, Any]) -> str | None: + value = node.get("parameters", {}).get("workflowId") + if isinstance(value, str): + return value + if isinstance(value, dict): + nested = value.get("value") + if isinstance(nested, str): + return nested + return None + + +class RepositoryManifestTests(unittest.TestCase): + def setUp(self) -> None: + self.workflow_paths = sorted(WORKFLOW_DIR.glob("*.json")) + self.workflows = {path: load_json(path) for path in self.workflow_paths} + + self.credential_paths = sorted(CREDENTIAL_DIR.glob("*.template.json")) + self.credentials: list[dict[str, Any]] = [] + for path in self.credential_paths: + payload = load_json(path) + self.assertIsInstance(payload, list, path) + self.credentials.extend(payload) + + def test_expected_workflows_are_versioned(self) -> None: + self.assertEqual( + {path.stem for path in self.workflow_paths}, + { + "Adapt Feature Image", + "Adapt Hugo Media", + "Adapt Reel Media", + "Blog Post Publish", + "Reel Publish", + }, + ) + + def test_workflow_names_and_ids_are_unique(self) -> None: + ids: list[str] = [] + for path, workflow in self.workflows.items(): + self.assertIsInstance(workflow, dict, path) + self.assertEqual(workflow.get("name"), path.stem, path) + workflow_id = workflow.get("id") + self.assertIsInstance(workflow_id, str, path) + self.assertTrue(workflow_id, path) + ids.append(workflow_id) + + self.assertEqual(len(ids), len(set(ids)), "workflow IDs must be unique") + + def test_subworkflow_references_resolve(self) -> None: + known_ids = {workflow["id"] for workflow in self.workflows.values()} + for path, workflow in self.workflows.items(): + for node in workflow.get("nodes", []): + referenced_id = workflow_reference(node) + if referenced_id is not None: + self.assertIn( + referenced_id, + known_ids, + f"{path.name}: {node.get('name')} references an unknown workflow", + ) + + def test_credential_ids_and_names_are_unique(self) -> None: + ids = [credential.get("id") for credential in self.credentials] + names = [credential.get("name") for credential in self.credentials] + self.assertTrue(all(isinstance(value, str) and value for value in ids)) + self.assertTrue(all(isinstance(value, str) and value for value in names)) + self.assertEqual(len(ids), len(set(ids)), "credential IDs must be unique") + self.assertEqual(len(names), len(set(names)), "credential names must be unique") + + def test_workflow_credential_references_resolve(self) -> None: + known_ids = {credential["id"] for credential in self.credentials} + for path, workflow in self.workflows.items(): + for node in workflow.get("nodes", []): + for reference in node.get("credentials", {}).values(): + credential_id = reference.get("id") + self.assertIn( + credential_id, + known_ids, + f"{path.name}: {node.get('name')} references an unknown credential", + ) + + def test_credential_templates_contain_only_placeholders(self) -> None: + placeholder = re.compile(r"^\$\{[A-Z][A-Z0-9_]*\}$") + for credential in self.credentials: + data = credential.get("data") + self.assertIsInstance(data, dict, credential.get("name")) + for key, value in data.items(): + self.assertIsInstance(value, str, f"{credential.get('name')}.{key}") + self.assertRegex(value, placeholder, f"{credential.get('name')}.{key}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/validate-compose.sh b/tests/validate-compose.sh new file mode 100755 index 0000000..622a200 --- /dev/null +++ b/tests/validate-compose.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +owner_env="$ROOT/secrets/n8n_owner.env" +created_owner_env=0 + +cleanup() { + if [[ "$created_owner_env" -eq 1 ]]; then + rm -f "$owner_env" + fi +} +trap cleanup EXIT + +if [[ ! -e "$owner_env" ]]; then + mkdir -p "$(dirname "$owner_env")" + printf '%s\n' 'N8N_INSTANCE_OWNER_PASSWORD_HASH=ci-only' >"$owner_env" + created_owner_env=1 +fi + +export N8N_ENCRYPTION_KEY="ci-only-encryption-key" +export N8N_OWNER_EMAIL="ci@example.invalid" + +docker compose --env-file /dev/null config --quiet From 14a56fa320e88017e759cc88d043ef5ad706c56c Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Wed, 12 Aug 2026 11:25:08 +0200 Subject: [PATCH 02/16] Make container runtime deterministic Pin image and package inputs, keep community nodes synchronized, and replace the sshd binary shim with a supported startup hook. Co-authored-by: Cursor --- .env.example | 24 +- .github/dependabot.yml | 31 + .github/workflows/ci.yml | 16 + .gitignore | 1 + docker-compose.yml | 39 +- n8n/.dockerignore | 5 + n8n/Dockerfile | 23 +- n8n/entrypoint.sh | 15 +- n8n/package-lock.json | 1880 ++++++++++++++++++++++++++++++++ n8n/package.json | 12 + pyautoflip/.dockerignore | 5 + pyautoflip/Dockerfile | 17 +- pyautoflip/requirements.in | 4 + pyautoflip/requirements.txt | 2008 ++++++++++++++++++++++++++++++++++- pyautoflip/warm_models.py | 7 + sftp/Dockerfile | 10 - sftp/entrypoint.sh | 28 - sftp/setup.sh | 43 + sftp/sshd-wrapper.sh | 32 - tests/test_repository.py | 31 + tests/validate-compose.sh | 6 +- 21 files changed, 4109 insertions(+), 128 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 n8n/.dockerignore create mode 100644 n8n/package-lock.json create mode 100644 n8n/package.json create mode 100644 pyautoflip/.dockerignore create mode 100644 pyautoflip/requirements.in create mode 100644 pyautoflip/warm_models.py delete mode 100644 sftp/Dockerfile delete mode 100755 sftp/entrypoint.sh create mode 100755 sftp/setup.sh delete mode 100755 sftp/sshd-wrapper.sh diff --git a/.env.example b/.env.example index 53710ad..a2e1673 100644 --- a/.env.example +++ b/.env.example @@ -3,12 +3,12 @@ # --- Host / timezone --- GENERIC_TIMEZONE=Europe/Zurich -# --- n8n URL (Mac / LAN callers) --- -N8N_HOST=192.168.0.26 +# --- n8n URL (local default; use the server DNS name/IP for LAN callers) --- +N8N_HOST=localhost N8N_PORT=5678 N8N_PROTOCOL=http N8N_HOST_PORT=5678 -WEBHOOK_URL=http://192.168.0.26:5678/ +WEBHOOK_URL=http://localhost:5678/ N8N_SECURE_COOKIE=false # Encryption key for credentials at rest. @@ -48,11 +48,17 @@ SFTP_USERNAME=sftp # Created automatically by scripts/ensure-sftp-keys.sh (also run by bootstrap). SFTP_PRIVATE_KEY_FILE=./secrets/sftp_n8n_ed25519 -# --- Image pins (optional; update.sh uses :stable by default) --- -# N8N_BASE_IMAGE=docker.n8n.io/n8nio/n8n:stable +# --- Image overrides --- +# Reviewed defaults are pinned in docker-compose.yml and the Dockerfiles. +# Override only while testing an explicit dependency update. +# SYNDICATOR_IMAGE_TAG=local +# N8N_BASE_IMAGE=docker.io/n8nio/n8n:2.33.7 # FFMPEG_IMAGE=mwader/static-ffmpeg:7.1.1 -# POSTIZ_NODE_VERSION=0.2.17 -# FFMPEG_STUDIO_NODE_VERSION=1.0.0 +# PYAUTOFLIP_BASE_IMAGE=python:3.12-slim-bookworm +# PYAUTOFLIP_WARM_MODELS=1 +# SFTP_BASE_IMAGE=atmoz/sftp:alpine +# SFTP_PLATFORM=linux/amd64 +# FILES_INIT_IMAGE=alpine:3.20 -# --- update.sh --- -# UPDATE_LOG=/var/log/syndicator-update.log +# --- Operations --- +# SYNDICATOR_BACKUP_DIR=./backups diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f3df24c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + + - package-ecosystem: docker + directory: /n8n + schedule: + interval: weekly + + - package-ecosystem: docker + directory: /pyautoflip + schedule: + interval: weekly + + - package-ecosystem: npm + directory: /n8n + schedule: + interval: weekly + + - package-ecosystem: pip + directory: /pyautoflip + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78fe559..02d2738 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,19 @@ jobs: - name: Render Compose configuration run: bash tests/validate-compose.sh + + - name: Audit locked community nodes + working-directory: n8n + run: npm audit + + build-images: + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build application images + env: + PYAUTOFLIP_WARM_MODELS: "0" + run: | + bash tests/validate-compose.sh build n8n pyautoflip \ No newline at end of file diff --git a/.gitignore b/.gitignore index a207e51..e770e27 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ .pytest_cache/ .ruff_cache/ dist/ +node_modules/ # Secrets .env diff --git a/docker-compose.yml b/docker-compose.yml index ae7be68..0d521cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,20 +11,21 @@ name: syndicator services: # Docker named volumes are root-owned on first create; n8n + pyautoflip run as uid 1000. files-init: - image: alpine:3.20 + image: ${FILES_INIT_IMAGE:-alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc} volumes: - n8n_files:/data command: [ "sh", "-c", - "chown -R 1000:1000 /data && chmod 775 /data", + "chown 1000:1000 /data && chmod 775 /data", ] restart: "no" sftp: - build: ./sftp - image: syndicator-sftp:local + image: ${SFTP_BASE_IMAGE:-atmoz/sftp:alpine@sha256:81fa92512bf8ead4849f33c1c153907b86d32d77704d1c62a9c70b4316ae9e50} + # The upstream image is amd64-only; Docker Desktop emulates it on Apple Silicon. + platform: ${SFTP_PLATFORM:-linux/amd64} restart: unless-stopped ports: - "${SFTP_PUBLISH_PORT:-2222}:22" @@ -34,11 +35,17 @@ services: - ./sftp/keys:/home/sftp/.ssh/keys:ro # Server host keys generated on first start; survive recreate. - sftp_host_keys:/etc/ssh/host_keys + # Supported atmoz startup hook; avoids replacing the sshd binary. + - ./sftp/setup.sh:/etc/sftp.d/10-syndicator.sh:ro # user::uid:gid:dirs — empty password → key-only auth command: sftp::1001:100:syndicator healthcheck: - test: ["CMD-SHELL", "pgrep -x sshd >/dev/null || exit 1"] - interval: 30s + test: + [ + "CMD-SHELL", + "kill -0 1 && /usr/sbin/sshd -t", + ] + interval: 10s timeout: 5s retries: 3 @@ -46,11 +53,9 @@ services: build: context: ./n8n args: - N8N_BASE_IMAGE: ${N8N_BASE_IMAGE:-docker.n8n.io/n8nio/n8n:stable} - FFMPEG_IMAGE: ${FFMPEG_IMAGE:-mwader/static-ffmpeg:7.1.1} - POSTIZ_VERSION: ${POSTIZ_NODE_VERSION:-0.2.17} - FFMPEG_STUDIO_VERSION: ${FFMPEG_STUDIO_NODE_VERSION:-1.0.0} - image: syndicator-n8n:stable + N8N_BASE_IMAGE: ${N8N_BASE_IMAGE:-docker.io/n8nio/n8n:2.33.7@sha256:3989d9b8ebb77b4ee8f604519eb73e44f4384bfaa689526e0104eed79a237d30} + FFMPEG_IMAGE: ${FFMPEG_IMAGE:-mwader/static-ffmpeg:7.1.1@sha256:11a44711684c0b9f754c047dcd64235b8b52deab251bd0e0a86f22faa160749c} + image: syndicator-n8n:${SYNDICATOR_IMAGE_TAG:-local} restart: unless-stopped ports: - "${N8N_HOST_PORT:-5678}:5678" @@ -80,9 +85,9 @@ services: files-init: condition: service_completed_successfully sftp: - condition: service_started - pyautoflip: condition: service_healthy + pyautoflip: + condition: service_started healthcheck: test: [ @@ -95,8 +100,12 @@ services: start_period: 60s pyautoflip: - build: ./pyautoflip - image: syndicator-pyautoflip:local + build: + context: ./pyautoflip + args: + PYAUTOFLIP_BASE_IMAGE: ${PYAUTOFLIP_BASE_IMAGE:-python:3.12-slim-bookworm@sha256:4766d8b510c428e595d74b9cc5bbb2fae8e26316fffb4adc89908d79aacd58a2} + PYAUTOFLIP_WARM_MODELS: ${PYAUTOFLIP_WARM_MODELS:-1} + image: syndicator-pyautoflip:${SYNDICATOR_IMAGE_TAG:-local} # Same uid as n8n (node) so /files writes are readable by both. # HOME must be writable: compose user override otherwise sets HOME=/ and # InsightFace fails creating /.insightface (EACCES). diff --git a/n8n/.dockerignore b/n8n/.dockerignore new file mode 100644 index 0000000..4df0d62 --- /dev/null +++ b/n8n/.dockerignore @@ -0,0 +1,5 @@ +* +!Dockerfile +!entrypoint.sh +!package.json +!package-lock.json diff --git a/n8n/Dockerfile b/n8n/Dockerfile index 7bea7d2..5f66dd5 100644 --- a/n8n/Dockerfile +++ b/n8n/Dockerfile @@ -1,35 +1,28 @@ -# Custom n8n image: static ffmpeg/ffprobe + community nodes (Postiz, FFmpeg Studio). -# Base tag stays :stable so scripts/update.sh can pull security patches; -# override N8N_BASE_IMAGE to pin a digest/version when needed. +# Custom n8n image: static ffmpeg/ffprobe + locked community nodes. -ARG N8N_BASE_IMAGE=docker.n8n.io/n8nio/n8n:stable -ARG FFMPEG_IMAGE=mwader/static-ffmpeg:7.1.1 +ARG N8N_BASE_IMAGE=docker.io/n8nio/n8n:2.33.7@sha256:3989d9b8ebb77b4ee8f604519eb73e44f4384bfaa689526e0104eed79a237d30 +ARG FFMPEG_IMAGE=mwader/static-ffmpeg:7.1.1@sha256:11a44711684c0b9f754c047dcd64235b8b52deab251bd0e0a86f22faa160749c FROM ${FFMPEG_IMAGE} AS ffmpeg FROM ${N8N_BASE_IMAGE} -ARG POSTIZ_VERSION=0.2.17 -ARG FFMPEG_STUDIO_VERSION=1.0.0 - USER root COPY --from=ffmpeg /ffmpeg /usr/local/bin/ffmpeg COPY --from=ffmpeg /ffprobe /usr/local/bin/ffprobe # Seed community packages outside the data volume. The entrypoint copies them -# into /home/node/.n8n/nodes on first start (volume mount hides image content). -RUN mkdir -p /opt/n8n-nodes-seed \ - && cd /opt/n8n-nodes-seed \ - && npm init -y \ - && npm install --omit=dev \ - "n8n-nodes-postiz@${POSTIZ_VERSION}" \ - "n8n-nodes-ffmpeg-studio@${FFMPEG_STUDIO_VERSION}" \ +# into /home/node/.n8n/nodes when the lock changes. +WORKDIR /opt/n8n-nodes-seed +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev \ && chown -R node:node /opt/n8n-nodes-seed COPY entrypoint.sh /entrypoint-syndicator.sh RUN chmod +x /entrypoint-syndicator.sh USER node +WORKDIR /home/node ENTRYPOINT ["tini", "--", "/entrypoint-syndicator.sh"] diff --git a/n8n/entrypoint.sh b/n8n/entrypoint.sh index 13b2b24..94513b8 100755 --- a/n8n/entrypoint.sh +++ b/n8n/entrypoint.sh @@ -1,15 +1,20 @@ #!/bin/sh -# Seed community nodes into the n8n data volume when missing, then start n8n. +# Sync locked community nodes into the n8n data volume, then start n8n. set -eu NODES_DIR="/home/node/.n8n/nodes" SEED_DIR="/opt/n8n-nodes-seed" +SEED_MARKER="$NODES_DIR/.syndicator-seed.sha256" -if [ -d "$SEED_DIR/node_modules" ]; then - mkdir -p "$NODES_DIR" - if [ ! -f "$NODES_DIR/package.json" ]; then - echo "Seeding community nodes into $NODES_DIR" +if [ -d "$SEED_DIR/node_modules" ] && [ -f "$SEED_DIR/package-lock.json" ]; then + seed_hash="$(sha256sum "$SEED_DIR/package-lock.json" | cut -d ' ' -f 1)" + installed_hash="$(cat "$SEED_MARKER" 2>/dev/null || true)" + if [ "$seed_hash" != "$installed_hash" ]; then + echo "Syncing locked community nodes into $NODES_DIR" + rm -rf "$NODES_DIR" + mkdir -p "$NODES_DIR" cp -a "$SEED_DIR"/. "$NODES_DIR"/ + printf '%s\n' "$seed_hash" >"$SEED_MARKER" fi fi diff --git a/n8n/package-lock.json b/n8n/package-lock.json new file mode 100644 index 0000000..43f55e5 --- /dev/null +++ b/n8n/package-lock.json @@ -0,0 +1,1880 @@ +{ + "name": "syndicator-n8n-nodes", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "syndicator-n8n-nodes", + "dependencies": { + "n8n-nodes-ffmpeg-studio": "1.0.0", + "n8n-nodes-postiz": "0.2.17", + "n8n-workflow": "2.33.2" + } + }, + "node_modules/@apm-js-collab/code-transformer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.18.1.tgz", + "integrity": "sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/estree": "^1.0.8", + "astring": "^1.9.0", + "esquery": "^1.7.0", + "meriyah": "^6.1.4", + "semifies": "^1.0.0", + "source-map": "^0.6.0" + }, + "bin": { + "code-transformer": "cli.js" + } + }, + "node_modules/@apm-js-collab/code-transformer-bundler-plugins": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.4.tgz", + "integrity": "sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.18.1", + "es-module-lexer": "^2.1.0", + "magic-string": "^0.30.21", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@apm-js-collab/tracing-hooks": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.13.0.tgz", + "integrity": "sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==", + "license": "Apache-2.0", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.18.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", + "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.8", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.8.tgz", + "integrity": "sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, + "node_modules/@n8n/constants": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@n8n/constants/-/constants-0.32.0.tgz", + "integrity": "sha512-ewF/bQL2bjmHU9Dv5PnW8uDpYw5GlFJ0/nuAp+RDK9OJXXm19ErHb9itLyANvNTLGPQv8bb0BMvHh2kpWaa/8g==", + "license": "SEE LICENSE IN LICENSE.md" + }, + "node_modules/@n8n/errors": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@n8n/errors/-/errors-0.13.0.tgz", + "integrity": "sha512-hUZ+ePKp2BzJL40OK1+wTVefJkKLy+mjiBT6KsejkXi1HboYoqHruFqeQZivm5PHhX3CZsHa9ZJy2LGHZrpKCQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "callsites": "3.1.0" + } + }, + "node_modules/@n8n/expression-runtime": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@n8n/expression-runtime/-/expression-runtime-0.24.0.tgz", + "integrity": "sha512-ATII3IU1yAb29oFdGviJuMuL+31oSvTr31FIdVpASDyfHDGPFAbsAC0t/Gpa/RorGvjvmzu6fXow7XA0Xj5jdQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@n8n/errors": "0.13.0", + "@n8n/tournament": "1.9.0", + "isolated-vm": "^6.1.2", + "jmespath": "0.16.0", + "js-base64": "3.7.8", + "jssha": "3.3.1", + "lodash": "4.18.1", + "luxon": "3.7.2", + "md5": "2.3.0", + "title-case": "3.0.3", + "transliteration": "2.3.5", + "zod": "3.25.67" + } + }, + "node_modules/@n8n/tournament": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@n8n/tournament/-/tournament-1.9.0.tgz", + "integrity": "sha512-XB88QlKuD5QO1ERrfVbb2BC2uJrHqHsbmK5FlV+hY7W6IZO4BQtGCTJmHKgwCm+mKsVwBUz3edO8rUadNX6sxg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "ast-types": "^0.16.1", + "esprima-next": "^5.8.4", + "recast": "^0.22.0" + } + }, + "node_modules/@n8n/utils": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/@n8n/utils/-/utils-1.41.0.tgz", + "integrity": "sha512-wjlYqMYRDI8RGNEEBQXA9Pw5FsgdqP+4tvk6HJ9POB5x9yQyF4PhHt45a2RVz0BAoGwZKuxnunR3T+pE/FbjKQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@n8n/constants": "0.32.0", + "nanoid": "3.3.8" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.10.0.tgz", + "integrity": "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.10.0.tgz", + "integrity": "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz", + "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/sdk-trace": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "10.70.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.70.0.tgz", + "integrity": "sha512-ozhCTDqg89oB4XmWfAwuHshABpvT7AkRpaPnogopPfMAaI61G1t8EKCJ4W7aum8JSBonlfyjPCyW5oYZFm0KvA==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.70.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.70.0.tgz", + "integrity": "sha512-SPOOVxmKTVIEtqvOKkQT163e/pOwucjS7OPsCHyRs8sFR4nfBNu0EThplyqnvqd5BWBMTPH6WTBQfo+QWHV+HA==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.70.0", + "@sentry/node-core": "10.70.0", + "@sentry/opentelemetry": "10.70.0", + "@sentry/server-utils": "10.70.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.70.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.70.0.tgz", + "integrity": "sha512-oPOEVVNxv5WHtckx2i06Wi9FLWyvOg/1DUeX732jZ4iqT2nupINaMH4nF4f4kSvUThFnxkFSRQxwqOxgzMKhKA==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.70.0", + "@sentry/opentelemetry": "10.70.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + } + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.70.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.70.0.tgz", + "integrity": "sha512-UNV/2tqypcUK6FDzerAsFJn1Km/c4VZCYkUZDNbnV5S0cwAq2BYKMo4M5vovaLDBQlxA+Wk9ovbxi5wYjjl9fw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.70.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + } + }, + "node_modules/@sentry/server-utils": { + "version": "10.70.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.70.0.tgz", + "integrity": "sha512-rzegZjMFFgCp3o+N8+XU13rfSvz4B+f8rU0ijBGrQcHdMNyfsFDTu1UTm262JofmrV2u+s+D0u0vFTnqtOGkbA==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.3", + "@apm-js-collab/tracing-hooks": "^0.13.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.70.0", + "meriyah": "^6.1.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esprima-next": { + "version": "5.8.4", + "resolved": "https://registry.npmjs.org/esprima-next/-/esprima-next-5.8.4.tgz", + "integrity": "sha512-8nYVZ4ioIH4Msjb/XmhnBdz5WRRBaYqevKa1cv9nGJdCehMbzZCPNEEnqfLCZVetUVrUPEcb5IYyu1GG4hFqgg==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/import-in-the-middle": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.3.tgz", + "integrity": "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==", + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isolated-vm": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/isolated-vm/-/isolated-vm-6.2.0.tgz", + "integrity": "sha512-UuSlxSHWt2QuJ5WvBhzlIJx2VVZN/a44SqBbEZFKNdvuSyhOvhmyDo8SQ+njVbhnh/njoL/aW0bUTiFYlpweGQ==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/jsonrepair": { + "version": "3.13.2", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.2.tgz", + "integrity": "sha512-Leuly0nbM4R+S5SVJk3VHfw1oxnlEK9KygdZvfUtEtTawNDyzB4qa1xWTmFt1aeoA7sXZkVTRuIixJ8bAvqVUg==", + "license": "ISC", + "bin": { + "jsonrepair": "bin/cli.js" + } + }, + "node_modules/jssha": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz", + "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", + "license": "ISC", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/n8n-nodes-ffmpeg-studio": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/n8n-nodes-ffmpeg-studio/-/n8n-nodes-ffmpeg-studio-1.0.0.tgz", + "integrity": "sha512-S3hKQZanEFEtqATahuQ0jX7aysgxDiO1llZeajuBVgN1COkZjCRMj8tsnTSrGbDBXdQIoiqo7KPYdqL3ti7Xlg==", + "license": "MIT", + "dependencies": { + "@types/tmp": "^0.2.0", + "axios": "^1.6.0", + "tmp": "^0.2.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "n8n-workflow": "^2.14.0" + } + }, + "node_modules/n8n-nodes-postiz": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/n8n-nodes-postiz/-/n8n-nodes-postiz-0.2.17.tgz", + "integrity": "sha512-+dlEfTLuDGUsaO6aldsw3EYwyFNQmhMwEM1hrP0gSuUtPzvwblOd2kPrwKDqE6GKXZa3EnKcdYDKlBps/M1SrQ==", + "license": "MIT", + "engines": { + "node": ">=20.15" + }, + "peerDependencies": { + "n8n-workflow": "*" + } + }, + "node_modules/n8n-workflow": { + "version": "2.33.2", + "resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-2.33.2.tgz", + "integrity": "sha512-dGbWefR1opE5fIXKT0AQKDVxXD0LmLX4r84PGl5cGmYzqJhBS/FucRpm+nVo5EBA/fUop5qpGPGcU9BoQUb0aQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@codemirror/autocomplete": "6.20.0", + "@n8n/errors": "0.13.0", + "@n8n/expression-runtime": "0.24.0", + "@n8n/tournament": "1.9.0", + "@n8n/utils": "1.41.0", + "@sentry/core": "^10.55.0", + "@sentry/node": "^10.55.0", + "ast-types": "0.16.1", + "axios": "1.18.0", + "callsites": "3.1.0", + "esprima-next": "5.8.4", + "form-data": "4.0.6", + "jmespath": "0.16.0", + "js-base64": "3.7.8", + "json-schema": "0.4.0", + "jsonrepair": "3.13.2", + "jssha": "3.3.1", + "lodash": "4.18.1", + "luxon": "3.7.2", + "md5": "2.3.0", + "recast": "0.22.0", + "ssh2": "1.15.0", + "title-case": "3.0.3", + "transliteration": "2.3.5", + "uuid": "11.1.1", + "xml2js": "0.6.2" + }, + "peerDependencies": { + "zod": "3.25.67" + } + }, + "node_modules/n8n-workflow/node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/recast": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.22.0.tgz", + "integrity": "sha512-5AAx+mujtXijsEavc5lWXBPQqrM4+Dl5qNH96N2aNeuJFUzpiiToKPsxQD/zAIJHspz7zz0maX0PCtCTFVlixQ==", + "license": "MIT", + "dependencies": { + "assert": "^2.0.0", + "ast-types": "0.15.2", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/ast-types": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.15.2.tgz", + "integrity": "sha512-c27loCv9QkZinsa5ProX751khO9DJl/AcB5c2KNtA6NRvHKS0PgLfcftz72KVq504vB0Gku5s2kUZzDBvQWvHg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semifies": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", + "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==", + "license": "Apache-2.0" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssh2": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.15.0.tgz", + "integrity": "sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.9", + "nan": "^2.18.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/title-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", + "integrity": "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/transliteration": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/transliteration/-/transliteration-2.3.5.tgz", + "integrity": "sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==", + "license": "MIT", + "dependencies": { + "yargs": "^17.5.1" + }, + "bin": { + "slugify": "dist/bin/slugify", + "transliterate": "dist/bin/transliterate" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "3.25.67", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", + "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/n8n/package.json b/n8n/package.json new file mode 100644 index 0000000..20142bf --- /dev/null +++ b/n8n/package.json @@ -0,0 +1,12 @@ +{ + "name": "syndicator-n8n-nodes", + "private": true, + "dependencies": { + "n8n-nodes-ffmpeg-studio": "1.0.0", + "n8n-nodes-postiz": "0.2.17", + "n8n-workflow": "2.33.2" + }, + "overrides": { + "nanoid": "3.3.18" + } +} diff --git a/pyautoflip/.dockerignore b/pyautoflip/.dockerignore new file mode 100644 index 0000000..80e2f97 --- /dev/null +++ b/pyautoflip/.dockerignore @@ -0,0 +1,5 @@ +* +!Dockerfile +!app.py +!requirements.txt +!warm_models.py diff --git a/pyautoflip/Dockerfile b/pyautoflip/Dockerfile index 5a0db1c..c035a25 100644 --- a/pyautoflip/Dockerfile +++ b/pyautoflip/Dockerfile @@ -1,4 +1,5 @@ -FROM python:3.12-slim-bookworm +ARG PYAUTOFLIP_BASE_IMAGE=python:3.12-slim-bookworm@sha256:4766d8b510c428e595d74b9cc5bbb2fae8e26316fffb4adc89908d79aacd58a2 +FROM ${PYAUTOFLIP_BASE_IMAGE} RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -12,7 +13,7 @@ RUN apt-get update \ WORKDIR /app COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt \ +RUN pip install --no-cache-dir --require-hashes -r requirements.txt \ && chown -R pyautoflip:pyautoflip /app # Match n8n's node user (uid 1000). Compose may also set user: "1000:1000"; @@ -24,13 +25,11 @@ USER pyautoflip # Warm InsightFace packs into $HOME/.insightface so runtime needs no downloads. # pyautoflip's face detector defaults to buffalo_s (not buffalo_l). -RUN python - <<'PY' -from insightface.app import FaceAnalysis -for name in ("buffalo_s", "buffalo_l"): - app = FaceAnalysis(name=name, providers=["CPUExecutionProvider"]) - app.prepare(ctx_id=-1, det_size=(640, 640)) - print(f"insightface {name} ready") -PY +ARG PYAUTOFLIP_WARM_MODELS=1 +COPY --chown=pyautoflip:pyautoflip warm_models.py . +RUN if [ "$PYAUTOFLIP_WARM_MODELS" = "1" ]; then \ + python warm_models.py; \ + fi COPY --chown=pyautoflip:pyautoflip app.py . diff --git a/pyautoflip/requirements.in b/pyautoflip/requirements.in new file mode 100644 index 0000000..77f2496 --- /dev/null +++ b/pyautoflip/requirements.in @@ -0,0 +1,4 @@ +pyautoflip==0.2.1 +fastapi==0.115.12 +uvicorn[standard]==0.34.2 +pydantic==2.11.3 diff --git a/pyautoflip/requirements.txt b/pyautoflip/requirements.txt index 77f2496..6e9d5fa 100644 --- a/pyautoflip/requirements.txt +++ b/pyautoflip/requirements.txt @@ -1,4 +1,2004 @@ -pyautoflip==0.2.1 -fastapi==0.115.12 -uvicorn[standard]==0.34.2 -pydantic==2.11.3 +# This file was autogenerated by uv via the following command: +# uv pip compile pyautoflip/requirements.in --universal --python-version 3.12 --generate-hashes --output-file pyautoflip/requirements.txt +absl-py==2.5.0 \ + --hash=sha256:0c996f25c0490700fadabe6351630f6111534fa0ae252cc6d2014ea3b141135f \ + --hash=sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba + # via + # mediapipe + # tensorboard +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f + # via + # starlette + # watchfiles +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # mediapipe + # requests +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via sounddevice +charset-normalizer==3.4.9 \ + --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ + --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ + --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \ + --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \ + --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \ + --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \ + --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \ + --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \ + --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \ + --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \ + --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \ + --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \ + --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \ + --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \ + --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \ + --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \ + --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \ + --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \ + --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \ + --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \ + --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \ + --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \ + --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \ + --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \ + --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \ + --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \ + --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \ + --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \ + --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \ + --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \ + --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \ + --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \ + --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \ + --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \ + --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \ + --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \ + --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \ + --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \ + --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \ + --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \ + --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \ + --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \ + --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \ + --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \ + --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \ + --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \ + --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \ + --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \ + --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \ + --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \ + --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \ + --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \ + --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \ + --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \ + --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \ + --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \ + --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \ + --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \ + --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \ + --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \ + --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \ + --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \ + --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \ + --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \ + --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \ + --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \ + --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \ + --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \ + --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \ + --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \ + --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \ + --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \ + --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \ + --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \ + --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \ + --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \ + --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \ + --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \ + --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \ + --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \ + --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \ + --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \ + --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \ + --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \ + --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \ + --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \ + --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \ + --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \ + --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \ + --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \ + --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \ + --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ + --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 + # via requests +click==8.4.2 \ + --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + # via + # scenedetect + # uvicorn +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via + # click + # tqdm + # uvicorn +contourpy==1.3.3 \ + --hash=sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69 \ + --hash=sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc \ + --hash=sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880 \ + --hash=sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a \ + --hash=sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8 \ + --hash=sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc \ + --hash=sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470 \ + --hash=sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5 \ + --hash=sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263 \ + --hash=sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b \ + --hash=sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5 \ + --hash=sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381 \ + --hash=sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3 \ + --hash=sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4 \ + --hash=sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e \ + --hash=sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f \ + --hash=sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772 \ + --hash=sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286 \ + --hash=sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42 \ + --hash=sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301 \ + --hash=sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77 \ + --hash=sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7 \ + --hash=sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411 \ + --hash=sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1 \ + --hash=sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9 \ + --hash=sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a \ + --hash=sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b \ + --hash=sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db \ + --hash=sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6 \ + --hash=sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620 \ + --hash=sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989 \ + --hash=sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea \ + --hash=sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67 \ + --hash=sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5 \ + --hash=sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d \ + --hash=sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36 \ + --hash=sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99 \ + --hash=sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1 \ + --hash=sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e \ + --hash=sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b \ + --hash=sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8 \ + --hash=sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d \ + --hash=sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7 \ + --hash=sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7 \ + --hash=sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339 \ + --hash=sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1 \ + --hash=sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659 \ + --hash=sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4 \ + --hash=sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f \ + --hash=sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20 \ + --hash=sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36 \ + --hash=sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb \ + --hash=sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d \ + --hash=sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8 \ + --hash=sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0 \ + --hash=sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b \ + --hash=sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7 \ + --hash=sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe \ + --hash=sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77 \ + --hash=sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497 \ + --hash=sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd \ + --hash=sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1 \ + --hash=sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216 \ + --hash=sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13 \ + --hash=sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae \ + --hash=sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae \ + --hash=sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77 \ + --hash=sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3 \ + --hash=sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f \ + --hash=sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff \ + --hash=sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9 \ + --hash=sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a + # via matplotlib +cuda-bindings==13.3.1 ; python_full_version < '3.15' and sys_platform == 'linux' \ + --hash=sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708 \ + --hash=sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86 \ + --hash=sha256:18c8c167c8907b8f02531ca810534315c458dabef31f7965095619bf647b9202 \ + --hash=sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8 \ + --hash=sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7 \ + --hash=sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9 \ + --hash=sha256:507b0e19e7f934c5e30f30f0244ad70a75812619a7d3a0d742543caae1bd50f1 \ + --hash=sha256:61120b5e4f4a63f67efd7e7396914cb9ef871bb1f0021e990fb70277be240a4d \ + --hash=sha256:8de12ef60bf40756852cb62bbb40460609269f6ece522903d1cc93d73a3ececb \ + --hash=sha256:9851b0caa8bfd3bc6fa054eaf57bea7c8e9c3a62db2d2621224677f49f3c53d0 \ + --hash=sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf \ + --hash=sha256:b134dd8c5c66ae4c4ad814f7aee88fd215353c077010cbc47e3b55ed35ec9eff \ + --hash=sha256:c0c4b1a995098c46695c24257a342dc97d6e6d3f3050b944c9f43bd26d734051 \ + --hash=sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76 \ + --hash=sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474 \ + --hash=sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49 \ + --hash=sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a \ + --hash=sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80 + # via torch +cuda-pathfinder==1.6.0 ; python_full_version < '3.15' and sys_platform == 'linux' \ + --hash=sha256:1503af579d8379c24bdd65528379bc57039b0455be9f5f9686cf8e473a1fce51 + # via cuda-bindings +cuda-toolkit==13.0.3.0 ; sys_platform == 'linux' \ + --hash=sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f + # via torch +cycler==0.12.1 \ + --hash=sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30 \ + --hash=sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c + # via matplotlib +fastapi==0.115.12 \ + --hash=sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681 \ + --hash=sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d + # via -r requirements.in +filelock==3.32.2 \ + --hash=sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82 \ + --hash=sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8 + # via torch +flatbuffers==25.12.19 \ + --hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4 + # via + # mediapipe + # onnxruntime +fonttools==4.63.0 \ + --hash=sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69 \ + --hash=sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c \ + --hash=sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac \ + --hash=sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096 \ + --hash=sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d \ + --hash=sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68 \ + --hash=sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616 \ + --hash=sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78 \ + --hash=sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f \ + --hash=sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b \ + --hash=sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b \ + --hash=sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02 \ + --hash=sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d \ + --hash=sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f \ + --hash=sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8 \ + --hash=sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272 \ + --hash=sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49 \ + --hash=sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419 \ + --hash=sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001 \ + --hash=sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03 \ + --hash=sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196 \ + --hash=sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9 \ + --hash=sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e \ + --hash=sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5 \ + --hash=sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007 \ + --hash=sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380 \ + --hash=sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8 \ + --hash=sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27 \ + --hash=sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40 \ + --hash=sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e \ + --hash=sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0 \ + --hash=sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263 \ + --hash=sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb \ + --hash=sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94 \ + --hash=sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b \ + --hash=sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6 \ + --hash=sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579 \ + --hash=sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4 \ + --hash=sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59 \ + --hash=sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0 \ + --hash=sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e \ + --hash=sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be \ + --hash=sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd \ + --hash=sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18 \ + --hash=sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22 \ + --hash=sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0 \ + --hash=sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b \ + --hash=sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b \ + --hash=sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af \ + --hash=sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745 + # via matplotlib +fsspec==2026.7.0 \ + --hash=sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279 \ + --hash=sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88 + # via torch +grpcio==1.83.0 \ + --hash=sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df \ + --hash=sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867 \ + --hash=sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5 \ + --hash=sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df \ + --hash=sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930 \ + --hash=sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf \ + --hash=sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9 \ + --hash=sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33 \ + --hash=sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da \ + --hash=sha256:35a5b1c192496b6c25956eebfa963468935612206fd2543ac3ce981e6a5e0f03 \ + --hash=sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa \ + --hash=sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223 \ + --hash=sha256:4e3eedfc92b6b9f2960115e7e620cf0cbf80bb7849a51ce3820dc54dfd88b6b9 \ + --hash=sha256:4fcaa7c45c45b4a89e2867d1f1785d9481a788399d915e341ed2eb49aeef9dd4 \ + --hash=sha256:5882c1a721b50ce0123ee5e839e1ab059ad72a7ade76cdf2d5bd833b56791acf \ + --hash=sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881 \ + --hash=sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af \ + --hash=sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5 \ + --hash=sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0 \ + --hash=sha256:6755ed67cc3e454d51ae9f6e1915b80d3942fa4de956ef48dacd45ab7f40b727 \ + --hash=sha256:6b6c666a1d5613ff360c9e90f44665e3a88b25a815209ddbc0917eec281931cb \ + --hash=sha256:6be5c807b717be3dd649446f021301fd7907e376318675d2147823071034112a \ + --hash=sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735 \ + --hash=sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16 \ + --hash=sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49 \ + --hash=sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1 \ + --hash=sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24 \ + --hash=sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b \ + --hash=sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f \ + --hash=sha256:8f6c395e493d20c39b29392ca200e9aaeb78d0bc2f04db0c0a7da7ddc939aa57 \ + --hash=sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf \ + --hash=sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f \ + --hash=sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c \ + --hash=sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969 \ + --hash=sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd \ + --hash=sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c \ + --hash=sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b \ + --hash=sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61 \ + --hash=sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404 \ + --hash=sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617 \ + --hash=sha256:c834e86d8fd2f03d7e4db49a027f7c5b89c5b88eed305543a5295bd6fee61e40 \ + --hash=sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45 \ + --hash=sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc \ + --hash=sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c \ + --hash=sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745 \ + --hash=sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45 \ + --hash=sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c \ + --hash=sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0 \ + --hash=sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d \ + --hash=sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9 \ + --hash=sha256:fba099b716e73512d61b97f71ea3c31a72abb36904036e316bf4dd148ca8dcc8 + # via tensorboard +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via uvicorn +httptools==0.8.0 \ + --hash=sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683 \ + --hash=sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb \ + --hash=sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b \ + --hash=sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527 \ + --hash=sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124 \ + --hash=sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca \ + --hash=sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081 \ + --hash=sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c \ + --hash=sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77 \ + --hash=sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09 \ + --hash=sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f \ + --hash=sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085 \ + --hash=sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376 \ + --hash=sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5 \ + --hash=sha256:5d7fa4ba7292c1139c0526f0b5aad507c6263c948206ea1b1cbca015c8af1b62 \ + --hash=sha256:5eb911c515b96ee44bbd861e42cbefc488681d450545b1d02127f6136e3a86f5 \ + --hash=sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8 \ + --hash=sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681 \ + --hash=sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999 \ + --hash=sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1 \ + --hash=sha256:7b71e7d7031928c650e1006e6c03e911bf967f7c69c011d37d541c3e7bf55005 \ + --hash=sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d \ + --hash=sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d \ + --hash=sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d \ + --hash=sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d \ + --hash=sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba \ + --hash=sha256:9fc1644f415372cec4f8a5be3a64183737398f10dbb1263602a036427fe75247 \ + --hash=sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745 \ + --hash=sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07 \ + --hash=sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b \ + --hash=sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4 \ + --hash=sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2 \ + --hash=sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557 \ + --hash=sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d \ + --hash=sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826 \ + --hash=sha256:c08ffe3e79756e0963cbc8fe410139f38a5884874b6f2e17761bef6563fdcd9b \ + --hash=sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813 \ + --hash=sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0 \ + --hash=sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150 \ + --hash=sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e \ + --hash=sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77 \ + --hash=sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568 \ + --hash=sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6 \ + --hash=sha256:df31ef5494f406ab6cf827b7e64a22841c6e2d654100e6a116ea15b46d02d5e8 \ + --hash=sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b \ + --hash=sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7 \ + --hash=sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168 \ + --hash=sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a \ + --hash=sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0 \ + --hash=sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72 + # via uvicorn +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # requests +imageio==2.37.4 \ + --hash=sha256:1ab2e22c8debf700f24c3ac43e8f95f3b3a8110c83b93411e97b4b0b2cd1c7e6 \ + --hash=sha256:e45cbc5e83502047fb138f7f585f7f105a136a57eea5f4b3cfc6ce1b52720bd3 + # via scikit-image +insightface==1.0.1 \ + --hash=sha256:27af24891bbba470cb3573b366a0fcca8989fc8503c9f8f281e8cba6fd716075 \ + --hash=sha256:5f373f6fedbdda5cbc59a34ca386a75a2995cdaf6899402590ae9eb4308fc2e8 + # via pyautoflip +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via torch +joblib==1.5.3 \ + --hash=sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713 \ + --hash=sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3 + # via scikit-learn +kiwisolver==1.5.0 \ + --hash=sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9 \ + --hash=sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679 \ + --hash=sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0 \ + --hash=sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8 \ + --hash=sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276 \ + --hash=sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96 \ + --hash=sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e \ + --hash=sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac \ + --hash=sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f \ + --hash=sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a \ + --hash=sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15 \ + --hash=sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7 \ + --hash=sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368 \ + --hash=sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02 \ + --hash=sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9 \ + --hash=sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681 \ + --hash=sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57 \ + --hash=sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27 \ + --hash=sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4 \ + --hash=sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920 \ + --hash=sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374 \ + --hash=sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3 \ + --hash=sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa \ + --hash=sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23 \ + --hash=sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859 \ + --hash=sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb \ + --hash=sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d \ + --hash=sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc \ + --hash=sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581 \ + --hash=sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c \ + --hash=sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099 \ + --hash=sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05 \ + --hash=sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9 \ + --hash=sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd \ + --hash=sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc \ + --hash=sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796 \ + --hash=sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303 \ + --hash=sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca \ + --hash=sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314 \ + --hash=sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489 \ + --hash=sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57 \ + --hash=sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1 \ + --hash=sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797 \ + --hash=sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021 \ + --hash=sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db \ + --hash=sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22 \ + --hash=sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028 \ + --hash=sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083 \ + --hash=sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65 \ + --hash=sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588 \ + --hash=sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0 \ + --hash=sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a \ + --hash=sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1 \ + --hash=sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c \ + --hash=sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac \ + --hash=sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476 \ + --hash=sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53 \ + --hash=sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3 \ + --hash=sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4 \ + --hash=sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615 \ + --hash=sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb \ + --hash=sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18 \ + --hash=sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b \ + --hash=sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1 \ + --hash=sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2 \ + --hash=sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c \ + --hash=sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac \ + --hash=sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d \ + --hash=sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf \ + --hash=sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2 \ + --hash=sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f \ + --hash=sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f \ + --hash=sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4 \ + --hash=sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9 \ + --hash=sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e \ + --hash=sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737 \ + --hash=sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b \ + --hash=sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed \ + --hash=sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3 \ + --hash=sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7 \ + --hash=sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08 \ + --hash=sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e \ + --hash=sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902 \ + --hash=sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd \ + --hash=sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6 \ + --hash=sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310 \ + --hash=sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537 \ + --hash=sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554 \ + --hash=sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e \ + --hash=sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87 \ + --hash=sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a \ + --hash=sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c \ + --hash=sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79 \ + --hash=sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e \ + --hash=sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16 \ + --hash=sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1 \ + --hash=sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875 \ + --hash=sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd \ + --hash=sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0 \ + --hash=sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9 \ + --hash=sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646 \ + --hash=sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657 \ + --hash=sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4 \ + --hash=sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232 \ + --hash=sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819 \ + --hash=sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384 \ + --hash=sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309 \ + --hash=sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede \ + --hash=sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2 \ + --hash=sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203 \ + --hash=sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7 \ + --hash=sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df \ + --hash=sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c \ + --hash=sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167 \ + --hash=sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3 \ + --hash=sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09 \ + --hash=sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398 + # via matplotlib +lazy-loader==0.5 \ + --hash=sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3 \ + --hash=sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005 + # via scikit-image +markdown==3.10.3 \ + --hash=sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f \ + --hash=sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea + # via tensorboard +markupsafe==3.0.3 \ + --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \ + --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \ + --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \ + --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \ + --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ + --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \ + --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ + --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ + --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ + --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ + --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ + --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \ + --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \ + --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ + --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ + --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ + --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \ + --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \ + --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ + --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ + --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ + --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \ + --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \ + --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ + --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ + --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \ + --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ + --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \ + --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ + --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \ + --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ + --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \ + --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ + --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ + --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ + --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ + --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \ + --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ + --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ + --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \ + --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ + --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ + --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ + --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ + --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \ + --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \ + --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ + --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ + --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ + --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ + --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ + --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ + --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \ + --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ + --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ + --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ + --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \ + --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ + --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ + --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ + --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \ + --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ + --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ + --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ + --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \ + --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ + --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \ + --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ + --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \ + --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ + --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ + --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \ + --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \ + --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \ + --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \ + --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ + --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \ + --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ + --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ + --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \ + --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ + --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ + --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ + --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ + --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ + --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \ + --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \ + --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ + --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 + # via + # jinja2 + # werkzeug +matplotlib==3.11.1 \ + --hash=sha256:0c1f44890d435c1b4ef52f701ad5828cb450ea97bcc83918fda6be74965d6cd2 \ + --hash=sha256:11664c551345553db92e61cae6cf1376f138f8c47cafdf13b64b18f3e3e9e464 \ + --hash=sha256:1524e2bdd48a93557aa47ddcfe9c225dfdd57d5a01a5c49128c20f0632980ee1 \ + --hash=sha256:191163532cdefcb1571ca38a6d7e6474baccde64495783e6ba47aa07ec4b9bbb \ + --hash=sha256:1ac697e591c11b6ad04679a73c2d2f9980fe9d9f0311fb414a2e329706343dfb \ + --hash=sha256:216fbb93a74add02ddb4cb38ef5348f59ac00b3e84567eaf16598772d40e150a \ + --hash=sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b \ + --hash=sha256:2abdee5ffa2fe11b2d19f7a5c63b785fb7c28cc46c7bc1814156341d9d1a33e1 \ + --hash=sha256:30c492d4ba9448595b6fd8708c6725963f8148e25c0d8842948da5b05f0ee8d3 \ + --hash=sha256:3d3fd84082b1afbd9398466c81309e20045be20d48fe0fb18c43504d164cbbb2 \ + --hash=sha256:427258425f9a3fc4ed79a91f9e9b9aaf5a82cb6571e85dc14063cc6fbb993741 \ + --hash=sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a \ + --hash=sha256:54d47b8ae8b579633a3902ca5b4ad6c1e132a5626d64447b2e22a66394e79987 \ + --hash=sha256:5af0dcda57d471440a7b5b623e70e0a61003518443d9098f211a96ecfbbc25be \ + --hash=sha256:5e1f8922ba31959cf6a9dfb51be64b7f7bc582801a3957dc0c2f3afcd3537adf \ + --hash=sha256:5e510088c27a89d53580a752f959146893563e63c330e161d159b0fee652af6f \ + --hash=sha256:6771b0cd7838c6a857a7209814158c0ad09bfef878db3033dd82d70ad101f191 \ + --hash=sha256:67e4c3cd578c65ebd81bdc09a1b6592ceafee6dfafe116dc85dfcb647b5bbb18 \ + --hash=sha256:68408341f2312836fbbdf6b3c78047f65b2d8752f5fd221c3e72d348f5b34f8b \ + --hash=sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30 \ + --hash=sha256:6be943cb68bc6660ead58c55b3aa6366cba2ef7feb06460fbcce32360376f19f \ + --hash=sha256:7389b77ed2ab0552f46d9a90b81b7b8e6dfcdc42adc36c37a0865799843e0e3e \ + --hash=sha256:7f33a781e12b1e53b278deb2f5373c2e55ec4f10727be3440c0cfb5cda9f944f \ + --hash=sha256:83235693abde86e5e0129998f80ee39fc7f58e6d56a88fafb28a9278833e9d5f \ + --hash=sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f \ + --hash=sha256:89b193b255f4f6f7948dbcee3691f4f341ab05d9a8874a67b45ddb4182922eda \ + --hash=sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481 \ + --hash=sha256:9601a1e90be21e4884c53b4f3dc3ee0544654946f9975258d691f1c2e2f119c6 \ + --hash=sha256:96f4bdeea33a8d15a071dbfe6d119451b1d719c733ac666d65357082901a9099 \ + --hash=sha256:9a076f4fc5cdc43fdf510f5981418d25c2db4973418d9f22d8bb3dc8045ada78 \ + --hash=sha256:9fdf1c818ab05d0e74002091ddaf414478a3a449ec9d51c8976d45be7e3a01e2 \ + --hash=sha256:ac104be2768ffdd8655db9e71b768cbb45f2b9aa7b450cf1595e8f65d3822319 \ + --hash=sha256:ae30c6109848ac0f9fa36c5d6270938487614c47ba31860bd5361266dabc5685 \ + --hash=sha256:aee55e9041211bf84302ab55ec3965df18dd90ae19f8b58332a7feaf208bfe83 \ + --hash=sha256:b0a19dcf73406d3746d25a5ed42d713604c9a3e024d129b102852b0d941cb9f3 \ + --hash=sha256:b4c78ceb2f11bcac7389d305cda17aeb1f4586a857854ab5780bd3dd8dbfc407 \ + --hash=sha256:b7cf158e7add54a8d51ac9b5a84abd6d4e13ed4951b4f25f1c5139f41c2addb2 \ + --hash=sha256:b937b9dba5f5f6c1e31c47abe2186c865c0914fd18f2ce0dfc39c9adcef5951d \ + --hash=sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea \ + --hash=sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472 \ + --hash=sha256:c90be0b73568da4f662afac580956a76e308437e641b4a45aa08925eeb67d95f \ + --hash=sha256:d2ace7273b9a5061a3b420918a16fae1f2dc5dfee1abcc13aba71b5d94b1820c \ + --hash=sha256:dadfe80797174e2984aae3be0b77594a3c72d2c0a40fbd4a0de48d2728caf3ae \ + --hash=sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74 \ + --hash=sha256:e4b9ac2f1f607ecda2af90a5232beee2af7582fce1cc30c4b6a1b012dc21ee99 \ + --hash=sha256:f2912f647f3fbe1ccf085f91e213936f9101bead81a5e670565b1f1b3712f4fb + # via + # mediapipe + # pyautoflip +mediapipe==1.0.0 \ + --hash=sha256:01260f58fedd2cbebe69f4d4fcbc6327aba17090e19ef6a03c7afda084cd64e1 \ + --hash=sha256:07a449446bf888a8a2787dbf6fc1a33da4c47977313deec64d13c35bff41f6d2 \ + --hash=sha256:7ee4783be41b2de345e1eb71e2f7e7c159a50ed5c283e60ccb8f5a6027c70a82 \ + --hash=sha256:da57e6719bbab05007272c91d6ca2e0e2e370709491cbe344a372f87e25cf604 \ + --hash=sha256:e57d9a606723b2c77a51bb1d194c8eb736a84c552d24578a8536f47f656bb241 + # via pyautoflip +ml-dtypes==0.5.4 \ + --hash=sha256:0d2ffd05a2575b1519dc928c0b93c06339eb67173ff53acb00724502cda231cf \ + --hash=sha256:11942cbf2cf92157db91e5022633c0d9474d4dfd813a909383bd23ce828a4b7d \ + --hash=sha256:14a4fd3228af936461db66faccef6e4f41c1d82fcc30e9f8d58a08916b1d811f \ + --hash=sha256:19b9a53598f21e453ea2fbda8aa783c20faff8e1eeb0d7ab899309a0053f1483 \ + --hash=sha256:2314892cdc3fcf05e373d76d72aaa15fda9fb98625effa73c1d646f331fcecb7 \ + --hash=sha256:2b857d3af6ac0d39db1de7c706e69c7f9791627209c3d6dedbfca8c7e5faec22 \ + --hash=sha256:304ad47faa395415b9ccbcc06a0350800bc50eda70f0e45326796e27c62f18b6 \ + --hash=sha256:35f29491a3e478407f7047b8a4834e4640a77d2737e0b294d049746507af5175 \ + --hash=sha256:388d399a2152dd79a3f0456a952284a99ee5c93d3e2f8dfe25977511e0515270 \ + --hash=sha256:3bbbe120b915090d9dd1375e4684dd17a20a2491ef25d640a908281da85e73f1 \ + --hash=sha256:3d277bf3637f2a62176f4575512e9ff9ef51d00e39626d9fe4a161992f355af2 \ + --hash=sha256:4381fe2f2452a2d7589689693d3162e876b3ddb0a832cde7a414f8e1adf7eab1 \ + --hash=sha256:4ff7f3e7ca2972e7de850e7b8fcbb355304271e2933dd90814c1cb847414d6e2 \ + --hash=sha256:531eff30e4d368cb6255bc2328d070e35836aa4f282a0fb5f3a0cd7260257298 \ + --hash=sha256:533ce891ba774eabf607172254f2e7260ba5f57bdd64030c9a4fcfbd99815d0d \ + --hash=sha256:557a31a390b7e9439056644cb80ed0735a6e3e3bb09d67fd5687e4b04238d1de \ + --hash=sha256:5a0f68ca8fd8d16583dfa7793973feb86f2fbb56ce3966daf9c9f748f52a2049 \ + --hash=sha256:6a0df4223b514d799b8a1629c65ddc351b3efa833ccf7f8ea0cf654a61d1e35d \ + --hash=sha256:6c7ecb74c4bd71db68a6bea1edf8da8c34f3d9fe218f038814fd1d310ac76c90 \ + --hash=sha256:7c23c54a00ae43edf48d44066a7ec31e05fdc2eee0be2b8b50dd1903a1db94bb \ + --hash=sha256:805cef3a38f4eafae3a5bf9ebdcdb741d0bcfd9e1bd90eb54abd24f928cd2465 \ + --hash=sha256:88c982aac7cb1cbe8cbb4e7f253072b1df872701fcaf48d84ffbb433b6568f24 \ + --hash=sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453 \ + --hash=sha256:8c6a2dcebd6f3903e05d51960a8058d6e131fe69f952a5397e5dbabc841b6d56 \ + --hash=sha256:8c760d85a2f82e2bed75867079188c9d18dae2ee77c25a54d60e9cc79be1bc48 \ + --hash=sha256:9ad459e99793fa6e13bd5b7e6792c8f9190b4e5a1b45c63aba14a4d0a7f1d5ff \ + --hash=sha256:9bad06436568442575beb2d03389aa7456c690a5b05892c471215bfd8cf39460 \ + --hash=sha256:a174837a64f5b16cab6f368171a1a03a27936b31699d167684073ff1c4237dac \ + --hash=sha256:a7f7c643e8b1320fd958bf098aa7ecf70623a42ec5154e3be3be673f4c34d900 \ + --hash=sha256:a9b61c19040397970d18d7737375cffd83b1f36a11dd4ad19f83a016f736c3ef \ + --hash=sha256:b4b801ebe0b477be666696bda493a9be8356f1f0057a57f1e35cd26928823e5a \ + --hash=sha256:b95e97e470fe60ed493fd9ae3911d8da4ebac16bd21f87ffa2b7c588bf22ea2c \ + --hash=sha256:bc11d7e8c44a65115d05e2ab9989d1e045125d7be8e05a071a48bc76eb6d6040 \ + --hash=sha256:bfc534409c5d4b0bf945af29e5d0ab075eae9eecbb549ff8a29280db822f34f9 \ + --hash=sha256:c1a953995cccb9e25a4ae19e34316671e4e2edaebe4cf538229b1fc7109087b7 \ + --hash=sha256:cb73dccfc991691c444acc8c0012bee8f2470da826a92e3a20bb333b1a7894e6 \ + --hash=sha256:ce756d3a10d0c4067172804c9cc276ba9cc0ff47af9078ad439b075d1abdc29b \ + --hash=sha256:d81fdb088defa30eb37bf390bb7dde35d3a83ec112ac8e33d75ab28cc29dd8b0 \ + --hash=sha256:f21c9219ef48ca5ee78402d5cc831bd58ea27ce89beda894428bc67a52da5328 + # via onnx +mpmath==1.3.0 \ + --hash=sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f \ + --hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c + # via sympy +narwhals==2.24.0 \ + --hash=sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489 \ + --hash=sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d + # via scikit-learn +networkx==3.6.1 \ + --hash=sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509 \ + --hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762 + # via + # scikit-image + # torch +numpy==2.5.2 \ + --hash=sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a \ + --hash=sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f \ + --hash=sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7 \ + --hash=sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0 \ + --hash=sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3 \ + --hash=sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c \ + --hash=sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce \ + --hash=sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8 \ + --hash=sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1 \ + --hash=sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4 \ + --hash=sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee \ + --hash=sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740 \ + --hash=sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98 \ + --hash=sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710 \ + --hash=sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee \ + --hash=sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68 \ + --hash=sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf \ + --hash=sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8 \ + --hash=sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf \ + --hash=sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b \ + --hash=sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884 \ + --hash=sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03 \ + --hash=sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69 \ + --hash=sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4 \ + --hash=sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842 \ + --hash=sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65 \ + --hash=sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080 \ + --hash=sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e \ + --hash=sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e \ + --hash=sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414 \ + --hash=sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59 \ + --hash=sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8 \ + --hash=sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617 \ + --hash=sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4 \ + --hash=sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb \ + --hash=sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251 \ + --hash=sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d \ + --hash=sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2 \ + --hash=sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab \ + --hash=sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657 \ + --hash=sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15 \ + --hash=sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9 \ + --hash=sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8 \ + --hash=sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323 \ + --hash=sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788 \ + --hash=sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc \ + --hash=sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56 \ + --hash=sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1 \ + --hash=sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d \ + --hash=sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec \ + --hash=sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2 \ + --hash=sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e \ + --hash=sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7 \ + --hash=sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26 \ + --hash=sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514 \ + --hash=sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860 \ + --hash=sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a \ + --hash=sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1 \ + --hash=sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab \ + --hash=sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba \ + --hash=sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12 \ + --hash=sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6 \ + --hash=sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e \ + --hash=sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac \ + --hash=sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb \ + --hash=sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f + # via + # contourpy + # imageio + # insightface + # matplotlib + # mediapipe + # ml-dtypes + # onnx + # onnxruntime + # opencv-contrib-python + # opencv-python + # pyautoflip + # scenedetect + # scikit-image + # scikit-learn + # scipy + # tensorboard + # tensorboardx + # tifffile + # torchvision +nvidia-cublas==13.1.1.3 ; sys_platform == 'linux' \ + --hash=sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436 \ + --hash=sha256:b6cdce694e47ff6aadf0a69df1cab6628d696f5ff56e8d16af50309d855fa20f \ + --hash=sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5 + # via + # cuda-toolkit + # nvidia-cudnn-cu13 + # nvidia-cusolver +nvidia-cuda-cupti==13.0.85 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ + --hash=sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8 \ + --hash=sha256:683f58d301548deeefcb8f6fac1b8d907691b9d8b18eccab417f51e362102f00 \ + --hash=sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151 + # via cuda-toolkit +nvidia-cuda-nvrtc==13.0.88 ; sys_platform == 'linux' \ + --hash=sha256:6bcd4e7f8e205cbe644f5a98f2f799bef9556fefc89dd786e79a16312ce49872 \ + --hash=sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575 \ + --hash=sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b + # via + # cuda-toolkit + # nvidia-cublas +nvidia-cuda-runtime==13.0.96 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ + --hash=sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548 \ + --hash=sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55 \ + --hash=sha256:f79298c8a098cec150a597c8eba58ecdab96e3bdc4b9bc4f9983635031740492 + # via cuda-toolkit +nvidia-cudnn-cu13==9.20.0.48 ; sys_platform == 'linux' \ + --hash=sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304 \ + --hash=sha256:af8139732b99c0118be65ea5aac97f0d46018f8c552889e49d2fb0c6261a4a24 \ + --hash=sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1 + # via torch +nvidia-cufft==12.0.0.61 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ + --hash=sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5 \ + --hash=sha256:2abce5b39d2f5ae12730fb7e5db6696533e36c26e2d3e8fd1750bdd2853364eb \ + --hash=sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3 + # via cuda-toolkit +nvidia-cufile==1.15.1.6 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ + --hash=sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44 \ + --hash=sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1 + # via cuda-toolkit +nvidia-curand==10.4.0.35 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ + --hash=sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a \ + --hash=sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc \ + --hash=sha256:65b1710aa6961d326b411e314b374290904c5ddf41dc3f766ebc3f1d7d4ca69f + # via cuda-toolkit +nvidia-cusolver==12.0.4.66 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ + --hash=sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2 \ + --hash=sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112 \ + --hash=sha256:16515bd33a8e76bb54d024cfa068fa68d30e80fc34b9e1090813ea9362e0cb65 + # via cuda-toolkit +nvidia-cusparse==12.6.3.3 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ + --hash=sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b \ + --hash=sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c \ + --hash=sha256:cbcf42feb737bd7ec15b4c0a63e62351886bd3f975027b8815d7f720a2b5ea79 + # via + # cuda-toolkit + # nvidia-cusolver +nvidia-cusparselt-cu13==0.8.1 ; sys_platform == 'linux' \ + --hash=sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f \ + --hash=sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0 \ + --hash=sha256:dccbd362f91a7b9024d1f55ee9f548ac065027ff15d8c8b0db889ab3a8f31215 + # via torch +nvidia-nccl-cu13==2.29.7 ; sys_platform == 'linux' \ + --hash=sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5 \ + --hash=sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d + # via torch +nvidia-nvjitlink==13.3.33 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ + --hash=sha256:26a6de7fb4c8fdaa7703d3dad720d6d427ddfea5c48a528fd97c11733ad830e5 \ + --hash=sha256:4297ee49639b4f2e07255a1d69b3acc7ab2d011bb892b403e91ac98368962e3b \ + --hash=sha256:ce48b37dfeb3cb1eae4cf85adacb47d7a6539ea2272870c9a3628ce275c2037e + # via + # cuda-toolkit + # nvidia-cufft + # nvidia-cusolver + # nvidia-cusparse +nvidia-nvshmem-cu13==3.4.5 ; sys_platform == 'linux' \ + --hash=sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80 \ + --hash=sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9 + # via torch +nvidia-nvtx==13.0.85 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ + --hash=sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4 \ + --hash=sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6 \ + --hash=sha256:d66ea44254dd3c6eacc300047af6e1288d2269dd072b417e0adffbf479e18519 + # via cuda-toolkit +onnx==1.22.0 \ + --hash=sha256:19e45e4af88e3fe3261458d4b8cc461957ae2782a358a3560503569bf3b23b72 \ + --hash=sha256:1d0a2bdb15eb2b3cb65c438f3423d9620d14fdce32f92380e6bb1b2e09568ef5 \ + --hash=sha256:239958534464612fbcb6ed23d5228aaa925b39b8773f58726809ffdccb4edd1c \ + --hash=sha256:2632406b8f523ef2e2873c363f90b20a3d88c0fbcfac757d3addffccf8f452c2 \ + --hash=sha256:2d8f229a553fa440fe623ed7b36fca5e7762da3af871c3f8f8ce451df73e2914 \ + --hash=sha256:33ce94119bbb7f05d9caea4ea7549f5185a54369f6bbc9f70171bd5ee6935bbc \ + --hash=sha256:596fbf0490947533c1c1045ba860851dc9fb77471023dac9a71ba5b42ceab103 \ + --hash=sha256:5c1c0408a9d4b4df33851672e5fc7590b96301ee123396d608f9ab6f045ab06b \ + --hash=sha256:6d0ffffd63a4ecc21ddaeddd5bf02099cb701aa4243f2de00122726869065ca4 \ + --hash=sha256:72ccebab3bac07215c204ce8848d42e78eaaa666badbf72d25cd359b9f269e3a \ + --hash=sha256:82e9f27fc1223cb06d68a56bed6f9d3caf3d0dad1b61bce45006d529b15bd94c \ + --hash=sha256:8561a2c00041c07e08db0c228593b5b4694100398685f348532af7dbb84189da \ + --hash=sha256:87a3077958f66f9a26dec10077ac28326d9cec2cbe1f0b040947243449754573 \ + --hash=sha256:8907b9b9389893bc0dc6314cc00ee1e3a69844e48d689eacc6a0340411a7da58 \ + --hash=sha256:8a5eccce2d5fc6c5046928a9aa7cdd9750ea4a586f8de341d3d40d820c35fdec \ + --hash=sha256:8e268cdc0547e3949799ffd4a44451dc2b9080b57d0824a2db680b6ec65506f0 \ + --hash=sha256:955e02e1f6d385b53d52f9cd7b9cdf5caf417c300bcfe3c64c6d542be763845b \ + --hash=sha256:a1a89a7cb9ba13d78f009bdec448ec82a98972589734f157022a2bff7a5973a6 \ + --hash=sha256:a3a39fc4643867aecb33417fdddb11e308ee79d2d4a584b9d50cc7aec2091b13 \ + --hash=sha256:ae5a563f281cd9d2845622cecf6c092a57e4ee1b138f66fdbbdd4200567a5e16 \ + --hash=sha256:c21a0e59fd967a95b358e4a6e756d1f1eec2d304a83480f329f66e30d2bf0223 \ + --hash=sha256:cc8b66b312f8f03a53e268afb67180a2d97dd12cc79e2b61361c6c0073448016 \ + --hash=sha256:ef40c0aaf0b643857ea9306fc7eddce17eaf9fb0407e4801f1fc5758443a38e0 \ + --hash=sha256:f3c120dcdb70ad738f3c061b32798f408ea299eb69f84dd69ab4a6bf3c2ec01f + # via insightface +onnxruntime==1.28.0 \ + --hash=sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f \ + --hash=sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c \ + --hash=sha256:0d650aeee29368414367b65529e90afe4bf1bab76254789063b8b2f7ea3013c8 \ + --hash=sha256:0faf85fb447a663c9cdadc39bd6b19bdf7bedded6699e45731b9b36c46fd993d \ + --hash=sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58 \ + --hash=sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da \ + --hash=sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e \ + --hash=sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c \ + --hash=sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e \ + --hash=sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe \ + --hash=sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a \ + --hash=sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f \ + --hash=sha256:8d66f9ceb29909c70839e4e4fb3435c7b490050d8f162bd5f3aba4ca01ee517f \ + --hash=sha256:a166b78ee04f3a37fa1ef82034b6a3ce96d9684e582d4d30b296de83e9998bb5 \ + --hash=sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031 \ + --hash=sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b \ + --hash=sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8 \ + --hash=sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7 \ + --hash=sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02 \ + --hash=sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af \ + --hash=sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5 \ + --hash=sha256:f5c5daabd28aad610f83fdcf32acec8fb57e6adc6c6a39fe2a3c755db957b410 \ + --hash=sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135 \ + --hash=sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28 + # via + # insightface + # pyautoflip +opencv-contrib-python==5.0.0.93 \ + --hash=sha256:29b916da864002a921c79b6df4cfb2dcf79870f166ddf3ce1179b558bc7313d7 \ + --hash=sha256:3ebb8e0506573f36f54038116321a086c97aeda8ef154198931f3ea18b435cc7 \ + --hash=sha256:45a1ce7c68828907348e649edd471a7dc5244d3d0ac21989e67c63d2f4fa629a \ + --hash=sha256:461622db95c964652d4d8fda171034961c3de270f78a6095aaad31050771774a \ + --hash=sha256:8427dcb0561dc3ba32f3771c627f52b87c29932c265bea28ffcb54804c3c3fec \ + --hash=sha256:b84f0b0fcdbd2421b5819e517542463e71eed7f41e0a0c4ec280ed88b7269a66 \ + --hash=sha256:cac609c9a4fce67feb287837c671a2c4da9467df8e47fa0cce7bdc453d12a529 \ + --hash=sha256:da0ba61096b08c63cb4440d8fd6f323835cbb78b714c5ba22a39e1db68c83166 \ + --hash=sha256:dd8a80a04a8610c033757135b7846ad2027ba2d3bf9faf2f83f3dc6ed9e3814a + # via mediapipe +opencv-python==5.0.0.93 \ + --hash=sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2 \ + --hash=sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898 \ + --hash=sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157 \ + --hash=sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2 \ + --hash=sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b \ + --hash=sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039 \ + --hash=sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac \ + --hash=sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881 \ + --hash=sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2 + # via + # insightface + # pyautoflip + # scenedetect +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via + # lazy-loader + # matplotlib + # onnxruntime + # scikit-image + # tensorboard + # tensorboardx +pillow==12.3.0 \ + --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ + --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ + --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ + --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ + --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ + --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ + --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ + --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ + --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ + --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ + --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ + --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ + --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ + --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ + --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ + --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ + --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ + --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ + --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ + --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ + --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ + --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ + --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ + --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ + --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ + --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ + --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ + --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ + --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ + --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ + --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ + --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ + --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ + --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ + --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ + --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ + --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ + --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ + --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ + --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ + --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ + --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ + --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ + --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ + --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ + --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ + --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ + --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ + --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ + --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ + --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ + --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ + --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ + --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ + --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ + --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ + --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ + --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ + --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ + --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ + --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ + --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ + --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ + --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ + --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ + --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ + --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ + --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ + --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ + --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ + --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ + --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ + --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ + --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ + --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ + --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ + --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ + --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ + --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ + --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ + --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ + --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ + --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ + --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ + --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ + --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ + --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 + # via + # imageio + # matplotlib + # pyautoflip + # scikit-image + # tensorboard + # torchvision +platformdirs==4.11.2 \ + --hash=sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d \ + --hash=sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4 + # via scenedetect +protobuf==7.35.1 \ + --hash=sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799 \ + --hash=sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87 \ + --hash=sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6 \ + --hash=sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30 \ + --hash=sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9 \ + --hash=sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4 \ + --hash=sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4 \ + --hash=sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a + # via + # onnx + # onnxruntime + # tensorboard + # tensorboardx +pyautoflip==0.2.1 \ + --hash=sha256:34dfcc6cfbf3c142c927345f18baf5f16219b017997c247ed69bb5205130b629 \ + --hash=sha256:da2cfea93e62b9cf0265c596faa174d7aad65a9800851a8dac0732fd631ee96e + # via -r requirements.in +pycparser==3.0 ; implementation_name != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pydantic==2.11.3 \ + --hash=sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3 \ + --hash=sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f + # via + # -r requirements.in + # fastapi +pydantic-core==2.33.1 \ + --hash=sha256:0483847fa9ad5e3412265c1bd72aad35235512d9ce9d27d81a56d935ef489672 \ + --hash=sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1 \ + --hash=sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add \ + --hash=sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068 \ + --hash=sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b \ + --hash=sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505 \ + --hash=sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8 \ + --hash=sha256:177d50460bc976a0369920b6c744d927b0ecb8606fb56858ff542560251b19e5 \ + --hash=sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e \ + --hash=sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544 \ + --hash=sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4 \ + --hash=sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a \ + --hash=sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a \ + --hash=sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1 \ + --hash=sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266 \ + --hash=sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83 \ + --hash=sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764 \ + --hash=sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde \ + --hash=sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26 \ + --hash=sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896 \ + --hash=sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18 \ + --hash=sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939 \ + --hash=sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48 \ + --hash=sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a \ + --hash=sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761 \ + --hash=sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7 \ + --hash=sha256:3f1fdb790440a34f6ecf7679e1863b825cb5ffde858a9197f851168ed08371e5 \ + --hash=sha256:3f2648b9262607a7fb41d782cc263b48032ff7a03a835581abbf7a3bec62bcf5 \ + --hash=sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d \ + --hash=sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e \ + --hash=sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3 \ + --hash=sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db \ + --hash=sha256:5277aec8d879f8d05168fdd17ae811dd313b8ff894aeeaf7cd34ad28b4d77e33 \ + --hash=sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850 \ + --hash=sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde \ + --hash=sha256:5773da0ee2d17136b1f1c6fbde543398d452a6ad2a7b54ea1033e2daa739b8d2 \ + --hash=sha256:5ab77f45d33d264de66e1884fca158bc920cb5e27fd0764a72f72f5756ae8bdb \ + --hash=sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02 \ + --hash=sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c \ + --hash=sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77 \ + --hash=sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504 \ + --hash=sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516 \ + --hash=sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24 \ + --hash=sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a \ + --hash=sha256:723c5630c4259400818b4ad096735a829074601805d07f8cafc366d95786d331 \ + --hash=sha256:7965c13b3967909a09ecc91f21d09cfc4576bf78140b988904e94f130f188396 \ + --hash=sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c \ + --hash=sha256:7edbc454a29fc6aeae1e1eecba4f07b63b8d76e76a748532233c4c167b4cb9ea \ + --hash=sha256:7fb66263e9ba8fea2aa85e1e5578980d127fb37d7f2e292773e7bc3a38fb0c7b \ + --hash=sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969 \ + --hash=sha256:8ab581d3530611897d863d1a649fb0644b860286b4718db919bfd51ece41f10b \ + --hash=sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea \ + --hash=sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927 \ + --hash=sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc \ + --hash=sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e \ + --hash=sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595 \ + --hash=sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d \ + --hash=sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498 \ + --hash=sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe \ + --hash=sha256:9f466e8bf0a62dc43e068c12166281c2eca72121dd2adc1040f3aa1e21ef8599 \ + --hash=sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e \ + --hash=sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89 \ + --hash=sha256:a3edde68d1a1f9af1273b2fe798997b33f90308fb6d44d8550c89fc6a3647cf6 \ + --hash=sha256:a62c3c3ef6a7e2c45f7853b10b5bc4ddefd6ee3cd31024754a1a5842da7d598d \ + --hash=sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523 \ + --hash=sha256:ab0277cedb698749caada82e5d099dc9fed3f906a30d4c382d1a21725777a1e5 \ + --hash=sha256:ad05b683963f69a1d5d2c2bdab1274a31221ca737dbbceaa32bcb67359453cdd \ + --hash=sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d \ + --hash=sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a \ + --hash=sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe \ + --hash=sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df \ + --hash=sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c \ + --hash=sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30 \ + --hash=sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e \ + --hash=sha256:c91dbb0ab683fa0cd64a6e81907c8ff41d6497c346890e26b23de7ee55353f96 \ + --hash=sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f \ + --hash=sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3 \ + --hash=sha256:d100e3ae783d2167782391e0c1c7a20a31f55f8015f3293647544df3f9c67824 \ + --hash=sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde \ + --hash=sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d \ + --hash=sha256:de9e06abe3cc5ec6a2d5f75bc99b0bdca4f5c719a5b34026f8c57efbdecd2ee3 \ + --hash=sha256:df6a94bf9452c6da9b5d76ed229a5683d0306ccb91cca8e1eea883189780d568 \ + --hash=sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961 \ + --hash=sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4 \ + --hash=sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda \ + --hash=sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5 \ + --hash=sha256:e7aaba1b4b03aaea7bb59e1b5856d734be011d3e6d98f5bcaa98cb30f375f2ad \ + --hash=sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db \ + --hash=sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd \ + --hash=sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383 \ + --hash=sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40 \ + --hash=sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f \ + --hash=sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b \ + --hash=sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc \ + --hash=sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5 \ + --hash=sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65 \ + --hash=sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39 \ + --hash=sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89 \ + --hash=sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091 + # via pydantic +pyparsing==3.3.2 \ + --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \ + --hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc + # via matplotlib +python-dateutil==2.9.0.post0 \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + # via matplotlib +python-dotenv==1.2.2 \ + --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ + --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 + # via uvicorn +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via uvicorn +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed + # via insightface +scenedetect==0.7.1 \ + --hash=sha256:2bc082056f231d35f9d5eed6d8077a5d417c77c2787d965d7f03be3802cb6f7f \ + --hash=sha256:91b67902275b2e0d29a12f6d70435f04e0bd7852a6dc8a69c901c6069328dffa + # via pyautoflip +scikit-image==0.26.0 \ + --hash=sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf \ + --hash=sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650 \ + --hash=sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb \ + --hash=sha256:0baa0108d2d027f34d748e84e592b78acc23e965a5de0e4bb03cf371de5c0581 \ + --hash=sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f \ + --hash=sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9 \ + --hash=sha256:21a818ee6ca2f2131b9e04d8eb7637b5c18773ebe7b399ad23dcc5afaa226d2d \ + --hash=sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e \ + --hash=sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2 \ + --hash=sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394 \ + --hash=sha256:3409e89d66eff5734cd2b672d1c48d2759360057e714e1d92a11df82c87cba37 \ + --hash=sha256:3f5bf622d7c0435884e1e141ebbe4b2804e16b2dd23ae4c6183e2ea99233be70 \ + --hash=sha256:4c717490cec9e276afb0438dd165b7c3072d6c416709cc0f9f5a4c1070d23a44 \ + --hash=sha256:4d57e39ef67a95d26860c8caf9b14b8fb130f83b34c6656a77f191fa6d1d04d8 \ + --hash=sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc \ + --hash=sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab \ + --hash=sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c \ + --hash=sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3 \ + --hash=sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59 \ + --hash=sha256:74aa5518ccea28121f57a95374581d3b979839adc25bb03f289b1bc9b99c58af \ + --hash=sha256:7af7aa331c6846bd03fa28b164c18d0c3fd419dbb888fb05e958ac4257a78fdd \ + --hash=sha256:7df650e79031634ac90b11e64a9eedaf5a5e06fcd09bcd03a34be01745744466 \ + --hash=sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21 \ + --hash=sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37 \ + --hash=sha256:9490360c8d3f9a7e85c8de87daf7c0c66507960cf4947bb9610d1751928721c7 \ + --hash=sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5 \ + --hash=sha256:9ea6207d9e9d21c3f464efe733121c0504e494dbdc7728649ff3e23c3c5a4953 \ + --hash=sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49 \ + --hash=sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604 \ + --hash=sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219 \ + --hash=sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7 \ + --hash=sha256:abed017474593cd3056ae0fe948d07d0747b27a085e92df5474f4955dd65aec0 \ + --hash=sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1 \ + --hash=sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd \ + --hash=sha256:b1ede33a0fb3731457eaf53af6361e73dd510f449dac437ab54573b26788baf0 \ + --hash=sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b \ + --hash=sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4 \ + --hash=sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f \ + --hash=sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505 \ + --hash=sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a \ + --hash=sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1 \ + --hash=sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd \ + --hash=sha256:cefd85033e66d4ea35b525bb0937d7f42d4cdcfed2d1888e1570d5ce450d3932 \ + --hash=sha256:d454b93a6fa770ac5ae2d33570f8e7a321bb80d29511ce4b6b78058ebe176e8c \ + --hash=sha256:d5c244656de905e195a904e36dbc18585e06ecf67d90f0482cbde63d7f9ad59d \ + --hash=sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578 \ + --hash=sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa \ + --hash=sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331 \ + --hash=sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62 + # via + # insightface + # pyautoflip +scikit-learn==1.9.0 \ + --hash=sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa \ + --hash=sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8 \ + --hash=sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b \ + --hash=sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42 \ + --hash=sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb \ + --hash=sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2 \ + --hash=sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60 \ + --hash=sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac \ + --hash=sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28 \ + --hash=sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05 \ + --hash=sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283 \ + --hash=sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949 \ + --hash=sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913 \ + --hash=sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277 \ + --hash=sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713 \ + --hash=sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a \ + --hash=sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1 \ + --hash=sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759 \ + --hash=sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f \ + --hash=sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673 \ + --hash=sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666 \ + --hash=sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119 \ + --hash=sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557 \ + --hash=sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162 \ + --hash=sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b \ + --hash=sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e \ + --hash=sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714 \ + --hash=sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96 \ + --hash=sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c \ + --hash=sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8 \ + --hash=sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa + # via pyautoflip +scipy==1.18.0 \ + --hash=sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446 \ + --hash=sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468 \ + --hash=sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b \ + --hash=sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553 \ + --hash=sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0 \ + --hash=sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7 \ + --hash=sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2 \ + --hash=sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b \ + --hash=sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61 \ + --hash=sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8 \ + --hash=sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8 \ + --hash=sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6 \ + --hash=sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690 \ + --hash=sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de \ + --hash=sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578 \ + --hash=sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab \ + --hash=sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce \ + --hash=sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f \ + --hash=sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520 \ + --hash=sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378 \ + --hash=sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197 \ + --hash=sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709 \ + --hash=sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132 \ + --hash=sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867 \ + --hash=sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a \ + --hash=sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677 \ + --hash=sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4 \ + --hash=sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0 \ + --hash=sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58 \ + --hash=sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8 \ + --hash=sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76 \ + --hash=sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9 \ + --hash=sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f \ + --hash=sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11 \ + --hash=sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4 \ + --hash=sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b \ + --hash=sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b \ + --hash=sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76 \ + --hash=sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f \ + --hash=sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d \ + --hash=sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707 + # via + # insightface + # pyautoflip + # scikit-image + # scikit-learn +setuptools==84.0.0 \ + --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \ + --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73 + # via + # tensorboard + # torch +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ + --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 + # via python-dateutil +sounddevice==0.5.5 \ + --hash=sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722 \ + --hash=sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103 \ + --hash=sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3 \ + --hash=sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f \ + --hash=sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6 \ + --hash=sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519 + # via mediapipe +starlette==0.46.2 \ + --hash=sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35 \ + --hash=sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5 + # via fastapi +sympy==1.14.0 \ + --hash=sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517 \ + --hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 + # via torch +tensorboard==2.21.0 \ + --hash=sha256:7279316dcb6bd5bc391d623dea841531299cde1887310e8133bc34a996d32255 + # via pyautoflip +tensorboard-data-server==0.7.2 \ + --hash=sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb \ + --hash=sha256:9fe5d24221b29625dbc7328b0436ca7fc1c23de4acf4d272f1180856e32f9f60 \ + --hash=sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530 + # via tensorboard +tensorboardx==2.6.5 \ + --hash=sha256:c10b891d00af306537cb8b58a039b2ba41571f0da06f433a41c4ca8d6abe1373 \ + --hash=sha256:ca176db3997ee8c07d2eb77381225956a3fd1c10c91beafab1f17069adc47017 + # via pyautoflip +threadpoolctl==3.6.0 \ + --hash=sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb \ + --hash=sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e + # via scikit-learn +tifffile==2026.7.31 \ + --hash=sha256:79b1f4b1aba3ef3e6b6f1691a32abb62f5d7383faa52a12771c695232ba40bee \ + --hash=sha256:81adfa08012be1c478f99b83cda2f529eef8620cfbdf94fc41eef6f1d7b47dc5 + # via scikit-image +torch==2.13.0 \ + --hash=sha256:024c6cc0c1b085f2f91f20a3dc27b0471d021c31ce84b81be3afdc39f791fd9d \ + --hash=sha256:092790c696a760c729fd5722835f50b9d81fd7c8f141571f3f3cf4081a8f664c \ + --hash=sha256:0ab4b69f3ee03a62a002cfbf77b1ca5e88aceb4ea64cb4388bb28f638ddbb045 \ + --hash=sha256:1e09d6a722504957c694faceca843acde562786df1144ebcc5a74075ec7f6005 \ + --hash=sha256:2bd30b6b730d987fa386ce3898933762c5cb8cc82eb0535211d787cc3ce2dfeb \ + --hash=sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027 \ + --hash=sha256:31061ff56ed8fbf26c749806905aeb749ebeb819810fd5d52508aa5afd90dddc \ + --hash=sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09 \ + --hash=sha256:49b58f1e2c52440abb6f17c28f0335fe6c6d01ad1a7f55b0183b81e4b34d64e6 \ + --hash=sha256:49f1ea385c754e54919408a9bb3b5a72b0b755bbe2c916c1d6f70afbec4908a2 \ + --hash=sha256:4f8573e3ce9ebcd53fe922f01077a6085ccdfbe5f12fd215883a9d87d7a744fd \ + --hash=sha256:572df8be8ffb4599c88cbd6a0726f1f854f4da65d2e3c09f0e2c2283333cd6d4 \ + --hash=sha256:60fcdcb2f3876e21146cb4524ef06397d727ca9ad5f020818547e25075fe3cb7 \ + --hash=sha256:796633c4cdf0fe2cdced72d8f88f22e73dbcfce83132763162f6d4bff13b820b \ + --hash=sha256:94f0de129916f77b8dc2c7a8eff644cfeddfe59e39c9f55e9f6e17543410281d \ + --hash=sha256:a0d8b11f16a48d60e2015d8213aa0390744cbebb98e58b62b3514dddc656e330 \ + --hash=sha256:a3893dc2da0a972a8ca5d698c85a9f967559ac5f8ee1797b77408aa8734d073c \ + --hash=sha256:a3a9a21312872af8a26950b2c15680335a386a1f56ed03e780653d78b9607e9e \ + --hash=sha256:a7de8a313090dc5c7d7ba4bfe5c3be222528f9a4dba1acc83bddb1157360c4b8 \ + --hash=sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1 \ + --hash=sha256:c78b7b4d04461855a764cf01bae9a462bb88bc93defcfa11235cbc8fdf3e12c4 \ + --hash=sha256:cc26eead4cf51d0b544e31e364dcf000846549c273bd148936fe9d24d29acb92 \ + --hash=sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c \ + --hash=sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8 + # via + # pyautoflip + # torchvision +torchvision==0.28.0 \ + --hash=sha256:028a3d481b37d785605620d7cdad897064c5a55bae2aa1f2658766333e291940 \ + --hash=sha256:09ce8f56e81f19b9c378ae7bb109f83f6659fd8bc3cd14241a48e4af46e9ed49 \ + --hash=sha256:2a1ef4b6f4bf5828b48cfad97372c8982db906830884b2868ba5c3df937a7d81 \ + --hash=sha256:3557cc7b539f46dabcda2b6f2b14017ccbeef024de466d4fc5835fc3f287f769 \ + --hash=sha256:36beb0782976906069ca03d4c9aacaf4b6b838b06ed6c20960ea9c51cce7acdd \ + --hash=sha256:3bd9dba55224a9db4a2d77f6feaa5651770d8c8e86d3d0ddb0fa6bec54c8712b \ + --hash=sha256:46f581979c010ad6da6bd85ee602aa707e1ff44312670223b7a0ee517ad06d47 \ + --hash=sha256:546fd85345cf8652f6cd099d4f9884b0ca5c2f3fae78689a21dd2f35ea6b622f \ + --hash=sha256:5a38bc6da3d72621be003400b66f66a2b4c6d644fde05f680c2cb7ca8cf8dd6c \ + --hash=sha256:5cf78ebc401ce64ae19b8c55de866bb836797d559a4de9c25ccbe74cfa642d3a \ + --hash=sha256:62c7d110f86a039245b587e4fae60278c649f3bd42ff79cfbc1178eca4e72542 \ + --hash=sha256:6dfb0f45e2b4ceb4e76f158c3fbb5f44387099f3c466e3423a09ab665a194aba \ + --hash=sha256:7e80f543b22503d9415e126db5f0ff3917036925e38560ee6b9ae38c571a4002 \ + --hash=sha256:7e9dd6f60d6e15f8dc27d4f877fdb6002fc70d70272412135f1c2ff9cfa08d3b \ + --hash=sha256:7fad44dc9582570c7d92c4487d36ac46998f40cc39b438e8b8f5111a935ce4e8 \ + --hash=sha256:83fe6c020866a85acd7d97deccc45ff11d66daf42916d04396a4309c66c0ccb8 \ + --hash=sha256:87dc16b2df427c1318ad335f1e2be2b3b15b2cf20f7934c83b0505a48425ee5d \ + --hash=sha256:89f90e29b0966352811b12589f3a3c61943bf2bb9487b9d7bbec10efb1096bb5 \ + --hash=sha256:904cf89af220f8c6b2ed0296bb5065b474ce43b77558e48b2bf9de8b0ba17204 \ + --hash=sha256:9a45ea67235d965ef52187130d20002a4de20c54ea3d927a24286961d268dc37 \ + --hash=sha256:ad7b3a439265cc3739a4ab5b4c998c0e38ea99c0ee7ca4dea35c5d0b099ec237 \ + --hash=sha256:bb6dd6918460ed89cc7644adcc2402991474d6933cf1ce92b390641cb233fddf \ + --hash=sha256:d483b4aa3f5237569053f749cd1a2b5bb548ca456e40461a5dd087f21149d123 \ + --hash=sha256:e9f54c30cd52e3ef7fd034cc69b7bb7e0964e1c8f8743e018ab92e95b40f9eee + # via pyautoflip +tqdm==4.70.0 \ + --hash=sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220 \ + --hash=sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953 + # via + # insightface + # pyautoflip + # scenedetect +triton==3.7.1 ; python_full_version < '3.15' and sys_platform == 'linux' \ + --hash=sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68 \ + --hash=sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2 \ + --hash=sha256:3daf64305d6cea88d3334c65ebc9bcd0c64c9564a977084366aa768d57cbcf64 \ + --hash=sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb \ + --hash=sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5 \ + --hash=sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728 \ + --hash=sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1 \ + --hash=sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7 \ + --hash=sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a \ + --hash=sha256:d4a0e1cd4c4a76370ed74a8432a53cea28716827d19e40ffc732233e35ceb3f6 \ + --hash=sha256:ee89fbf782ec2ad50391dd1cf26cbea4f4467154c37f4773026da8fc31c0f58e \ + --hash=sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa + # via torch +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anyio + # fastapi + # grpcio + # onnx + # pydantic + # pydantic-core + # torch + # typing-inspection +typing-inspection==0.4.3 \ + --hash=sha256:5f42b23858a91e0b4ef521f5418f03a0da3c9216fd2995ef5e73463100e676cd \ + --hash=sha256:c5f9ec1530b5c1e2c9bc34a84d9a3466ed1b2f3f2fa9f901368d9c5596210e4d + # via pydantic +urllib3==2.7.0 \ + --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 + # via requests +uvicorn==0.34.2 \ + --hash=sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328 \ + --hash=sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403 + # via -r requirements.in +uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' \ + --hash=sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772 \ + --hash=sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e \ + --hash=sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743 \ + --hash=sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54 \ + --hash=sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec \ + --hash=sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659 \ + --hash=sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8 \ + --hash=sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad \ + --hash=sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7 \ + --hash=sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35 \ + --hash=sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289 \ + --hash=sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142 \ + --hash=sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77 \ + --hash=sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733 \ + --hash=sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd \ + --hash=sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193 \ + --hash=sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74 \ + --hash=sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0 \ + --hash=sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6 \ + --hash=sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473 \ + --hash=sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21 \ + --hash=sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242 \ + --hash=sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705 \ + --hash=sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702 \ + --hash=sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6 \ + --hash=sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f \ + --hash=sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e \ + --hash=sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d \ + --hash=sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370 \ + --hash=sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4 \ + --hash=sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792 \ + --hash=sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa \ + --hash=sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079 \ + --hash=sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2 \ + --hash=sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86 \ + --hash=sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6 \ + --hash=sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4 \ + --hash=sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3 \ + --hash=sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21 \ + --hash=sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c \ + --hash=sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e \ + --hash=sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25 \ + --hash=sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820 \ + --hash=sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9 \ + --hash=sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88 \ + --hash=sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2 \ + --hash=sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c \ + --hash=sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c \ + --hash=sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42 + # via uvicorn +watchfiles==1.2.0 \ + --hash=sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9 \ + --hash=sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98 \ + --hash=sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551 \ + --hash=sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d \ + --hash=sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7 \ + --hash=sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db \ + --hash=sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69 \ + --hash=sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242 \ + --hash=sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925 \ + --hash=sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f \ + --hash=sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5 \ + --hash=sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5 \ + --hash=sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427 \ + --hash=sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19 \ + --hash=sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4 \ + --hash=sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e \ + --hash=sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa \ + --hash=sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba \ + --hash=sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df \ + --hash=sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c \ + --hash=sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906 \ + --hash=sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65 \ + --hash=sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c \ + --hash=sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c \ + --hash=sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30 \ + --hash=sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077 \ + --hash=sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374 \ + --hash=sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01 \ + --hash=sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33 \ + --hash=sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831 \ + --hash=sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9 \ + --hash=sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2 \ + --hash=sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b \ + --hash=sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f \ + --hash=sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658 \ + --hash=sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579 \ + --hash=sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5 \ + --hash=sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0 \ + --hash=sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7 \ + --hash=sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666 \ + --hash=sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5 \ + --hash=sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201 \ + --hash=sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103 \ + --hash=sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6 \ + --hash=sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8 \ + --hash=sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1 \ + --hash=sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631 \ + --hash=sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898 \ + --hash=sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d \ + --hash=sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44 \ + --hash=sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2 \ + --hash=sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5 \ + --hash=sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a \ + --hash=sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1 \ + --hash=sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b \ + --hash=sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc \ + --hash=sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5 \ + --hash=sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377 \ + --hash=sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8 \ + --hash=sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add \ + --hash=sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281 \ + --hash=sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9 \ + --hash=sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994 \ + --hash=sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0 \ + --hash=sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e \ + --hash=sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0 \ + --hash=sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28 \ + --hash=sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7 \ + --hash=sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55 \ + --hash=sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb \ + --hash=sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07 \ + --hash=sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb \ + --hash=sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4 \ + --hash=sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0 \ + --hash=sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e \ + --hash=sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4 \ + --hash=sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9 \ + --hash=sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06 \ + --hash=sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26 \ + --hash=sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7 \ + --hash=sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4 \ + --hash=sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3 \ + --hash=sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3 \ + --hash=sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838 \ + --hash=sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71 \ + --hash=sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488 \ + --hash=sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717 \ + --hash=sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d \ + --hash=sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44 \ + --hash=sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2 \ + --hash=sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b \ + --hash=sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2 \ + --hash=sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22 \ + --hash=sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6 \ + --hash=sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e \ + --hash=sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310 \ + --hash=sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165 \ + --hash=sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5 \ + --hash=sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799 \ + --hash=sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8 \ + --hash=sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7 \ + --hash=sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379 \ + --hash=sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925 \ + --hash=sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72 \ + --hash=sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4 \ + --hash=sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08 \ + --hash=sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4 + # via uvicorn +websockets==17.0.1 \ + --hash=sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3 \ + --hash=sha256:02f0b037a737d0cb0c33866c97bcd1a0b73170dfbf42d69d8fb86f51002fd5ae \ + --hash=sha256:038cfad5d5417f8bb09295abe986029a26d22f34bda622ccc79b670efd4dab56 \ + --hash=sha256:07abc3bd196a48af476a82fd47f3f79a6a3f70937a9f930cef703cfa0c9d83b6 \ + --hash=sha256:07d78a509c3333f5908c83d7f78144ea68a6c9ec28110f5c54d81d8fcdc262c4 \ + --hash=sha256:0b52c76b8a870b141b7ca0705289452183ce7a523101954ccfe29a25986a673f \ + --hash=sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685 \ + --hash=sha256:10f461191125c63902ea7394ae9e752b1b5785641850c1d365bb30b0f88bc53f \ + --hash=sha256:15920057a6b723f84734f0641403bca163a4b176e5af809ee4f0c4a1e75e9fed \ + --hash=sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10 \ + --hash=sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0 \ + --hash=sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7 \ + --hash=sha256:1bdd8c4be420905dd732e00dcd669852d8128cc723efa585a0c0e51adb00a28a \ + --hash=sha256:1d4cf7e8e5b8b1fa40758ac7524843a00237b124ab217e227542cafcfeb7a946 \ + --hash=sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df \ + --hash=sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed \ + --hash=sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed \ + --hash=sha256:2437d4ca208cc0f246d3a2297ae7474b4ba18261aaf5b9c79c84c031ecf348e1 \ + --hash=sha256:246927ae9ae06ca0d42a483a4bdb80d4862e1ee5b4cab37c354a5e1ad8356448 \ + --hash=sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38 \ + --hash=sha256:2604de7228506b13a44a256a9d223943340c0e725af5d367dc068e192b027761 \ + --hash=sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d \ + --hash=sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2 \ + --hash=sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e \ + --hash=sha256:2b3f3020171202b135ca078e20434977c6b2b02af647130d6980c9e39b9462e3 \ + --hash=sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78 \ + --hash=sha256:2fa2cb465a131c347ba6717a78c887746e73edb1c131d01c982d6ef0d68b82e0 \ + --hash=sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c \ + --hash=sha256:41d6aa06b5ab832aee72fedf47a149535b121ac900b6bb4d3fe14712afac9a79 \ + --hash=sha256:49266e4488309b38783257293a38298942b9a03aa106fcb45195377a77c0c1e2 \ + --hash=sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461 \ + --hash=sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb \ + --hash=sha256:5033ffe6804dd53afafa7d08e8c3eef2d2431f34d58ca30507a8442dd04a033a \ + --hash=sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e \ + --hash=sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a \ + --hash=sha256:5508f38c98ac29def9e747b87543b008a58b075df6da70b2cf2e0b47073d33bb \ + --hash=sha256:55383d8177b3c99fd873ee5db0e0193f4c1dd4a3feaccf1a4a03c1b7cf539cac \ + --hash=sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22 \ + --hash=sha256:5661f868ef191d33dfc6a0cc7c5b3d495f0cc8bb3f8b30d87bda8755c61c95f5 \ + --hash=sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f \ + --hash=sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b \ + --hash=sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc \ + --hash=sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c \ + --hash=sha256:6740be6d1bab69f08ab52cb15b08f76c143b6fe61c580ba62bd929f3ab7a1d42 \ + --hash=sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce \ + --hash=sha256:6db9e5bf3649ab506c6ae8a3ac85a00fb1ae3816d75962771b2df8adbc5d40d2 \ + --hash=sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63 \ + --hash=sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc \ + --hash=sha256:70d438268e49f1a4bd096b6b6f7010f3ab48b5db2574dbf7d8c864c46ce7a06a \ + --hash=sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c \ + --hash=sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab \ + --hash=sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9 \ + --hash=sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253 \ + --hash=sha256:769ce7e2acfd9a89f2bed3a9c0da229459516bbc00bd4c9e2ca492c613ae4861 \ + --hash=sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d \ + --hash=sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add \ + --hash=sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f \ + --hash=sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58 \ + --hash=sha256:8848c207049ad49d318e5f64a3d4d7bb189f8328d0d98e65647788f2a085785c \ + --hash=sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2 \ + --hash=sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594 \ + --hash=sha256:8cd3369e42c0246afaf9d669cfc19797e3a49e8c0a639544459c57597108b966 \ + --hash=sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605 \ + --hash=sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5 \ + --hash=sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b \ + --hash=sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa \ + --hash=sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283 \ + --hash=sha256:a39ce3a7b0e6059be093213d637963101380157bcbad355916738fafb490698d \ + --hash=sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87 \ + --hash=sha256:a68e604c6d1b0338e46652e2688cbce8096ad9c03548b075fda9e2ea19a9b7dd \ + --hash=sha256:a8af570fc29cd998a921c7131c8ac81d9434466d6d25300cb12a690fb56a8a08 \ + --hash=sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0 \ + --hash=sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf \ + --hash=sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe \ + --hash=sha256:afbce6e3f0fac32dc87c2a0d84869d1a706460d64f39f3889386413e6e4d3d26 \ + --hash=sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0 \ + --hash=sha256:b580794e926cab7ff42ee4371ef14e0b22cb2bb722a607f77769136468f49a3f \ + --hash=sha256:b85b960a4507b0714c0a1246d031be9118d908ee974dc085257297a955205f1d \ + --hash=sha256:b98860aefbd3d9bc8e3c7f0eefb83b11142b16110739c68cd33d3b4d6e84e536 \ + --hash=sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833 \ + --hash=sha256:bc0bca48ba24c6c866847fd20478a51dd547fa0ad258dab9615c414ec534bbc0 \ + --hash=sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442 \ + --hash=sha256:c09e097d0e46e3c289bedab9a475ae344b70c30ff5646e46af22b4e6fdc97b21 \ + --hash=sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc \ + --hash=sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75 \ + --hash=sha256:c23e532c8a2325a1e7486de8763a60dc43e83f01bcaeca07e3ba79652c156db1 \ + --hash=sha256:c356dbddab0a529ed7574f78f559d75a223735c321c28f6f587fbf02b11ed301 \ + --hash=sha256:c38515cb54902f7e97d0239e81ef46c4444f9475f4807fb9bbdb789b4089abcf \ + --hash=sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48 \ + --hash=sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345 \ + --hash=sha256:cd526c8228e759c1006c4b7c9ac71dc4e925ced1a6a6a5a8e94643709738f63e \ + --hash=sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163 \ + --hash=sha256:cffc84ddec6da7f447677266fee2a3c40ecc78172f00752aa1150b8a8d65df1d \ + --hash=sha256:d41e9845514754a42d1d83b2fca9d27fee2ca7b3b0bee6843ba5a9bb2b6e25ac \ + --hash=sha256:d69fd559f9f0e8a52d2fce6f04ee143f86e70df0a189cd95164eddac599e810f \ + --hash=sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51 \ + --hash=sha256:d9aac6081513f02eac3f8caace800dbfc5c608b69e4a7bef69e414eabfc95aa1 \ + --hash=sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b \ + --hash=sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6 \ + --hash=sha256:e95e321d0d763f2b6633512605f6112ebd70d5746f3ce05c941909d4a25233f2 \ + --hash=sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6 \ + --hash=sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5 \ + --hash=sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891 \ + --hash=sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198 \ + --hash=sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed \ + --hash=sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a \ + --hash=sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab \ + --hash=sha256:ffad64ce7ad3703d652a3fd9af26238377d24ce52c6ad8ff35d26d82f61f493f + # via uvicorn +werkzeug==3.1.8 \ + --hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \ + --hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44 + # via tensorboard diff --git a/pyautoflip/warm_models.py b/pyautoflip/warm_models.py new file mode 100644 index 0000000..8794cd7 --- /dev/null +++ b/pyautoflip/warm_models.py @@ -0,0 +1,7 @@ +from insightface.app import FaceAnalysis + + +for name in ("buffalo_s", "buffalo_l"): + app = FaceAnalysis(name=name, providers=["CPUExecutionProvider"]) + app.prepare(ctx_id=-1, det_size=(640, 640)) + print(f"insightface {name} ready") diff --git a/sftp/Dockerfile b/sftp/Dockerfile deleted file mode 100644 index 2fc592b..0000000 --- a/sftp/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -# Thin wrapper around atmoz/sftp: durable host keys, key sync, data chown. -FROM atmoz/sftp:alpine - -# atmoz execs /usr/sbin/sshd by absolute path — replace it with our wrapper. -RUN mv /usr/sbin/sshd /usr/sbin/sshd.real -COPY sshd-wrapper.sh /usr/sbin/sshd -COPY entrypoint.sh /syndicator-entrypoint.sh -RUN chmod +x /usr/sbin/sshd /syndicator-entrypoint.sh - -ENTRYPOINT ["/syndicator-entrypoint.sh"] diff --git a/sftp/entrypoint.sh b/sftp/entrypoint.sh deleted file mode 100755 index 7f3ddc5..0000000 --- a/sftp/entrypoint.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -# Wrapper around atmoz/sftp entrypoint: -# - durable host keys in a named volume (no host-side keygen / bind mounts) -# - real sshd is replaced at build time by sshd-wrapper (see Dockerfile) -set -Eeo pipefail - -HOST_KEY_DIR="${SFTP_HOST_KEY_DIR:-/etc/ssh/host_keys}" - -mkdir -p "$HOST_KEY_DIR" - -if [[ ! -f "$HOST_KEY_DIR/ssh_host_ed25519_key" ]]; then - echo "[syndicator-sftp] generating ed25519 host key" - ssh-keygen -t ed25519 -f "$HOST_KEY_DIR/ssh_host_ed25519_key" -N '' /dev/null || true -# Install into /etc/ssh (image layer is writable; avoids 4 bind mounts). -cp -a "$HOST_KEY_DIR"/ssh_host_ed25519_key "$HOST_KEY_DIR"/ssh_host_ed25519_key.pub \ - "$HOST_KEY_DIR"/ssh_host_rsa_key "$HOST_KEY_DIR"/ssh_host_rsa_key.pub \ - /etc/ssh/ -chmod 600 /etc/ssh/ssh_host_ed25519_key /etc/ssh/ssh_host_rsa_key - -exec /entrypoint "$@" diff --git a/sftp/setup.sh b/sftp/setup.sh new file mode 100755 index 0000000..8d53b27 --- /dev/null +++ b/sftp/setup.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Supported atmoz/sftp startup hook: persist host keys, refresh user keys, and +# repair the writable data directory without replacing the sshd binary. +set -Eeo pipefail + +HOST_KEY_DIR="${SFTP_HOST_KEY_DIR:-/etc/ssh/host_keys}" +SFTP_UID="${SFTP_UID:-1001}" +SFTP_GID="${SFTP_GID:-100}" +DATA_DIR="${SFTP_DATA_DIR:-/home/sftp/syndicator}" + +mkdir -p "$HOST_KEY_DIR" + +if [[ ! -f "$HOST_KEY_DIR/ssh_host_ed25519_key" ]]; then + ssh-keygen -t ed25519 -f "$HOST_KEY_DIR/ssh_host_ed25519_key" -N '' /dev/null; then + sort -u "$keys_dir"/* >"$auth" + else + : >"$auth" + fi + chown "$(id -u "$user")" "$auth" + chmod 600 "$auth" +done + +if [[ -d "$DATA_DIR" ]]; then + chown "${SFTP_UID}:${SFTP_GID}" "$DATA_DIR" + chmod 755 "$DATA_DIR" +fi diff --git a/sftp/sshd-wrapper.sh b/sftp/sshd-wrapper.sh deleted file mode 100755 index e299cd0..0000000 --- a/sftp/sshd-wrapper.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -# Installed as /usr/sbin/sshd (real binary at /usr/sbin/sshd.real). -# atmoz entrypoint execs /usr/sbin/sshd — refresh keys + ownership first. -set -Eeo pipefail - -SFTP_UID="${SFTP_UID:-1001}" -SFTP_GID="${SFTP_GID:-100}" -DATA_DIR="${SFTP_DATA_DIR:-/home/sftp/syndicator}" - -for home in /home/*; do - [[ -d "$home" ]] || continue - user="$(basename "$home")" - keys_dir="$home/.ssh/keys" - [[ -d "$keys_dir" ]] || continue - auth="$home/.ssh/authorized_keys" - mkdir -p "$home/.ssh" - if compgen -G "$keys_dir/*" >/dev/null; then - cat "$keys_dir"/* | sort -u >"$auth" - else - : >"$auth" - fi - uid="$(id -u "$user" 2>/dev/null || echo "$SFTP_UID")" - chown "$uid" "$auth" - chmod 600 "$auth" -done - -if [[ -d "$DATA_DIR" ]]; then - chown -R "${SFTP_UID}:${SFTP_GID}" "$DATA_DIR" - chmod 755 "$DATA_DIR" -fi - -exec /usr/sbin/sshd.real "$@" diff --git a/tests/test_repository.py b/tests/test_repository.py index 40d9eb4..369327d 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -104,6 +104,37 @@ def test_credential_templates_contain_only_placeholders(self) -> None: self.assertIsInstance(value, str, f"{credential.get('name')}.{key}") self.assertRegex(value, placeholder, f"{credential.get('name')}.{key}") + def test_runtime_dependencies_are_locked(self) -> None: + runtime_files = [ + ROOT / "docker-compose.yml", + ROOT / "n8n" / "Dockerfile", + ROOT / "pyautoflip" / "Dockerfile", + ] + runtime_config = "\n".join( + path.read_text(encoding="utf-8") for path in runtime_files + ) + self.assertNotIn(":stable", runtime_config) + self.assertGreaterEqual( + len(re.findall(r"@sha256:[0-9a-f]{64}", runtime_config)), + 7, + "container bases should be immutable by default", + ) + + package = load_json(ROOT / "n8n" / "package.json") + for version in package["dependencies"].values(): + self.assertRegex(version, r"^\d+\.\d+\.\d+$") + self.assertTrue((ROOT / "n8n" / "package-lock.json").is_file()) + + requirements = (ROOT / "pyautoflip" / "requirements.txt").read_text( + encoding="utf-8" + ) + self.assertIn("--hash=sha256:", requirements) + self.assertTrue((ROOT / "pyautoflip" / "requirements.in").is_file()) + + def test_example_configuration_is_host_neutral(self) -> None: + example = (ROOT / ".env.example").read_text(encoding="utf-8") + self.assertNotRegex(example, r"\b192\.168\.\d{1,3}\.\d{1,3}\b") + if __name__ == "__main__": unittest.main() diff --git a/tests/validate-compose.sh b/tests/validate-compose.sh index 622a200..47435ff 100755 --- a/tests/validate-compose.sh +++ b/tests/validate-compose.sh @@ -23,4 +23,8 @@ fi export N8N_ENCRYPTION_KEY="ci-only-encryption-key" export N8N_OWNER_EMAIL="ci@example.invalid" -docker compose --env-file /dev/null config --quiet +if [[ "$#" -gt 0 ]]; then + docker compose --env-file /dev/null "$@" +else + docker compose --env-file /dev/null config --quiet +fi From 677dfc7778f13604004e6df816fe40b8bf523bd0 Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Wed, 12 Aug 2026 11:43:57 +0200 Subject: [PATCH 03/16] Make deployment lifecycle idempotent Provide one checked deploy command, reconcile n8n without database internals, and prove repeated full-stack setup in isolation. Co-authored-by: Cursor --- .env.example | 16 +- .github/workflows/ci.yml | 14 +- README.md | 15 +- bin/syndicator | 71 +++++ docker-compose.yml | 13 +- n8n/workflows/Adapt Feature Image.json | 29 +- n8n/workflows/Adapt Hugo Media.json | 29 +- n8n/workflows/Adapt Reel Media.json | 29 +- n8n/workflows/Blog Post Publish.json | 216 +------------ n8n/workflows/Reel Publish.json | 29 +- scripts/bootstrap-n8n.sh | 399 +++++++++++++------------ scripts/doctor.sh | 75 +++++ scripts/ensure-n8n-owner.sh | 51 ++-- scripts/ensure-sftp-keys.sh | 21 +- scripts/export-workflows.sh | 36 +-- scripts/init.sh | 61 ++++ scripts/lib.sh | 68 +++++ scripts/verify.sh | 67 +++++ tests/fixtures/n8n_owner.env | 2 + tests/integration/stack.sh | 124 ++++++++ tests/test-init.sh | 33 ++ tests/test_repository.py | 17 ++ 22 files changed, 812 insertions(+), 603 deletions(-) create mode 100755 bin/syndicator create mode 100755 scripts/doctor.sh create mode 100755 scripts/init.sh create mode 100644 scripts/lib.sh create mode 100755 scripts/verify.sh create mode 100644 tests/fixtures/n8n_owner.env create mode 100755 tests/integration/stack.sh create mode 100755 tests/test-init.sh diff --git a/.env.example b/.env.example index a2e1673..5e08603 100644 --- a/.env.example +++ b/.env.example @@ -25,16 +25,15 @@ N8N_OWNER_PASSWORD= # N8N_OWNER_FIRST_NAME=Syndicator # N8N_OWNER_LAST_NAME=Owner -# Optional override for workflow publish. When empty, bootstrap creates or -# rotates secrets/n8n_api_key (label syndicator-bootstrap). +# Optional override for workflow publish. Bootstrap otherwise preserves a +# generated key in secrets/n8n_api_key (label syndicator-bootstrap). # N8N_API_KEY= - -# Optional: owner user id for import ownership. Bootstrap auto-detects the -# global:owner user from SQLite when empty. -# N8N_OWNER_USER_ID= +# N8N_API_KEY_FILE=secrets/n8n_api_key +# N8N_BOOTSTRAP_STATE_FILE=secrets/bootstrap.sha256 # --- SFTP (published to host; internal compose hostname is always "sftp") --- SFTP_PUBLISH_PORT=2222 +# SFTP_KEYS_DIR=./sftp/keys # --- Credential secrets (rendered into templates, then deleted) --- OPENAI_API_KEY= @@ -45,9 +44,12 @@ POSTIZ_API_KEY= SFTP_HOST=sftp SFTP_USERNAME=sftp # Path to the private key n8n uses to reach the sftp service (PEM/OpenSSH). -# Created automatically by scripts/ensure-sftp-keys.sh (also run by bootstrap). +# Created automatically by `bin/syndicator init`. SFTP_PRIVATE_KEY_FILE=./secrets/sftp_n8n_ed25519 +# Generated bcrypt environment file consumed by Compose. +# N8N_OWNER_ENV_FILE=secrets/n8n_owner.env + # --- Image overrides --- # Reviewed defaults are pinned in docker-compose.yml and the Dockerfiles. # Override only while testing an explicit dependency update. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02d2738..5825577 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,12 +21,15 @@ jobs: - uses: actions/checkout@v4 - name: Validate repository manifests - run: python3 -m unittest discover -s tests -p 'test_*.py' + run: | + python3 -m unittest discover -s tests -p 'test_*.py' + bash tests/test-init.sh - name: Validate shell scripts run: | - bash -n scripts/*.sh tests/*.sh - shellcheck scripts/*.sh tests/*.sh + shell_files=(bin/syndicator scripts/*.sh tests/*.sh tests/integration/*.sh sftp/*.sh n8n/*.sh) + bash -n "${shell_files[@]}" + shellcheck "${shell_files[@]}" - name: Render Compose configuration run: bash tests/validate-compose.sh @@ -45,4 +48,7 @@ jobs: env: PYAUTOFLIP_WARM_MODELS: "0" run: | - bash tests/validate-compose.sh build n8n pyautoflip \ No newline at end of file + bash tests/validate-compose.sh build n8n pyautoflip + + - name: Exercise isolated stack twice + run: bash tests/integration/stack.sh diff --git a/README.md b/README.md index e8fb91e..1263d4e 100644 --- a/README.md +++ b/README.md @@ -156,22 +156,21 @@ Once Syndicator has finished processing Blog Post Publish the static Hugo post c ## Setup ```bash -cp .env.example .env -# Fill secrets: N8N_ENCRYPTION_KEY, N8N_OWNER_EMAIL, N8N_OWNER_PASSWORD, OpenAI, Postiz -./scripts/ensure-sftp-keys.sh -./scripts/ensure-n8n-owner.sh -docker compose up -d --build -./scripts/bootstrap-n8n.sh +bin/syndicator init +# Fill the values requested in .env, then: +bin/syndicator deploy ``` +`deploy` checks prerequisites, generates local-only keys, builds and starts the stack, reconciles n8n credentials/workflows, and verifies n8n, pyautoflip, and SFTP. It is safe to run repeatedly; an unchanged bootstrap is skipped. + Owner account is provisioned from env on n8n start (`N8N_INSTANCE_OWNER_*`). Bootstrap logs in with `N8N_OWNER_EMAIL` / `N8N_OWNER_PASSWORD` to create or reuse an API key at `secrets/n8n_api_key` (or uses `N8N_API_KEY` if set), then imports credentials/workflows and publishes webhooks. UI login uses the same owner credentials. -`ensure-sftp-keys.sh` writes `secrets/sftp_n8n_ed25519` (private) and `sftp/keys/n8n.pub` (public); bootstrap runs it too. `ensure-n8n-owner.sh` writes `secrets/n8n_owner.env` (bcrypt hash for Compose); bootstrap runs that as well. Extra client keys: copy any `.pub` into `sftp/keys/` and `docker compose restart sftp`. Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`. +`init` writes `secrets/sftp_n8n_ed25519` (private), `sftp/keys/n8n.pub` (public), and `secrets/n8n_owner.env` (bcrypt hash for Compose). Extra client keys: copy any `.pub` into `sftp/keys/` and restart SFTP. Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`. The `files-init` Compose service chowns the shared `n8n_files` volume to uid/gid `1000` on each `up` so n8n and pyautoflip can write under `/files`. ## Update Worfklows -`./scripts/export-workflows.sh` exports all workflows from n8n into workflows/ folder in this repo +`bin/syndicator export` exports sanitized workflows from n8n into `n8n/workflows/`. ## Automatic updates diff --git a/bin/syndicator b/bin/syndicator new file mode 100755 index 0000000..ff964a1 --- /dev/null +++ b/bin/syndicator @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" + +usage() { + cat <<'EOF' +Usage: bin/syndicator + +Lifecycle: + doctor Check host prerequisites + init Create and validate local configuration and keys + deploy Build, start, bootstrap, and verify the stack + bootstrap Reconcile n8n credentials and workflows + verify Verify health, workflows, pyautoflip, and SFTP + export Export sanitized workflows from n8n + status Show Compose service status + logs Follow Compose service logs +EOF +} + +command="${1:-help}" +shift || true + +case "$command" in + doctor) + exec "$ROOT/scripts/doctor.sh" "$@" + ;; + init) + exec "$ROOT/scripts/init.sh" "$@" + ;; + deploy) + if [[ "$#" -ne 0 ]]; then + echo "Usage: bin/syndicator deploy" >&2 + exit 2 + fi + "$ROOT/scripts/init.sh" + "$ROOT/scripts/doctor.sh" --require-config + compose build + compose up -d --remove-orphans + "$ROOT/scripts/bootstrap-n8n.sh" + "$ROOT/scripts/verify.sh" + ;; + bootstrap) + exec "$ROOT/scripts/bootstrap-n8n.sh" "$@" + ;; + verify) + exec "$ROOT/scripts/verify.sh" "$@" + ;; + export) + exec "$ROOT/scripts/export-workflows.sh" "$@" + ;; + status) + load_env + compose ps "$@" + ;; + logs) + load_env + compose logs -f "$@" + ;; + help | --help | -h) + usage + ;; + *) + echo "Unknown command: $command" >&2 + usage >&2 + exit 2 + ;; +esac diff --git a/docker-compose.yml b/docker-compose.yml index 0d521cc..60bccc9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,7 @@ # Phase 1 syndicator stack: SFTP staging + n8n (regular/SQLite) + pyautoflip. # Usage (from this directory): -# cp .env.example .env # fill secrets -# ./scripts/ensure-sftp-keys.sh -# ./scripts/ensure-n8n-owner.sh -# docker compose up -d --build -# ./scripts/bootstrap-n8n.sh +# bin/syndicator init # fill the requested values in .env +# bin/syndicator deploy name: syndicator @@ -32,7 +29,7 @@ services: volumes: - sftp_data:/home/sftp/syndicator # Client public keys (ensure-sftp-keys.sh writes n8n.pub here). - - ./sftp/keys:/home/sftp/.ssh/keys:ro + - ${SFTP_KEYS_DIR:-./sftp/keys}:/home/sftp/.ssh/keys:ro # Server host keys generated on first start; survive recreate. - sftp_host_keys:/etc/ssh/host_keys # Supported atmoz startup hook; avoids replacing the sshd binary. @@ -61,7 +58,7 @@ services: - "${N8N_HOST_PORT:-5678}:5678" # Password hash written by ./scripts/ensure-n8n-owner.sh (bcrypt; $ escaped as $$). env_file: - - secrets/n8n_owner.env + - ${N8N_OWNER_ENV_FILE:-secrets/n8n_owner.env} environment: GENERIC_TIMEZONE: ${GENERIC_TIMEZONE:-Europe/Zurich} TZ: ${GENERIC_TIMEZONE:-Europe/Zurich} @@ -92,7 +89,7 @@ services: test: [ "CMD-SHELL", - "wget -qO- http://127.0.0.1:5678/healthz >/dev/null || exit 1", + "wget -qO- http://127.0.0.1:5678/healthz/readiness >/dev/null || exit 1", ] interval: 30s timeout: 5s diff --git a/n8n/workflows/Adapt Feature Image.json b/n8n/workflows/Adapt Feature Image.json index 65a7e84..79c51b5 100644 --- a/n8n/workflows/Adapt Feature Image.json +++ b/n8n/workflows/Adapt Feature Image.json @@ -358,8 +358,7 @@ "staticData": null, "meta": { "aiBuilderAssisted": true, - "builderVariant": "mcp", - "instanceId": "91b62e007400f324d5abda851b7466b395e6ae8249f94b33aa082b387b7fb350" + "builderVariant": "mcp" }, "nodeGroups": [], "pinData": {}, @@ -368,29 +367,5 @@ "versionCounter": 2, "triggerCount": 0, "sourceWorkflowId": null, - "tags": [], - "shared": [ - { - "updatedAt": "2026-08-08T14:54:20.448Z", - "createdAt": "2026-08-08T14:54:20.448Z", - "role": "workflow:owner", - "workflowId": "8NOGn9jgOoV0fw0u", - "projectId": "AuzF5v93vrOQ3QqB", - "project": { - "updatedAt": "2026-08-08T14:53:14.033Z", - "createdAt": "2026-08-08T14:51:44.577Z", - "id": "AuzF5v93vrOQ3QqB", - "name": "Syndicator Owner ", - "type": "personal", - "icon": null, - "description": null, - "customTelemetryTags": [], - "creatorId": "2e0ed5be-e08a-448d-a423-6e34be07b789" - } - } - ], - "versionMetadata": { - "name": null, - "description": null - } + "tags": [] } diff --git a/n8n/workflows/Adapt Hugo Media.json b/n8n/workflows/Adapt Hugo Media.json index ce9dd62..6182bda 100644 --- a/n8n/workflows/Adapt Hugo Media.json +++ b/n8n/workflows/Adapt Hugo Media.json @@ -431,8 +431,7 @@ "staticData": null, "meta": { "aiBuilderAssisted": true, - "builderVariant": "mcp", - "instanceId": "91b62e007400f324d5abda851b7466b395e6ae8249f94b33aa082b387b7fb350" + "builderVariant": "mcp" }, "nodeGroups": [], "pinData": {}, @@ -441,29 +440,5 @@ "versionCounter": 2, "triggerCount": 0, "sourceWorkflowId": null, - "tags": [], - "shared": [ - { - "updatedAt": "2026-08-08T14:54:15.797Z", - "createdAt": "2026-08-08T14:54:15.797Z", - "role": "workflow:owner", - "workflowId": "OGa6Xa8GxkSmA7Cr", - "projectId": "AuzF5v93vrOQ3QqB", - "project": { - "updatedAt": "2026-08-08T14:53:14.033Z", - "createdAt": "2026-08-08T14:51:44.577Z", - "id": "AuzF5v93vrOQ3QqB", - "name": "Syndicator Owner ", - "type": "personal", - "icon": null, - "description": null, - "customTelemetryTags": [], - "creatorId": "2e0ed5be-e08a-448d-a423-6e34be07b789" - } - } - ], - "versionMetadata": { - "name": null, - "description": null - } + "tags": [] } diff --git a/n8n/workflows/Adapt Reel Media.json b/n8n/workflows/Adapt Reel Media.json index f499a16..714b9e3 100644 --- a/n8n/workflows/Adapt Reel Media.json +++ b/n8n/workflows/Adapt Reel Media.json @@ -389,8 +389,7 @@ "staticData": null, "meta": { "aiBuilderAssisted": true, - "builderVariant": "mcp", - "instanceId": "91b62e007400f324d5abda851b7466b395e6ae8249f94b33aa082b387b7fb350" + "builderVariant": "mcp" }, "nodeGroups": [], "pinData": {}, @@ -399,29 +398,5 @@ "versionCounter": 4, "triggerCount": 0, "sourceWorkflowId": null, - "tags": [], - "shared": [ - { - "updatedAt": "2026-08-08T14:54:25.189Z", - "createdAt": "2026-08-08T14:54:25.189Z", - "role": "workflow:owner", - "workflowId": "y9TTx7N8Iygn88ry", - "projectId": "AuzF5v93vrOQ3QqB", - "project": { - "updatedAt": "2026-08-08T14:53:14.033Z", - "createdAt": "2026-08-08T14:51:44.577Z", - "id": "AuzF5v93vrOQ3QqB", - "name": "Syndicator Owner ", - "type": "personal", - "icon": null, - "description": null, - "customTelemetryTags": [], - "creatorId": "2e0ed5be-e08a-448d-a423-6e34be07b789" - } - } - ], - "versionMetadata": { - "name": "Version 3fa76090", - "description": "" - } + "tags": [] } diff --git a/n8n/workflows/Blog Post Publish.json b/n8n/workflows/Blog Post Publish.json index 93eaec9..726e2d3 100644 --- a/n8n/workflows/Blog Post Publish.json +++ b/n8n/workflows/Blog Post Publish.json @@ -1129,224 +1129,14 @@ "meta": { "templateCredsSetupCompleted": true, "aiBuilderAssisted": true, - "builderVariant": "mcp", - "instanceId": "91b62e007400f324d5abda851b7466b395e6ae8249f94b33aa082b387b7fb350" + "builderVariant": "mcp" }, "nodeGroups": [], - "pinData": { - "Publish Webhook": [ - { - "json": { - "headers": { - "host": "192.168.0.26:5678", - "accept": "*/*", - "accept-encoding": "gzip, deflate", - "connection": "keep-alive", - "user-agent": "python-httpx/0.28.1", - "content-length": "4477", - "content-type": "application/json" - }, - "params": {}, - "query": {}, - "body": { - "slug": "2026-06-03_Athen", - "meta": { - "title": "Athen", - "date": "2026-06-03", - "language": "german", - "lang_code": "de", - "author": "Benno", - "summary": "Von Lefkada aus fahren wir quer durch Griechenland, welches sehr viel grösser ist, als ich mir das vorgestellt habe.", - "position": "37.90973, 23.71095" - }, - "post_url": "https://www.sailingnomads.ch/de/posts/2026-06-03_athen/", - "blocks": [ - { - "kind": "text", - "raw": "Von Lefkada aus fahren wir quer durch Griechenland, welches sehr viel grösser ist, als ich mir das vorgestellt habe." - }, - { - "kind": "title", - "raw": "### Autobahn", - "heading_level": 3 - }, - { - "kind": "media", - "media": { - "kind": "video", - "source_filename": "bruecke_1786291723616_0.mp4", - "alt": "bruecke.mp4" - } - }, - { - "kind": "media", - "media": { - "kind": "image", - "source_filename": "IMG_20260527_162635300_HDR.jpg", - "alt": "IMG_20260527_162635300_HDR" - } - }, - { - "kind": "text", - "raw": "Wir fahren an einem Tag bis Athen, was kein Problem ist, denn die griechischen Autobahnen sind unglaublich angenehm. Kaum Verkehr und in top Zustand. Unterwegs machen wir noch kurz Halt beim Korinth Kanal, der wirklich spektakulär ist. Irgendwann müssen wir hier mal durchsegeln." - }, - { - "kind": "title", - "raw": "### Öffentlicher Verkehr", - "heading_level": 3 - }, - { - "kind": "youtube", - "media": { - "kind": "youtube", - "youtube_id": "FAIZtHHsbSM" - } - }, - { - "kind": "media", - "media": { - "kind": "image", - "source_filename": "IMG_20260530_083835605_HDR.jpg", - "alt": "IMG_20260530_083835605_HDR" - } - }, - { - "kind": "media", - "media": { - "kind": "image", - "source_filename": "IMG_20260529_100817658_HDR.jpg", - "alt": "IMG_20260529_100817658_HDR" - } - }, - { - "kind": "text", - "raw": "Dem Auto scheint in Griechenland alles untergeordnet zu sein. Eine Tatsache, die mir in Athen echt auf die Nerven geht. Gerade mit dem Hund ist das super anstrengend, es wird null Rücksicht genommen und alles ist zugeparkt, sodass man teils auf die Strasse ausweichen muss. Die Athener selbst finden es auch zum Kotzen. Fairerweise muss man sagen, dass sie seit Jahren versuchen, die Fehler der Vergangenheit zu korrigieren. Ein Problem vieler Städte, wo aus reiner Ideologie auf das falsche Verkehrsmittel gesetzt wurde. Ich muss natürlich den neuen ÖV testen. Charly ist weniger begeistert als ich, denn es herrscht Maulkorbpflicht." - }, - { - "kind": "title", - "raw": "### Alexandros", - "heading_level": 3 - }, - { - "kind": "media", - "media": { - "kind": "image", - "source_filename": "IMG_20260529_080135001.jpg", - "alt": "IMG_20260529_080135001" - } - }, - { - "kind": "media", - "media": { - "kind": "image", - "source_filename": "IMG-20260529-WA0017.jpg", - "alt": "IMG-20260529-WA0017" - } - }, - { - "kind": "media", - "media": { - "kind": "image", - "source_filename": "IMG-20260529-WA0014.jpg", - "alt": "IMG-20260529-WA0014" - } - }, - { - "kind": "media", - "media": { - "kind": "video", - "source_filename": "ALEXANDRA_ALONGSIDE_1786294202993_0.mp4", - "alt": "ALEXANDRA ALONGSIDE.mp4" - } - }, - { - "kind": "media", - "media": { - "kind": "video", - "source_filename": "LINE_PRACTICE_1786293568316_0.mp4", - "alt": "LINE PRACTICE.mp4" - } - }, - { - "kind": "text", - "raw": "Der eigentliche Grund, warum wir in Athen sind, ist Alexandros, a.k.a. the epic navigator. Alexandros kenne ich von YouTube, wo er einen Kanal hat und Segeltips gibt. Er hat auch eine Segelschule in Athen und bietet ein 2-tägiges Katamarantraining an. Zufälligerweise haben wir gesehen, dass er noch einen Platz frei hat, und wir haben das kurzerhand gebucht für Alexandra. Alexandra ist dann auch sehr angetan von Kurs und Alexandros konnte ihr viel beibringen. Er geht die Sache wohl sehr viel pragmatischer an als unser deutsche Kollegen, und das scheint besser zu funktionieren für Alexandra." - }, - { - "kind": "title", - "raw": "### Der Denker", - "heading_level": 3 - }, - { - "kind": "media", - "media": { - "kind": "image", - "source_filename": "IMG_20260529_114044078.jpg", - "alt": "IMG_20260529_114044078" - } - }, - { - "kind": "media", - "media": { - "kind": "image", - "source_filename": "IMG_20260529_115656929.jpg", - "alt": "IMG_20260529_115656929" - } - }, - { - "kind": "media", - "media": { - "kind": "image", - "source_filename": "IMG_20260529_111236563.jpg", - "alt": "IMG_20260529_111236563" - } - }, - { - "kind": "text", - "raw": "Während Alex an ihrem Segelkurs teilnimmt, gehen Charly und ich alte Steine anschauen. Wir fahren mit dem Tram zur Akropolis, wo Charly natürlich nicht rein darf. Hundebisi und 3000 Jahre alter Sandstein sind keine gute Kombination. Wir besteigen den Hügel vis-à-vis, wo es alte Steine gratis und ohne Touristentrubel zu bestaunen gibt. Es ist schon beeindruckend auf Strassen zu wandeln, auf der vor 2500 Jahren wohl auch Sokrates, einer der grössten Denker der Menschheit, seine Abendspaziergänge absolviert hat." - } - ], - "header_source": "header.jpg", - "flags": { - "redeploy": false - } - }, - "webhookUrl": "http://192.168.0.26:5678/webhook/publish", - "executionMode": "production" - }, - "pairedItem": { - "item": 0 - } - } - ] - }, + "pinData": {}, "versionId": "daef5e42-f8d5-45c2-afa6-449ea33388d5", "activeVersionId": "daef5e42-f8d5-45c2-afa6-449ea33388d5", "versionCounter": 29, "triggerCount": 1, "sourceWorkflowId": null, - "tags": [], - "shared": [ - { - "updatedAt": "2026-08-08T14:54:29.929Z", - "createdAt": "2026-08-08T14:54:29.929Z", - "role": "workflow:owner", - "workflowId": "l7HCCWtO1ALC82n6", - "projectId": "AuzF5v93vrOQ3QqB", - "project": { - "updatedAt": "2026-08-08T14:53:14.033Z", - "createdAt": "2026-08-08T14:51:44.577Z", - "id": "AuzF5v93vrOQ3QqB", - "name": "Syndicator Owner ", - "type": "personal", - "icon": null, - "description": null, - "customTelemetryTags": [], - "creatorId": "2e0ed5be-e08a-448d-a423-6e34be07b789" - } - } - ], - "versionMetadata": { - "name": "Version daef5e42", - "description": "" - } + "tags": [] } diff --git a/n8n/workflows/Reel Publish.json b/n8n/workflows/Reel Publish.json index cb175ab..ca42e44 100644 --- a/n8n/workflows/Reel Publish.json +++ b/n8n/workflows/Reel Publish.json @@ -1084,8 +1084,7 @@ "meta": { "templateCredsSetupCompleted": true, "aiBuilderAssisted": true, - "builderVariant": "mcp", - "instanceId": "91b62e007400f324d5abda851b7466b395e6ae8249f94b33aa082b387b7fb350" + "builderVariant": "mcp" }, "nodeGroups": [], "pinData": {}, @@ -1094,29 +1093,5 @@ "versionCounter": 20, "triggerCount": 1, "sourceWorkflowId": null, - "tags": [], - "shared": [ - { - "updatedAt": "2026-08-08T14:54:34.710Z", - "createdAt": "2026-08-08T14:54:34.710Z", - "role": "workflow:owner", - "workflowId": "zh21miLsQC8Jvua6", - "projectId": "AuzF5v93vrOQ3QqB", - "project": { - "updatedAt": "2026-08-08T14:53:14.033Z", - "createdAt": "2026-08-08T14:51:44.577Z", - "id": "AuzF5v93vrOQ3QqB", - "name": "Syndicator Owner ", - "type": "personal", - "icon": null, - "description": null, - "customTelemetryTags": [], - "creatorId": "2e0ed5be-e08a-448d-a423-6e34be07b789" - } - } - ], - "versionMetadata": { - "name": "Version be986d7e", - "description": "" - } + "tags": [] } diff --git a/scripts/bootstrap-n8n.sh b/scripts/bootstrap-n8n.sh index a8fb13d..437776d 100755 --- a/scripts/bootstrap-n8n.sh +++ b/scripts/bootstrap-n8n.sh @@ -1,133 +1,101 @@ #!/usr/bin/env bash -# Import syndicator credentials + workflows into the running compose n8n, -# publish webhook workflows, and smoke-check webhooks + pyautoflip. +# Idempotently import credentials and workflows into a running n8n instance. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" -if [[ ! -f .env ]]; then - echo "Missing .env — copy .env.example and fill secrets." >&2 - exit 1 -fi +load_env +for name in \ + N8N_ENCRYPTION_KEY \ + N8N_OWNER_EMAIL \ + N8N_OWNER_PASSWORD \ + OPENAI_API_KEY \ + POSTIZ_API_KEY \ + SFTP_HOST \ + SFTP_USERNAME; do + need_env "$name" +done -# shellcheck disable=SC1091 -set -a -# shellcheck source=/dev/null -source .env -set +a - -COMPOSE=(docker compose) -N8N_EXEC=(docker compose exec -T -u node n8n) -# Import order: leaves before parents (same as publish). -SYNDICATOR_WORKFLOWS=( - "Adapt Hugo Media" - "Adapt Feature Image" - "Adapt Reel Media" - "Blog Post Publish" - "Reel Publish" -) -# Publish order: n8n 2.x requires referenced sub-workflows to be published -# before parents that call them. -PUBLISH_WORKFLOW_IDS=( - "OGa6Xa8GxkSmA7Cr" # Adapt Hugo Media - "8NOGn9jgOoV0fw0u" # Adapt Feature Image - "y9TTx7N8Iygn88ry" # Adapt Reel Media - "l7HCCWtO1ALC82n6" # Blog Post Publish - "zh21miLsQC8Jvua6" # Reel Publish +WORKFLOW_FILES=( + "n8n/workflows/Adapt Hugo Media.json" + "n8n/workflows/Adapt Feature Image.json" + "n8n/workflows/Adapt Reel Media.json" + "n8n/workflows/Blog Post Publish.json" + "n8n/workflows/Reel Publish.json" ) -need() { - local name="$1" - if [[ -z "${!name:-}" ]]; then - echo "Missing required env: $name" >&2 - exit 1 - fi -} - -need N8N_ENCRYPTION_KEY -need N8N_OWNER_EMAIL -need N8N_OWNER_PASSWORD -need OPENAI_API_KEY -need POSTIZ_API_KEY -need SFTP_HOST -need SFTP_USERNAME - -# Create n8n↔sftp keypair if missing, and refresh authorized public key. -"$ROOT/scripts/ensure-sftp-keys.sh" -# Bcrypt owner password into secrets/n8n_owner.env for Compose. -"$ROOT/scripts/ensure-n8n-owner.sh" - if [[ -z "${SFTP_PRIVATE_KEY:-}" ]]; then - key_file="${SFTP_PRIVATE_KEY_FILE:-./secrets/sftp_n8n_ed25519}" + key_file="$(resolve_from_root "${SFTP_PRIVATE_KEY_FILE:-secrets/sftp_n8n_ed25519}")" if [[ ! -f "$key_file" ]]; then - echo "Set SFTP_PRIVATE_KEY or provide $key_file" >&2 + echo "Missing SFTP private key: $key_file" >&2 exit 1 fi - SFTP_PRIVATE_KEY="$(cat "$key_file")" + SFTP_PRIVATE_KEY="$(<"$key_file")" export SFTP_PRIVATE_KEY fi -# Restart sftp so sshd-wrapper re-syncs authorized_keys from sftp/keys/. -if [[ -n "$("${COMPOSE[@]}" ps -q sftp 2>/dev/null || true)" ]]; then - echo "Restarting sftp to pick up authorized keys…" - "${COMPOSE[@]}" restart sftp -fi -for _ in $(seq 1 30); do - if "${COMPOSE[@]}" exec -T sftp pgrep -x sshd >/dev/null 2>&1; then - break - fi - sleep 1 -done - -echo "Waiting for n8n…" -for _ in $(seq 1 60); do - if "${COMPOSE[@]}" exec -T n8n wget -qO- http://127.0.0.1:5678/healthz >/dev/null 2>&1; then - break - fi - sleep 2 -done -if ! "${COMPOSE[@]}" exec -T n8n wget -qO- http://127.0.0.1:5678/healthz >/dev/null 2>&1; then - echo "n8n did not become healthy" >&2 - exit 1 -fi +wait_for_n8n N8N_BASE="http://127.0.0.1:${N8N_HOST_PORT:-5678}" API_KEY_LABEL="syndicator-bootstrap" -API_KEY_FILE="$ROOT/secrets/n8n_api_key" +API_KEY_FILE="$(resolve_from_root "${N8N_API_KEY_FILE:-secrets/n8n_api_key}")" +STATE_FILE="$(resolve_from_root "${N8N_BOOTSTRAP_STATE_FILE:-secrets/bootstrap.sha256}")" TMP_DIR="$(mktemp -d)" COOKIE_JAR="$TMP_DIR/n8n-cookies.txt" +LOGIN_BODY="$TMP_DIR/login.json" trap 'rm -rf "$TMP_DIR"' EXIT -ensure_n8n_api_key() { - if [[ -n "${N8N_API_KEY:-}" ]]; then - echo "Using N8N_API_KEY from environment" +login_n8n() { + if [[ -s "$LOGIN_BODY" ]]; then return fi - if [[ -f "$API_KEY_FILE" && -s "$API_KEY_FILE" ]]; then - N8N_API_KEY="$(tr -d '[:space:]' <"$API_KEY_FILE")" - if [[ -n "$N8N_API_KEY" ]]; then - echo "Using API key from $API_KEY_FILE" - export N8N_API_KEY - return - fi - fi - echo "Logging into n8n to provision API key…" - local login_code - login_code="$(curl -sS -o /tmp/n8n-login-body -w '%{http_code}' -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ + local code + code="$(curl -sS -o "$LOGIN_BODY" -w '%{http_code}' \ + -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ -X POST \ -H 'Content-Type: application/json' \ -d "$(python3 -c 'import json,os; print(json.dumps({"emailOrLdapLoginId":os.environ["N8N_OWNER_EMAIL"],"password":os.environ["N8N_OWNER_PASSWORD"]}))')" \ "${N8N_BASE}/rest/login" || true)" - if [[ "$login_code" != "200" ]]; then - echo "n8n login failed (HTTP $login_code): $(cat /tmp/n8n-login-body)" >&2 - echo "Ensure N8N_OWNER_EMAIL/PASSWORD match the env-managed owner and that ensure-n8n-owner.sh ran before compose up." >&2 + if [[ "$code" != "200" ]]; then + echo "n8n login failed (HTTP $code): $(<"$LOGIN_BODY")" >&2 exit 1 fi +} + +owner_user_id() { + login_n8n + python3 - "$LOGIN_BODY" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + body = json.load(handle) +data = body.get("data", body) +user_id = data.get("id", "") +if not user_id: + raise SystemExit(f"Login response has no owner id: {body!r}") +print(user_id) +PY +} + +api_key_valid() { + local key="$1" + local code + code="$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "X-N8N-API-KEY: $key" \ + "${N8N_BASE}/api/v1/workflows?limit=1" || true)" + [[ "$code" == "200" ]] +} + +provision_api_key() { + login_n8n local scopes_json key_id raw_key create_body - scopes_json="$(curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" "${N8N_BASE}/rest/api-keys/scopes")" + scopes_json="$(curl -fsS -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ + "${N8N_BASE}/rest/api-keys/scopes")" scopes_json="$(python3 -c ' import json,sys body=json.load(sys.stdin) @@ -137,7 +105,7 @@ if not isinstance(scopes, list): print(json.dumps(scopes)) ' <<<"$scopes_json")" - key_id="$(curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ + key_id="$(curl -fsS -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ --get \ --data-urlencode "label=${API_KEY_LABEL}" \ --data-urlencode "ownership=mine" \ @@ -146,39 +114,32 @@ print(json.dumps(scopes)) import json,sys body=json.load(sys.stdin) payload=body.get("data", body) -if isinstance(payload, dict): - items=payload.get("items", payload.get("data", [])) -elif isinstance(payload, list): - items=payload -else: - items=[] -label=sys.argv[1] +items=payload.get("items", payload.get("data", [])) if isinstance(payload, dict) else payload for item in items or []: - if item.get("label")==label: + if item.get("label")==sys.argv[1]: print(item.get("id","")) break ' "$API_KEY_LABEL")" if [[ -n "$key_id" ]]; then - echo "Rotating API key label=$API_KEY_LABEL id=$key_id…" - raw_key="$(curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \ + echo "Replacing inaccessible bootstrap API key..." + raw_key="$(curl -fsS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \ "${N8N_BASE}/rest/api-keys/${key_id}/rotate" | python3 -c ' import json,sys body=json.load(sys.stdin) data=body.get("data", body) key=data.get("rawApiKey") or data.get("apiKey") or "" if not key or key.startswith("*"): - raise SystemExit(f"Rotate did not return rawApiKey: {body!r}") + raise SystemExit(f"Rotate did not return a raw API key: {body!r}") print(key) ')" else - echo "Creating API key label=$API_KEY_LABEL…" + echo "Creating bootstrap API key..." create_body="$(python3 -c ' import json,sys -scopes=json.loads(sys.argv[1]) -print(json.dumps({"label":sys.argv[2],"expiresAt":None,"scopes":scopes})) +print(json.dumps({"label":sys.argv[2],"expiresAt":None,"scopes":json.loads(sys.argv[1])})) ' "$scopes_json" "$API_KEY_LABEL")" - raw_key="$(curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ + raw_key="$(curl -fsS -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ -X POST \ -H 'Content-Type: application/json' \ -d "$create_body" \ @@ -188,7 +149,7 @@ body=json.load(sys.stdin) data=body.get("data", body) key=data.get("rawApiKey") or "" if not key: - raise SystemExit(f"Create did not return rawApiKey: {body!r}") + raise SystemExit(f"Create did not return a raw API key: {body!r}") print(key) ')" fi @@ -199,125 +160,169 @@ print(key) chmod 600 "$API_KEY_FILE" N8N_API_KEY="$raw_key" export N8N_API_KEY - echo "Wrote $API_KEY_FILE" } -ensure_n8n_api_key - -resolve_owner_user_id() { - if [[ -n "${N8N_OWNER_USER_ID:-}" ]]; then - printf '%s' "$N8N_OWNER_USER_ID" +ensure_api_key() { + if [[ -n "${N8N_API_KEY:-}" ]]; then + if ! api_key_valid "$N8N_API_KEY"; then + echo "N8N_API_KEY is set but is not accepted by n8n." >&2 + exit 1 + fi return fi - local vol - # compose `name: syndicator` → volume syndicator_n8n_data - vol="syndicator_n8n_data" - if ! docker volume inspect "$vol" >/dev/null 2>&1; then - vol="$(docker volume ls -q | grep -E 'n8n_data$' | head -1 || true)" - fi - if [[ -n "$vol" ]]; then - docker run --rm -v "${vol}:/data:ro" alpine sh -c \ - 'apk add --no-cache sqlite >/dev/null && sqlite3 /data/database.sqlite "SELECT id FROM user WHERE roleSlug='\''global:owner'\'' LIMIT 1;"' \ - 2>/dev/null || true + + if [[ -s "$API_KEY_FILE" ]]; then + N8N_API_KEY="$(tr -d '[:space:]' <"$API_KEY_FILE")" + export N8N_API_KEY + if api_key_valid "$N8N_API_KEY"; then + return + fi + unset N8N_API_KEY fi + + provision_api_key } -OWNER_USER_ID="$(resolve_owner_user_id | tr -d '[:space:]')" -if [[ -z "$OWNER_USER_ID" ]]; then - echo "Could not resolve n8n owner user id. Ensure N8N_INSTANCE_OWNER_* is configured (./scripts/ensure-n8n-owner.sh + compose up), then re-run (or set N8N_OWNER_USER_ID)." >&2 - exit 1 -fi -echo "Using owner userId=$OWNER_USER_ID" +bootstrap_fingerprint() { + python3 - <<'PY' +import glob +import hashlib +import os + +digest = hashlib.sha256() +for pattern in ("n8n/credentials/*.template.json", "n8n/workflows/*.json"): + for path in sorted(glob.glob(pattern)): + digest.update(path.encode()) + with open(path, "rb") as handle: + digest.update(handle.read()) +for name in ( + "OPENAI_API_KEY", + "POSTIZ_API_KEY", + "SFTP_HOST", + "SFTP_USERNAME", + "SFTP_PRIVATE_KEY", +): + digest.update(name.encode()) + digest.update(os.environ[name].encode()) +print(digest.hexdigest()) +PY +} + +workflow_is_active() { + local id="$1" + local body="$TMP_DIR/workflow-${id}.json" + local code + code="$(curl -sS -o "$body" -w '%{http_code}' \ + -H "X-N8N-API-KEY: ${N8N_API_KEY}" \ + "${N8N_BASE}/api/v1/workflows/${id}" || true)" + [[ "$code" == "200" ]] || return 1 + python3 - "$body" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + body = json.load(handle) +data = body.get("data", body) +raise SystemExit(0 if data.get("active") is True else 1) +PY +} + +all_workflows_active() { + local file id + for file in "${WORKFLOW_FILES[@]}"; do + id="$(workflow_id "$ROOT/$file")" + workflow_is_active "$id" || return 1 + done +} render_credential() { local template="$1" local out="$2" python3 - "$template" "$out" <<'PY' -import json, os, re, sys -src, dst = sys.argv[1], sys.argv[2] -raw = open(src, encoding="utf-8").read() +import json +import os +import re +import sys + +source, destination = sys.argv[1:] +raw = open(source, encoding="utf-8").read() -def repl(match: re.Match[str]) -> str: +def replace(match: re.Match[str]) -> str: key = match.group(1) if key not in os.environ: - raise SystemExit(f"Missing env for template: {key}") - return json.dumps(os.environ[key])[1:-1] # escape for JSON string context + raise SystemExit(f"Missing environment value for template: {key}") + return json.dumps(os.environ[key])[1:-1] -# Replace ${VAR} inside JSON string values with JSON-escaped content. -rendered = re.sub(r"\$\{([A-Z0-9_]+)\}", repl, raw) -json.loads(rendered) # validate -open(dst, "w", encoding="utf-8").write(rendered) +rendered = re.sub(r"\$\{([A-Z0-9_]+)\}", replace, raw) +json.loads(rendered) +open(destination, "w", encoding="utf-8").write(rendered) PY } -echo "Importing credentials…" -for template in n8n/credentials/*.template.json; do - base="$(basename "$template" .template.json)" - rendered="$TMP_DIR/${base}.json" - render_credential "$template" "$rendered" - # Copy into container and import (decrypted JSON never stays in the repo). - docker compose cp "$rendered" "n8n:/tmp/${base}.json" - "${N8N_EXEC[@]}" n8n import:credentials --input="/tmp/${base}.json" --userId="$OWNER_USER_ID" - "${N8N_EXEC[@]}" rm -f "/tmp/${base}.json" -done - -echo "Importing workflows…" -for name in "${SYNDICATOR_WORKFLOWS[@]}"; do - file="n8n/workflows/${name}.json" - if [[ ! -f "$file" ]]; then - echo "Missing workflow export: $file" >&2 - exit 1 - fi - docker compose cp "$file" "n8n:/tmp/workflow-import.json" - "${N8N_EXEC[@]}" n8n import:workflow --input="/tmp/workflow-import.json" --userId="$OWNER_USER_ID" - "${N8N_EXEC[@]}" rm -f /tmp/workflow-import.json -done +copy_into_n8n() { + local source="$1" + local destination="$2" + # shellcheck disable=SC2016 + compose exec -T -u node n8n sh -c 'cat > "$1"' sh "$destination" <"$source" +} publish_workflow() { local id="$1" + local body="$TMP_DIR/publish-${id}.json" local code - code="$(curl -sS -o /tmp/n8n-activate-body -w '%{http_code}' -X POST \ + code="$(curl -sS -o "$body" -w '%{http_code}' -X POST \ -H "X-N8N-API-KEY: ${N8N_API_KEY}" \ - -H "Content-Type: application/json" \ - "http://127.0.0.1:${N8N_HOST_PORT:-5678}/api/v1/workflows/${id}/publish" || true)" + -H 'Content-Type: application/json' \ + "${N8N_BASE}/api/v1/workflows/${id}/publish" || true)" if [[ "$code" != "200" ]]; then - # Fallback for older n8n builds - code="$(curl -sS -o /tmp/n8n-activate-body -w '%{http_code}' -X POST \ + code="$(curl -sS -o "$body" -w '%{http_code}' -X POST \ -H "X-N8N-API-KEY: ${N8N_API_KEY}" \ - -H "Content-Type: application/json" \ - "http://127.0.0.1:${N8N_HOST_PORT:-5678}/api/v1/workflows/${id}/activate" || true)" + -H 'Content-Type: application/json' \ + "${N8N_BASE}/api/v1/workflows/${id}/activate" || true)" fi if [[ "$code" != "200" ]]; then - echo "Failed to publish workflow $id (HTTP $code): $(cat /tmp/n8n-activate-body)" >&2 + echo "Failed to publish workflow $id (HTTP $code): $(<"$body")" >&2 exit 1 fi - echo " published $id" } -echo "Publishing workflows (sub-workflows before webhook parents)…" -for id in "${PUBLISH_WORKFLOW_IDS[@]}"; do - publish_workflow "$id" +ensure_api_key +fingerprint="$(bootstrap_fingerprint)" +if [[ -s "$STATE_FILE" ]] && [[ "$(<"$STATE_FILE")" == "$fingerprint" ]] && \ + all_workflows_active; then + echo "n8n bootstrap is already current." + exit 0 +fi + +OWNER_USER_ID="$(owner_user_id)" +echo "Importing credentials for owner $OWNER_USER_ID..." +for template in n8n/credentials/*.template.json; do + base="$(basename "$template" .template.json)" + rendered="$TMP_DIR/${base}.json" + render_credential "$template" "$rendered" + copy_into_n8n "$rendered" "/tmp/${base}.json" + compose exec -T -u node n8n \ + n8n import:credentials --input="/tmp/${base}.json" --userId="$OWNER_USER_ID" + compose exec -T -u node n8n rm -f "/tmp/${base}.json" done -echo "Verifying production webhooks…" -for path in /webhook/publish /webhook/reel; do - code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ - -H 'Content-Type: application/json' \ - -d '{}' \ - "http://127.0.0.1:${N8N_HOST_PORT:-5678}${path}" || true)" - if [[ "$code" == "404" ]]; then - echo "Webhook $path returned 404 — workflow likely inactive or path mismatch" >&2 - exit 1 - fi - echo " $path → HTTP $code" +echo "Importing and publishing workflows..." +for file in "${WORKFLOW_FILES[@]}"; do + id="$(workflow_id "$ROOT/$file")" + copy_into_n8n "$ROOT/$file" /tmp/syndicator-workflow.json + compose exec -T -u node n8n \ + n8n import:workflow --input=/tmp/syndicator-workflow.json --userId="$OWNER_USER_ID" + compose exec -T -u node n8n rm -f /tmp/syndicator-workflow.json + publish_workflow "$id" done -echo "Verifying pyautoflip from n8n network…" -health="$("${COMPOSE[@]}" exec -T n8n wget -qO- http://pyautoflip:8080/health || true)" -if [[ "$health" != *'"status":"ok"'* && "$health" != *'"status": "ok"'* ]]; then - echo "pyautoflip /health failed: $health" >&2 +if ! all_workflows_active; then + echo "At least one imported workflow is not active." >&2 exit 1 fi -echo " pyautoflip /health → $health" -echo "Bootstrap complete." +mkdir -p "$(dirname "$STATE_FILE")" +umask 077 +printf '%s\n' "$fingerprint" >"$STATE_FILE" +chmod 600 "$STATE_FILE" +echo "n8n bootstrap complete." diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100755 index 0000000..292d7ad --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" + +require_config=0 +if [[ "${1:-}" == "--require-config" ]]; then + require_config=1 +elif [[ "$#" -gt 0 ]]; then + echo "Usage: $0 [--require-config]" >&2 + exit 2 +fi + +failed=0 +for command in docker curl python3 openssl ssh-keygen sftp; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Missing required command: $command" >&2 + failed=1 + fi +done + +if [[ "$failed" -ne 0 ]]; then + exit 1 +fi + +if ! docker info >/dev/null 2>&1; then + echo "Docker is installed but its daemon is unavailable." >&2 + exit 1 +fi +docker compose version >/dev/null + +if [[ "$require_config" -eq 0 ]]; then + echo "Host prerequisites are available." + exit 0 +fi + +load_env +for name in \ + N8N_ENCRYPTION_KEY \ + N8N_OWNER_EMAIL \ + N8N_OWNER_PASSWORD \ + OPENAI_API_KEY \ + POSTIZ_API_KEY \ + SFTP_HOST \ + SFTP_USERNAME; do + need_env "$name" +done + +owner_env="$(resolve_from_root "${N8N_OWNER_ENV_FILE:-secrets/n8n_owner.env}")" +private_key="$(resolve_from_root "${SFTP_PRIVATE_KEY_FILE:-secrets/sftp_n8n_ed25519}")" +keys_dir="$(resolve_from_root "${SFTP_KEYS_DIR:-sftp/keys}")" + +for path in "$owner_env" "$private_key" "$keys_dir/n8n.pub"; do + if [[ ! -e "$path" ]]; then + echo "Missing generated setup artifact: $path" >&2 + failed=1 + fi +done + +if [[ "$failed" -ne 0 ]]; then + exit 1 +fi + +compose config --quiet + +if [[ "${N8N_HOST:-localhost}" != "localhost" && \ + "${N8N_HOST:-localhost}" != "127.0.0.1" && \ + "${N8N_HOST:-localhost}" != "::1" && \ + "${N8N_PROTOCOL:-http}" != "https" ]]; then + echo "Warning: n8n is configured for non-local HTTP without TLS." >&2 +fi + +echo "Host, configuration, and generated artifacts are valid." diff --git a/scripts/ensure-n8n-owner.sh b/scripts/ensure-n8n-owner.sh index 6080e00..1ed620a 100755 --- a/scripts/ensure-n8n-owner.sh +++ b/scripts/ensure-n8n-owner.sh @@ -1,35 +1,30 @@ #!/usr/bin/env bash -# Idempotently bcrypt-hash N8N_OWNER_PASSWORD into secrets/n8n_owner.env for Compose. -# Run before `docker compose up` (bootstrap also runs this). +# Idempotently bcrypt-hash N8N_OWNER_PASSWORD into the Compose owner env file. +# Run through `bin/syndicator init` before starting the stack. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" - -if [[ ! -f .env ]]; then - echo "Missing .env — copy .env.example and fill N8N_OWNER_EMAIL / N8N_OWNER_PASSWORD." >&2 - exit 1 +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" + +force=0 +if [[ "${1:-}" == "--force" ]]; then + force=1 +elif [[ "$#" -gt 0 ]]; then + echo "Usage: $0 [--force]" >&2 + exit 2 fi -# shellcheck disable=SC1091 -set -a -# shellcheck source=/dev/null -source .env -set +a +load_env +need_env N8N_OWNER_EMAIL +need_env N8N_OWNER_PASSWORD -need() { - local name="$1" - if [[ -z "${!name:-}" ]]; then - echo "Missing required env: $name" >&2 - exit 1 - fi -} - -need N8N_OWNER_EMAIL -need N8N_OWNER_PASSWORD - -mkdir -p "$ROOT/secrets" -out="$ROOT/secrets/n8n_owner.env" +out="$(resolve_from_root "${N8N_OWNER_ENV_FILE:-secrets/n8n_owner.env}")" +mkdir -p "$(dirname "$out")" +if [[ -s "$out" && "$force" -eq 0 ]]; then + echo "n8n owner hash already present: $out" + exit 0 +fi hash_password() { # Prefer host bcrypt; fall back to a one-shot container (Docker is required anyway). @@ -41,8 +36,10 @@ print(bcrypt.hashpw(password, bcrypt.gensalt(rounds=10)).decode()) PY return fi - printf '%s' "$N8N_OWNER_PASSWORD" | docker run --rm -i python:3.12-alpine sh -c ' - pip install -q bcrypt >/dev/null + printf '%s' "$N8N_OWNER_PASSWORD" | docker run --rm -i \ + "python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" \ + sh -c ' + pip install --no-cache-dir -q bcrypt==5.0.0 >/dev/null python -c "import bcrypt,sys; p=sys.stdin.buffer.read(); print(bcrypt.hashpw(p, bcrypt.gensalt(rounds=10)).decode())" ' } diff --git a/scripts/ensure-sftp-keys.sh b/scripts/ensure-sftp-keys.sh index fd0b403..bc3fa04 100755 --- a/scripts/ensure-sftp-keys.sh +++ b/scripts/ensure-sftp-keys.sh @@ -4,24 +4,19 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" -# shellcheck disable=SC1091 -if [[ -f .env ]]; then - set -a - # shellcheck source=/dev/null - source .env - set +a +if [[ -f "$ENV_FILE" ]]; then + load_env fi key_file="${SFTP_PRIVATE_KEY_FILE:-./secrets/sftp_n8n_ed25519}" -# Resolve relative to repo root -if [[ "$key_file" != /* ]]; then - key_file="$ROOT/${key_file#./}" -fi -pub_file="$ROOT/sftp/keys/n8n.pub" +key_file="$(resolve_from_root "$key_file")" +keys_dir="$(resolve_from_root "${SFTP_KEYS_DIR:-sftp/keys}")" +pub_file="$keys_dir/n8n.pub" -mkdir -p "$(dirname "$key_file")" "$ROOT/sftp/keys" +mkdir -p "$(dirname "$key_file")" "$keys_dir" if [[ ! -f "$key_file" ]]; then echo "Generating SFTP client key: $key_file" diff --git a/scripts/export-workflows.sh b/scripts/export-workflows.sh index 53a0e98..e409d15 100755 --- a/scripts/export-workflows.sh +++ b/scripts/export-workflows.sh @@ -4,33 +4,27 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" +load_env OUT_DIR="$ROOT/n8n/workflows" mkdir -p "$OUT_DIR" -# id|filename (without .json) -WORKFLOWS=( - "l7HCCWtO1ALC82n6|Blog Post Publish" - "zh21miLsQC8Jvua6|Reel Publish" - "OGa6Xa8GxkSmA7Cr|Adapt Hugo Media" - "8NOGn9jgOoV0fw0u|Adapt Feature Image" - "y9TTx7N8Iygn88ry|Adapt Reel Media" -) - TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT -for entry in "${WORKFLOWS[@]}"; do - id="${entry%%|*}" - name="${entry#*|}" +count=0 +for source in "$OUT_DIR"/*.json; do + name="$(basename "$source" .json)" + id="$(workflow_id "$source")" echo "Exporting $name ($id)…" - docker compose exec -T -u node n8n \ + compose exec -T -u node n8n \ n8n export:workflow --id="$id" --pretty --output="/tmp/export-${id}.json" - docker compose cp "n8n:/tmp/export-${id}.json" "$TMP/${name}.json" - docker compose exec -T -u node n8n rm -f "/tmp/export-${id}.json" + compose cp "n8n:/tmp/export-${id}.json" "$TMP/${name}.json" + compose exec -T -u node n8n rm -f "/tmp/export-${id}.json" - # n8n may wrap a single workflow in an array — normalize to one object file. + # Normalize a single workflow and remove instance-specific export metadata. python3 - "$TMP/${name}.json" "$OUT_DIR/${name}.json" <<'PY' import json, sys src, dst = sys.argv[1], sys.argv[2] @@ -39,9 +33,15 @@ if isinstance(data, list): if len(data) != 1: raise SystemExit(f"Expected 1 workflow in {src}, got {len(data)}") data = data[0] +data["pinData"] = {} +data.pop("shared", None) +data.pop("versionMetadata", None) +if isinstance(data.get("meta"), dict): + data["meta"].pop("instanceId", None) json.dump(data, open(dst, "w", encoding="utf-8"), indent=2, ensure_ascii=False) open(dst, "a", encoding="utf-8").write("\n") PY + count=$((count + 1)) done -echo "Wrote ${#WORKFLOWS[@]} workflows to $OUT_DIR" +echo "Wrote $count workflows to $OUT_DIR" diff --git a/scripts/init.sh b/scripts/init.sh new file mode 100755 index 0000000..f0c0c82 --- /dev/null +++ b/scripts/init.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" + +created=0 +if [[ ! -f "$ENV_FILE" ]]; then + mkdir -p "$(dirname "$ENV_FILE")" + cp "$ROOT/.env.example" "$ENV_FILE" + created=1 +fi +chmod 600 "$ENV_FILE" + +load_env +if [[ -z "${N8N_ENCRYPTION_KEY:-}" ]]; then + encryption_key="$(openssl rand -hex 32)" + python3 - "$ENV_FILE" "$encryption_key" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +replacement = f"N8N_ENCRYPTION_KEY={sys.argv[2]}" +lines = path.read_text(encoding="utf-8").splitlines() +for index, line in enumerate(lines): + if line.startswith("N8N_ENCRYPTION_KEY="): + lines[index] = replacement + break +else: + lines.append(replacement) +path.write_text("\n".join(lines) + "\n", encoding="utf-8") +PY + load_env + echo "Generated N8N_ENCRYPTION_KEY in $ENV_FILE" +fi + +missing=0 +for name in \ + N8N_OWNER_EMAIL \ + N8N_OWNER_PASSWORD \ + OPENAI_API_KEY \ + POSTIZ_API_KEY \ + SFTP_HOST \ + SFTP_USERNAME; do + if ! need_env "$name"; then + missing=1 + fi +done + +if [[ "$missing" -ne 0 ]]; then + if [[ "$created" -eq 1 ]]; then + echo "Created $ENV_FILE. Fill the values above, then run init again." >&2 + fi + exit 2 +fi + +"$ROOT/scripts/ensure-sftp-keys.sh" +"$ROOT/scripts/ensure-n8n-owner.sh" + +echo "Initialization is complete." diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100644 index 0000000..c94aa52 --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${SYNDICATOR_ENV_FILE:-$ROOT/.env}" + +cd "$ROOT" || exit 1 + +load_env() { + if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing environment file: $ENV_FILE" >&2 + return 1 + fi + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" + set +a +} + +need_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "Missing required environment value: $name" >&2 + return 1 + fi +} + +resolve_from_root() { + local path="$1" + if [[ "$path" = /* ]]; then + printf '%s\n' "$path" + else + printf '%s\n' "$ROOT/${path#./}" + fi +} + +compose() { + local args=(--env-file "$ENV_FILE") + if [[ -n "${SYNDICATOR_PROJECT:-}" ]]; then + args+=(-p "$SYNDICATOR_PROJECT") + fi + docker compose "${args[@]}" "$@" +} + +wait_for_n8n() { + local attempts="${1:-60}" + local delay="${2:-2}" + local count + echo "Waiting for n8n..." + for ((count = 1; count <= attempts; count++)); do + if compose exec -T n8n wget -qO- \ + http://127.0.0.1:5678/healthz/readiness >/dev/null 2>&1; then + return 0 + fi + sleep "$delay" + done + echo "n8n did not become healthy" >&2 + return 1 +} + +workflow_id() { + python3 - "$1" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + print(json.load(handle)["id"]) +PY +} diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..5d516b0 --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" + +load_env +wait_for_n8n 30 2 + +if [[ -z "${N8N_API_KEY:-}" ]]; then + api_key_file="$(resolve_from_root "${N8N_API_KEY_FILE:-secrets/n8n_api_key}")" + if [[ ! -s "$api_key_file" ]]; then + echo "Missing n8n API key: $api_key_file" >&2 + exit 1 + fi + N8N_API_KEY="$(tr -d '[:space:]' <"$api_key_file")" +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +n8n_base="http://127.0.0.1:${N8N_HOST_PORT:-5678}" + +for file in n8n/workflows/*.json; do + id="$(workflow_id "$file")" + body="$tmp/workflow-${id}.json" + code="$(curl -sS -o "$body" -w '%{http_code}' \ + -H "X-N8N-API-KEY: ${N8N_API_KEY}" \ + "${n8n_base}/api/v1/workflows/${id}" || true)" + if [[ "$code" != "200" ]]; then + echo "Workflow $id is unavailable through the n8n API (HTTP $code)." >&2 + exit 1 + fi + python3 - "$body" "$file" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + body = json.load(handle) +data = body.get("data", body) +if data.get("active") is not True: + raise SystemExit(f"{sys.argv[2]} is not active") +PY +done +echo "n8n health and workflow publication are valid." + +health="$(compose exec -T n8n wget -qO- http://pyautoflip:8080/health || true)" +if [[ "$health" != *'"status":"ok"'* && "$health" != *'"status": "ok"'* ]]; then + echo "pyautoflip health check failed: $health" >&2 + exit 1 +fi +echo "pyautoflip is reachable from n8n." + +private_key="$(resolve_from_root "${SFTP_PRIVATE_KEY_FILE:-secrets/sftp_n8n_ed25519}")" +sftp_port="${SFTP_PUBLISH_PORT:-2222}" +ssh-keyscan -p "$sftp_port" 127.0.0.1 >"$tmp/known_hosts" 2>/dev/null +printf 'pwd\nquit\n' | sftp -q -b - \ + -P "$sftp_port" \ + -i "$private_key" \ + -o BatchMode=yes \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + -o "UserKnownHostsFile=$tmp/known_hosts" \ + "${SFTP_USERNAME}@127.0.0.1" >/dev/null +echo "SFTP key authentication is valid." + +echo "Syndicator verification complete." diff --git a/tests/fixtures/n8n_owner.env b/tests/fixtures/n8n_owner.env new file mode 100644 index 0000000..ce5df0f --- /dev/null +++ b/tests/fixtures/n8n_owner.env @@ -0,0 +1,2 @@ +# Test-only bcrypt hash for the password "ci-owner-password". +N8N_INSTANCE_OWNER_PASSWORD_HASH=$$2b$$04$$mi7C5m3f0DOu.IUj17n/peFz/qouRQCEgb7M2kEk.qT2nzdWzyl92 diff --git a/tests/integration/stack.sh b/tests/integration/stack.sh new file mode 100755 index 0000000..d08ccf9 --- /dev/null +++ b/tests/integration/stack.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +tmp="$(mktemp -d)" +project="syndicator-it-${RANDOM}" +env_file="$tmp/integration.env" + +free_port() { + python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + +n8n_port="$(free_port)" +sftp_port="$(free_port)" + +cat >"$env_file" <&2 || true + fi + docker compose --env-file "$env_file" -p "$project" \ + down -v --remove-orphans >/dev/null 2>&1 || true + rm -rf "$tmp" + exit "$status" +} +trap cleanup EXIT + +"$ROOT/bin/syndicator" deploy + +cp "$tmp/n8n_api_key" "$tmp/n8n_api_key.before" +if ! "$ROOT/bin/syndicator" deploy | tee "$tmp/second-deploy.log"; then + exit 1 +fi +if ! python3 - "$tmp/second-deploy.log" <<'PY' +from pathlib import Path +import sys + +raise SystemExit(0 if "already current" in Path(sys.argv[1]).read_text() else 1) +PY +then + echo "Second deployment did not skip an unchanged bootstrap." >&2 + exit 1 +fi +cmp "$tmp/n8n_api_key.before" "$tmp/n8n_api_key" + +api_key="$(tr -d '[:space:]' <"$tmp/n8n_api_key")" +curl -fsS \ + -H "X-N8N-API-KEY: $api_key" \ + "http://127.0.0.1:${n8n_port}/api/v1/workflows?limit=100" | + python3 -c ' +import json,sys +body=json.load(sys.stdin) +items=body.get("data", body) +if isinstance(items, dict): + items=items.get("data", []) +expected={ + "8NOGn9jgOoV0fw0u", + "OGa6Xa8GxkSmA7Cr", + "y9TTx7N8Iygn88ry", + "l7HCCWtO1ALC82n6", + "zh21miLsQC8Jvua6", +} +actual={item["id"] for item in items if item.get("id") in expected} +if actual != expected: + raise SystemExit(f"Expected five Syndicator workflows, got {sorted(actual)}") +' + +printf '%s\n' "integration payload" >"$tmp/upload.txt" +ssh-keyscan -p "$sftp_port" 127.0.0.1 >"$tmp/known_hosts" 2>/dev/null +cat >"$tmp/sftp.batch" </dev/null 2>&1 +status=$? +set -e + +if [[ "$status" -ne 2 ]]; then + echo "First init should request configuration and exit 2, got $status." >&2 + exit 1 +fi + +python3 - "$tmp/.env" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +values = {} +for line in path.read_text(encoding="utf-8").splitlines(): + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + values[key] = value +key = values.get("N8N_ENCRYPTION_KEY", "") +if len(key) != 64: + raise SystemExit("init did not generate a 256-bit n8n encryption key") +PY + +echo "First-run initialization test passed." diff --git a/tests/test_repository.py b/tests/test_repository.py index 369327d..53afb54 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -75,6 +75,13 @@ def test_subworkflow_references_resolve(self) -> None: f"{path.name}: {node.get('name')} references an unknown workflow", ) + def test_workflow_exports_exclude_instance_state(self) -> None: + for path, workflow in self.workflows.items(): + self.assertFalse(workflow.get("pinData"), path) + self.assertNotIn("shared", workflow, path) + self.assertNotIn("versionMetadata", workflow, path) + self.assertNotIn("instanceId", workflow.get("meta", {}), path) + def test_credential_ids_and_names_are_unique(self) -> None: ids = [credential.get("id") for credential in self.credentials] names = [credential.get("name") for credential in self.credentials] @@ -135,6 +142,16 @@ def test_example_configuration_is_host_neutral(self) -> None: example = (ROOT / ".env.example").read_text(encoding="utf-8") self.assertNotRegex(example, r"\b192\.168\.\d{1,3}\.\d{1,3}\b") + def test_bootstrap_uses_supported_interfaces(self) -> None: + bootstrap = (ROOT / "scripts" / "bootstrap-n8n.sh").read_text( + encoding="utf-8" + ) + self.assertNotIn("docker volume", bootstrap) + self.assertNotIn("sqlite", bootstrap.lower()) + self.assertNotIn("PUBLISH_WORKFLOW_IDS", bootstrap) + library = (ROOT / "scripts" / "lib.sh").read_text(encoding="utf-8") + self.assertIn("/healthz/readiness", library) + if __name__ == "__main__": unittest.main() From ed290f97741a8347c9a4b56860b426a2edb121af Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Wed, 12 Aug 2026 12:06:23 +0200 Subject: [PATCH 04/16] Add tested recovery and rollback Pair release-tagged updates with consistent state archives, verified restores, and an executable rollback path instead of unattended upgrades. Co-authored-by: Cursor --- .env.example | 3 +- .github/workflows/ci.yml | 1 + .gitignore | 2 +- README.md | 18 ++- bin/syndicator | 27 +++-- docker-compose.yml | 15 ++- scripts/backup.sh | 150 +++++++++++++++++++++++++ scripts/deploy.sh | 87 +++++++++++++++ scripts/lib.sh | 40 +++++++ scripts/restore.sh | 179 ++++++++++++++++++++++++++++++ scripts/rollback.sh | 43 +++++++ scripts/update.sh | 45 +------- systemd/syndicator-update.service | 11 -- systemd/syndicator-update.timer | 10 -- tests/integration/stack.sh | 52 ++++++++- 15 files changed, 596 insertions(+), 87 deletions(-) create mode 100755 scripts/backup.sh create mode 100755 scripts/deploy.sh create mode 100755 scripts/restore.sh create mode 100755 scripts/rollback.sh delete mode 100644 systemd/syndicator-update.service delete mode 100644 systemd/syndicator-update.timer diff --git a/.env.example b/.env.example index 5e08603..4e152cf 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ N8N_HOST=localhost N8N_PORT=5678 N8N_PROTOCOL=http N8N_HOST_PORT=5678 -WEBHOOK_URL=http://localhost:5678/ +N8N_WEBHOOK_URL=http://localhost:5678/ N8N_SECURE_COOKIE=false # Encryption key for credentials at rest. @@ -64,3 +64,4 @@ SFTP_PRIVATE_KEY_FILE=./secrets/sftp_n8n_ed25519 # --- Operations --- # SYNDICATOR_BACKUP_DIR=./backups +# SYNDICATOR_RELEASE_STATE_FILE=secrets/release.env diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5825577..96fe3cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,7 @@ jobs: build-images: needs: validate runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index e770e27..eb813c1 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,4 @@ secrets/* !secrets/.gitkeep sftp/keys/* !sftp/keys/.gitkeep -update.log +backups/ diff --git a/README.md b/README.md index 1263d4e..2e42692 100644 --- a/README.md +++ b/README.md @@ -172,11 +172,19 @@ The `files-init` Compose service chowns the shared `n8n_files` volume to uid/gid `bin/syndicator export` exports sanitized workflows from n8n into `n8n/workflows/`. -## Automatic updates +## Updates and recovery -`scripts/update.sh` rebuilds with `--pull`, recreates containers, prunes old images, leaves volumes alone. Covers **n8n and pyautoflip**. +Dependencies are pinned and proposed through reviewed dependency PRs; there are no unattended production upgrades. -Logs default to `update.log` (`UPDATE_LOG` to override). +```bash +bin/syndicator backup +bin/syndicator update +bin/syndicator rollback +# Destructive and explicit: +bin/syndicator restore --yes backups/.tar.gz +``` + +An update gets a commit-based image tag and creates a consistent backup when the release changes. Rollback requires both the retained previous images and their matching backup. Backup archives contain credentials and are written with mode `0600`; copy them to encrypted off-host storage. ## Architecture @@ -203,7 +211,7 @@ The repo is the blueprint for a containerized instance: Compose defines the stac | `n8n/credentials/` | Credential templates (stable IDs; secrets from `.env`) | | `pyautoflip/` | Image/build context for the reframe sidecar | | `sftp/keys/` | Authorized client public keys (refreshed into `authorized_keys` on each sftp start) | -| `systemd/*` | Optional host timer for updates | +| `bin/syndicator` | Checked lifecycle: deploy, verify, backup, restore, update, rollback | ``` docker-compose.yml @@ -214,7 +222,7 @@ n8n/credentials/*.template.json pyautoflip/ sftp/ scripts/{ensure-sftp-keys,ensure-n8n-owner,bootstrap,export,update}.sh -systemd/* +bin/syndicator ``` ### Runtime structure diff --git a/bin/syndicator b/bin/syndicator index ff964a1..96ffe6b 100755 --- a/bin/syndicator +++ b/bin/syndicator @@ -16,6 +16,10 @@ Lifecycle: bootstrap Reconcile n8n credentials and workflows verify Verify health, workflows, pyautoflip, and SFTP export Export sanitized workflows from n8n + backup Archive critical volumes, configuration, and secrets + restore Restore a validated backup archive + update Back up and deploy reviewed dependency changes + rollback Restore the recorded previous release status Show Compose service status logs Follow Compose service logs EOF @@ -32,16 +36,7 @@ case "$command" in exec "$ROOT/scripts/init.sh" "$@" ;; deploy) - if [[ "$#" -ne 0 ]]; then - echo "Usage: bin/syndicator deploy" >&2 - exit 2 - fi - "$ROOT/scripts/init.sh" - "$ROOT/scripts/doctor.sh" --require-config - compose build - compose up -d --remove-orphans - "$ROOT/scripts/bootstrap-n8n.sh" - "$ROOT/scripts/verify.sh" + exec "$ROOT/scripts/deploy.sh" "$@" ;; bootstrap) exec "$ROOT/scripts/bootstrap-n8n.sh" "$@" @@ -52,6 +47,18 @@ case "$command" in export) exec "$ROOT/scripts/export-workflows.sh" "$@" ;; + backup) + exec "$ROOT/scripts/backup.sh" "$@" + ;; + restore) + exec "$ROOT/scripts/restore.sh" "$@" + ;; + update) + exec "$ROOT/scripts/update.sh" "$@" + ;; + rollback) + exec "$ROOT/scripts/rollback.sh" "$@" + ;; status) load_env compose ps "$@" diff --git a/docker-compose.yml b/docker-compose.yml index 60bccc9..ab71288 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,17 @@ services: ] restart: "no" + # Explicitly invoked by backup/restore; never part of the runtime stack. + volume-tool: + image: ${FILES_INIT_IMAGE:-alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc} + profiles: [tools] + volumes: + - n8n_data:/volumes/n8n_data + - sftp_data:/volumes/sftp_data + - sftp_host_keys:/volumes/sftp_host_keys + entrypoint: ["sh"] + command: ["-c", "true"] + sftp: image: ${SFTP_BASE_IMAGE:-atmoz/sftp:alpine@sha256:81fa92512bf8ead4849f33c1c153907b86d32d77704d1c62a9c70b4316ae9e50} # The upstream image is amd64-only; Docker Desktop emulates it on Apple Silicon. @@ -66,11 +77,13 @@ services: N8N_HOST: ${N8N_HOST:-localhost} N8N_PORT: ${N8N_PORT:-5678} N8N_PROTOCOL: ${N8N_PROTOCOL:-http} - WEBHOOK_URL: ${WEBHOOK_URL:-http://localhost:5678/} + N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-${WEBHOOK_URL:-http://localhost:5678/}} N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true" N8N_RESTRICT_FILE_ACCESS_TO: /files N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE:-false} N8N_COMMUNITY_PACKAGES_ENABLED: "true" + N8N_UNVERIFIED_PACKAGES_ENABLED: "true" + N8N_RUNNERS_TASK_TIMEOUT: "300" N8N_INSTANCE_OWNER_MANAGED_BY_ENV: "true" N8N_INSTANCE_OWNER_EMAIL: ${N8N_OWNER_EMAIL:?set N8N_OWNER_EMAIL in .env} N8N_INSTANCE_OWNER_FIRST_NAME: ${N8N_OWNER_FIRST_NAME:-Syndicator} diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..c49fe43 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" + +load_env +"$ROOT/scripts/doctor.sh" --require-config >/dev/null + +output="" +if [[ "${1:-}" == "--output" && -n "${2:-}" && "$#" -eq 2 ]]; then + output="$2" +elif [[ "$#" -ne 0 ]]; then + echo "Usage: $0 [--output ARCHIVE.tar.gz]" >&2 + exit 2 +fi + +backup_dir="$(resolve_from_root "${SYNDICATOR_BACKUP_DIR:-backups}")" +mkdir -p "$backup_dir" +chmod 700 "$backup_dir" +if [[ -z "$output" ]]; then + output="$backup_dir/syndicator-$(date -u +%Y%m%dT%H%M%SZ).tar.gz" +else + output="$(resolve_from_root "$output")" + mkdir -p "$(dirname "$output")" +fi +if [[ -e "$output" ]]; then + echo "Backup already exists: $output" >&2 + exit 1 +fi + +staging="$(mktemp -d "$backup_dir/.syndicator-backup.XXXXXX")" +mkdir -p "$staging/config" "$staging/volumes" +chmod 700 "$staging" "$staging/config" "$staging/volumes" +temporary_output="" + +running_services=() +for service in sftp pyautoflip n8n; do + if [[ -n "$(compose ps --status running -q "$service")" ]]; then + running_services+=("$service") + fi +done +restarted=0 + +restart_services() { + if [[ "$restarted" -eq 0 && "${#running_services[@]}" -gt 0 ]]; then + compose start "${running_services[@]}" >/dev/null + restarted=1 + fi +} + +cleanup() { + status=$? + restart_services || true + rm -rf "$staging" + if [[ -n "$temporary_output" ]]; then + rm -f "$temporary_output" + fi + exit "$status" +} +trap cleanup EXIT + +if [[ "${#running_services[@]}" -gt 0 ]]; then + echo "Stopping stateful services for a consistent backup..." + compose stop "${running_services[@]}" >/dev/null +fi + +host_uid="$(id -u)" +host_gid="$(id -g)" +for volume in n8n_data sftp_data sftp_host_keys; do + echo "Archiving volume $volume..." + # shellcheck disable=SC2016 + compose run --rm --no-deps --user root \ + -e "BACKUP_VOLUME=$volume" \ + -e "HOST_UID=$host_uid" \ + -e "HOST_GID=$host_gid" \ + -v "$staging/volumes:/backup" \ + --entrypoint sh volume-tool -c ' + tar -czf "/backup/${BACKUP_VOLUME}.tar.gz" \ + -C "/volumes/${BACKUP_VOLUME}" . && + chown "${HOST_UID}:${HOST_GID}" "/backup/${BACKUP_VOLUME}.tar.gz" + ' >/dev/null +done + +copy_file() { + local source="$1" + local name="$2" + if [[ -f "$source" ]]; then + cp -p "$source" "$staging/config/$name" + fi +} + +owner_env="$(resolve_from_root "${N8N_OWNER_ENV_FILE:-secrets/n8n_owner.env}")" +api_key="$(resolve_from_root "${N8N_API_KEY_FILE:-secrets/n8n_api_key}")" +bootstrap_state="$(resolve_from_root "${N8N_BOOTSTRAP_STATE_FILE:-secrets/bootstrap.sha256}")" +private_key="$(resolve_from_root "${SFTP_PRIVATE_KEY_FILE:-secrets/sftp_n8n_ed25519}")" +keys_dir="$(resolve_from_root "${SFTP_KEYS_DIR:-sftp/keys}")" +release_state="$(release_state_file)" + +copy_file "$ENV_FILE" environment.env +copy_file "$owner_env" n8n_owner.env +copy_file "$api_key" n8n_api_key +copy_file "$bootstrap_state" bootstrap.sha256 +copy_file "$private_key" sftp_private_key +copy_file "$release_state" release.env +if [[ -d "$keys_dir" ]]; then + cp -Rp "$keys_dir" "$staging/config/sftp_keys" +fi + +git_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" +GIT_REVISION="$git_revision" python3 - "$staging" <<'PY' +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import sys + +root = Path(sys.argv[1]) +files = {} +for path in sorted(root.rglob("*")): + if path.is_file() and path.name != "manifest.json": + files[str(path.relative_to(root))] = hashlib.sha256(path.read_bytes()).hexdigest() +manifest = { + "format_version": 1, + "created_at": datetime.now(timezone.utc).isoformat(), + "git_revision": os.environ["GIT_REVISION"], + "files": files, +} +(root / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", +) +PY + +temporary_output="${output}.tmp.$$" +tar -czf "$temporary_output" -C "$staging" . +chmod 600 "$temporary_output" +mv "$temporary_output" "$output" +temporary_output="" + +restart_services +if [[ " ${running_services[*]} " == *" n8n "* && \ + " ${running_services[*]} " == *" sftp "* && \ + " ${running_services[*]} " == *" pyautoflip "* ]]; then + "$ROOT/scripts/verify.sh" >/dev/null +fi + +echo "Backup written to $output" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..4a57455 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" + +pull=0 +backup_on_change=1 +requested_tag="" +while [[ "$#" -gt 0 ]]; do + case "$1" in + --pull) + pull=1 + ;; + --no-backup) + backup_on_change=0 + ;; + --tag) + if [[ -z "${2:-}" ]]; then + echo "--tag requires a value." >&2 + exit 2 + fi + requested_tag="$2" + shift + ;; + *) + echo "Usage: $0 [--pull] [--no-backup] [--tag TAG]" >&2 + exit 2 + ;; + esac + shift +done + +"$ROOT/scripts/init.sh" +"$ROOT/scripts/doctor.sh" --require-config +load_env + +load_release_state +old_tag="${CURRENT_TAG:-}" +old_previous_tag="${PREVIOUS_TAG:-}" +old_rollback_backup="${ROLLBACK_BACKUP:-}" + +if [[ -n "$requested_tag" ]]; then + desired_tag="$requested_tag" +elif [[ -n "${SYNDICATOR_IMAGE_TAG:-}" ]]; then + desired_tag="$SYNDICATOR_IMAGE_TAG" +else + desired_tag="$(git rev-parse --short=12 HEAD 2>/dev/null || printf 'local')" +fi +if [[ ! "$desired_tag" =~ ^[a-zA-Z0-9_.-]+$ ]]; then + echo "Invalid image tag: $desired_tag" >&2 + exit 1 +fi + +backup_path="" +existing_container="$(compose ps -a -q n8n)" +if [[ "$backup_on_change" -eq 1 && \ + ( -n "$existing_container" ) && \ + ( -z "$old_tag" || "$old_tag" != "$desired_tag" ) ]]; then + backup_dir="$(resolve_from_root "${SYNDICATOR_BACKUP_DIR:-backups}")" + from_tag="${old_tag:-legacy}" + backup_path="$backup_dir/pre-update-${from_tag}-to-${desired_tag}-$(date -u +%Y%m%dT%H%M%SZ).tar.gz" + "$ROOT/scripts/backup.sh" --output "$backup_path" +fi + +export SYNDICATOR_IMAGE_TAG="$desired_tag" +if [[ "$pull" -eq 1 ]]; then + compose build --pull +else + compose build +fi +compose up -d --remove-orphans +"$ROOT/scripts/bootstrap-n8n.sh" +"$ROOT/scripts/verify.sh" + +if [[ "$old_tag" != "$desired_tag" ]]; then + previous_tag="$old_tag" + rollback_backup="$backup_path" +else + previous_tag="$old_previous_tag" + rollback_backup="$old_rollback_backup" +fi + +write_release_state "$desired_tag" "$previous_tag" "$rollback_backup" + +echo "Deployment $desired_tag is healthy." diff --git a/scripts/lib.sh b/scripts/lib.sh index c94aa52..a5f3a96 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -33,8 +33,48 @@ resolve_from_root() { fi } +release_state_file() { + resolve_from_root "${SYNDICATOR_RELEASE_STATE_FILE:-secrets/release.env}" +} + +load_release_state() { + local state + state="$(release_state_file)" + if [[ -s "$state" ]]; then + # shellcheck source=/dev/null + source "$state" + fi +} + +write_release_state() { + local current="$1" + local previous="${2:-}" + local rollback_backup="${3:-}" + local state temporary_state + state="$(release_state_file)" + mkdir -p "$(dirname "$state")" + umask 077 + temporary_state="${state}.tmp.$$" + { + printf 'CURRENT_TAG=%q\n' "$current" + printf 'PREVIOUS_TAG=%q\n' "$previous" + printf 'ROLLBACK_BACKUP=%q\n' "$rollback_backup" + printf 'DEPLOYED_GIT_REVISION=%q\n' \ + "$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" + printf 'DEPLOYED_AT=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } >"$temporary_state" + chmod 600 "$temporary_state" + mv "$temporary_state" "$state" +} + compose() { local args=(--env-file "$ENV_FILE") + if [[ -z "${SYNDICATOR_IMAGE_TAG:-}" ]]; then + load_release_state + if [[ -n "${CURRENT_TAG:-}" ]]; then + export SYNDICATOR_IMAGE_TAG="$CURRENT_TAG" + fi + fi if [[ -n "${SYNDICATOR_PROJECT:-}" ]]; then args+=(-p "$SYNDICATOR_PROJECT") fi diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..77430af --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" + +forced_image_tag="${SYNDICATOR_IMAGE_TAG:-}" +confirmed=0 +build_images=1 +while [[ "${1:-}" == --* ]]; do + case "$1" in + --yes) + confirmed=1 + ;; + --no-build) + build_images=0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 2 + ;; + esac + shift +done +if [[ "$#" -ne 1 ]]; then + echo "Usage: $0 --yes [--no-build] ARCHIVE.tar.gz" >&2 + exit 2 +fi +if [[ "$confirmed" -ne 1 ]]; then + echo "Restore replaces current configuration and persistent data; pass --yes." >&2 + exit 2 +fi + +archive="$(resolve_from_root "$1")" +if [[ ! -f "$archive" ]]; then + echo "Backup archive not found: $archive" >&2 + exit 1 +fi + +for command in docker python3 tar; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Missing required command: $command" >&2 + exit 1 + fi +done +if ! docker info >/dev/null 2>&1; then + echo "Docker daemon is unavailable." >&2 + exit 1 +fi + +staging="$(mktemp -d)" +trap 'rm -rf "$staging"' EXIT +python3 - "$archive" "$staging" <<'PY' +import hashlib +import json +from pathlib import Path +import sys +import tarfile + +archive = Path(sys.argv[1]) +destination = Path(sys.argv[2]).resolve() +with tarfile.open(archive, "r:gz") as bundle: + for member in bundle.getmembers(): + target = (destination / member.name).resolve() + if destination != target and destination not in target.parents: + raise SystemExit(f"Unsafe archive member: {member.name}") + if member.issym() or member.islnk() or member.isdev(): + raise SystemExit(f"Unsupported archive member: {member.name}") + bundle.extractall(destination) + +manifest_path = destination / "manifest.json" +if not manifest_path.is_file(): + raise SystemExit("Backup has no manifest.json") +manifest = json.loads(manifest_path.read_text(encoding="utf-8")) +if manifest.get("format_version") != 1: + raise SystemExit(f"Unsupported backup format: {manifest.get('format_version')}") +for relative, expected in manifest.get("files", {}).items(): + path = destination / relative + if not path.is_file(): + raise SystemExit(f"Backup member is missing: {relative}") + actual = hashlib.sha256(path.read_bytes()).hexdigest() + if actual != expected: + raise SystemExit(f"Checksum mismatch: {relative}") +PY + +for volume in n8n_data sftp_data sftp_host_keys; do + if [[ ! -f "$staging/volumes/${volume}.tar.gz" ]]; then + echo "Backup is missing volume archive: $volume" >&2 + exit 1 + fi +done +if [[ ! -f "$staging/config/environment.env" ]]; then + echo "Backup is missing environment configuration." >&2 + exit 1 +fi + +if [[ -f "$ENV_FILE" ]]; then + load_env + compose stop n8n sftp pyautoflip >/dev/null 2>&1 || true +fi + +mkdir -p "$(dirname "$ENV_FILE")" +cp "$staging/config/environment.env" "$ENV_FILE" +chmod 600 "$ENV_FILE" +unset SYNDICATOR_IMAGE_TAG +load_env +environment_image_tag="${SYNDICATOR_IMAGE_TAG:-}" + +restore_file() { + local name="$1" + local target="$2" + mkdir -p "$(dirname "$target")" + if [[ -f "$staging/config/$name" ]]; then + cp "$staging/config/$name" "$target" + chmod 600 "$target" + else + rm -f "$target" + fi +} + +owner_env="$(resolve_from_root "${N8N_OWNER_ENV_FILE:-secrets/n8n_owner.env}")" +api_key="$(resolve_from_root "${N8N_API_KEY_FILE:-secrets/n8n_api_key}")" +bootstrap_state="$(resolve_from_root "${N8N_BOOTSTRAP_STATE_FILE:-secrets/bootstrap.sha256}")" +private_key="$(resolve_from_root "${SFTP_PRIVATE_KEY_FILE:-secrets/sftp_n8n_ed25519}")" +keys_dir="$(resolve_from_root "${SFTP_KEYS_DIR:-sftp/keys}")" +release_state="$(release_state_file)" + +restore_file n8n_owner.env "$owner_env" +restore_file n8n_api_key "$api_key" +restore_file bootstrap.sha256 "$bootstrap_state" +restore_file sftp_private_key "$private_key" +restore_file release.env "$release_state" +if [[ ! -s "$owner_env" || ! -s "$private_key" ]]; then + echo "Backup is missing required owner or SFTP credentials." >&2 + exit 1 +fi + +rm -rf "$keys_dir" +mkdir -p "$(dirname "$keys_dir")" +if [[ -d "$staging/config/sftp_keys" ]]; then + cp -Rp "$staging/config/sftp_keys" "$keys_dir" +else + mkdir -p "$keys_dir" + ssh-keygen -y -f "$private_key" >"$keys_dir/n8n.pub" +fi + +if [[ -n "$forced_image_tag" ]]; then + export SYNDICATOR_IMAGE_TAG="$forced_image_tag" +elif [[ -n "$environment_image_tag" ]]; then + export SYNDICATOR_IMAGE_TAG="$environment_image_tag" +else + unset SYNDICATOR_IMAGE_TAG CURRENT_TAG PREVIOUS_TAG ROLLBACK_BACKUP + load_release_state + if [[ -n "${CURRENT_TAG:-}" ]]; then + export SYNDICATOR_IMAGE_TAG="$CURRENT_TAG" + fi +fi + +for volume in n8n_data sftp_data sftp_host_keys; do + echo "Restoring volume $volume..." + # shellcheck disable=SC2016 + compose run --rm --no-deps --user root \ + -e "RESTORE_VOLUME=$volume" \ + -v "$staging/volumes:/backup:ro" \ + --entrypoint sh volume-tool -c ' + target="/volumes/${RESTORE_VOLUME}" && + rm -rf "$target"/* "$target"/.[!.]* "$target"/..?* && + tar -xzf "/backup/${RESTORE_VOLUME}.tar.gz" -C "$target" + ' >/dev/null +done + +if [[ "$build_images" -eq 1 ]]; then + compose build +fi +compose up -d --remove-orphans +"$ROOT/scripts/bootstrap-n8n.sh" +"$ROOT/scripts/verify.sh" +echo "Restore from $archive completed." diff --git a/scripts/rollback.sh b/scripts/rollback.sh new file mode 100755 index 0000000..5798906 --- /dev/null +++ b/scripts/rollback.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" + +if [[ "$#" -ne 0 ]]; then + echo "Usage: $0" >&2 + exit 2 +fi + +load_env +load_release_state +current_tag="${CURRENT_TAG:-}" +previous_tag="${PREVIOUS_TAG:-}" +rollback_backup="${ROLLBACK_BACKUP:-}" + +if [[ -z "$current_tag" || -z "$previous_tag" || -z "$rollback_backup" ]]; then + echo "No complete previous release and backup are recorded." >&2 + exit 1 +fi +if [[ ! -f "$rollback_backup" ]]; then + echo "Recorded rollback backup is missing: $rollback_backup" >&2 + exit 1 +fi +for image in "syndicator-n8n:$previous_tag" "syndicator-pyautoflip:$previous_tag"; do + if ! docker image inspect "$image" >/dev/null 2>&1; then + echo "Previous release image is missing: $image" >&2 + exit 1 + fi +done + +export SYNDICATOR_IMAGE_TAG="$current_tag" +backup_dir="$(resolve_from_root "${SYNDICATOR_BACKUP_DIR:-backups}")" +forward_backup="$backup_dir/pre-rollback-${current_tag}-to-${previous_tag}-$(date -u +%Y%m%dT%H%M%SZ).tar.gz" +"$ROOT/scripts/backup.sh" --output "$forward_backup" + +export SYNDICATOR_IMAGE_TAG="$previous_tag" +"$ROOT/scripts/restore.sh" --yes --no-build "$rollback_backup" +write_release_state "$previous_tag" "$current_tag" "$forward_backup" + +echo "Rolled back from $current_tag to $previous_tag." diff --git a/scripts/update.sh b/scripts/update.sh index 6780b55..982d4de 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -1,47 +1,6 @@ #!/usr/bin/env bash -# Rebuild n8n + pyautoflip from the latest base images and recreate when changed. -# Safe to re-run (volumes untouched). Intended for cron / systemd timer. +# Deploy the reviewed, pinned sources in the current checkout. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" - -LOG="${UPDATE_LOG:-$ROOT/update.log}" -mkdir -p "$(dirname "$LOG")" -exec >>"$LOG" 2>&1 - -echo "=== $(date -Is) starting syndicator update ===" - -n8n_old="$(docker image inspect syndicator-n8n:stable --format '{{.Id}}' 2>/dev/null || true)" -py_old="$(docker image inspect syndicator-pyautoflip:local --format '{{.Id}}' 2>/dev/null || true)" - -docker compose build --pull -docker compose up -d --remove-orphans - -n8n_new="$(docker image inspect syndicator-n8n:stable --format '{{.Id}}' 2>/dev/null || true)" -py_new="$(docker image inspect syndicator-pyautoflip:local --format '{{.Id}}' 2>/dev/null || true)" - -changed=0 -if [[ "$n8n_old" != "$n8n_new" ]]; then - echo "Updated syndicator-n8n:stable" - echo " old: ${n8n_old:-}" - echo " new: $n8n_new" - changed=1 -else - echo "n8n image unchanged (${n8n_new:-})" -fi - -if [[ "$py_old" != "$py_new" ]]; then - echo "Updated syndicator-pyautoflip:local" - echo " old: ${py_old:-}" - echo " new: $py_new" - changed=1 -else - echo "pyautoflip image unchanged (${py_new:-})" -fi - -if [[ "$changed" -eq 1 ]]; then - docker image prune -f >/dev/null -fi - -echo "=== $(date -Is) done ===" +exec "$ROOT/scripts/deploy.sh" --pull "$@" diff --git a/systemd/syndicator-update.service b/systemd/syndicator-update.service deleted file mode 100644 index 85ed2d1..0000000 --- a/systemd/syndicator-update.service +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Rebuild syndicator compose images (n8n + pyautoflip) -After=docker.service -Requires=docker.service - -[Service] -Type=oneshot -WorkingDirectory=/home/benno/git/syndicator -ExecStart=/home/benno/git/syndicator/scripts/update.sh -User=benno -Group=benno diff --git a/systemd/syndicator-update.timer b/systemd/syndicator-update.timer deleted file mode 100644 index f64fa24..0000000 --- a/systemd/syndicator-update.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Daily syndicator image update - -[Timer] -OnCalendar=*-*-* 04:00:00 -Persistent=true -Unit=syndicator-update.service - -[Install] -WantedBy=timers.target diff --git a/tests/integration/stack.sh b/tests/integration/stack.sh index d08ccf9..db593fe 100755 --- a/tests/integration/stack.sh +++ b/tests/integration/stack.sh @@ -27,7 +27,7 @@ N8N_HOST=127.0.0.1 N8N_PORT=5678 N8N_PROTOCOL=http N8N_HOST_PORT=$n8n_port -WEBHOOK_URL=http://127.0.0.1:$n8n_port/ +N8N_WEBHOOK_URL=http://127.0.0.1:$n8n_port/ N8N_SECURE_COOKIE=false N8N_ENCRYPTION_KEY=integration-only-encryption-key N8N_OWNER_EMAIL=ci@example.invalid @@ -43,7 +43,8 @@ SFTP_USERNAME=sftp SFTP_PRIVATE_KEY_FILE=$tmp/sftp_n8n_ed25519 SFTP_KEYS_DIR=$tmp/keys PYAUTOFLIP_WARM_MODELS=0 -SYNDICATOR_IMAGE_TAG=local +SYNDICATOR_BACKUP_DIR=$tmp/backups +SYNDICATOR_RELEASE_STATE_FILE=$tmp/release.env EOF chmod 600 "$env_file" @@ -106,9 +107,8 @@ if actual != expected: printf '%s\n' "integration payload" >"$tmp/upload.txt" ssh-keyscan -p "$sftp_port" 127.0.0.1 >"$tmp/known_hosts" 2>/dev/null cat >"$tmp/sftp.batch" <"$tmp/sftp-remove.batch" <"$tmp/sftp-restored.batch" <&2 + exit 1 +fi + echo "Isolated stack integration test passed." From 1fb54a0bc15db3845b50a8e510240563268615b7 Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Wed, 12 Aug 2026 12:10:47 +0200 Subject: [PATCH 05/16] Document the deployment operating model Record why Compose remains the application boundary and provide a complete runbook with safe network defaults and clear Ansible/Terraform boundaries. Co-authored-by: Cursor --- .env.example | 2 + README.md | 26 +-- docker-compose.yml | 4 +- docs/adr/0001-deployment-model.md | 118 ++++++++++++ docs/operations.md | 292 ++++++++++++++++++++++++++++++ pyautoflip/README.md | 8 +- tests/test_repository.py | 16 ++ 7 files changed, 448 insertions(+), 18 deletions(-) create mode 100644 docs/adr/0001-deployment-model.md create mode 100644 docs/operations.md diff --git a/.env.example b/.env.example index 4e152cf..1513aec 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ GENERIC_TIMEZONE=Europe/Zurich N8N_HOST=localhost N8N_PORT=5678 N8N_PROTOCOL=http +N8N_BIND_ADDRESS=127.0.0.1 N8N_HOST_PORT=5678 N8N_WEBHOOK_URL=http://localhost:5678/ N8N_SECURE_COOKIE=false @@ -32,6 +33,7 @@ N8N_OWNER_PASSWORD= # N8N_BOOTSTRAP_STATE_FILE=secrets/bootstrap.sha256 # --- SFTP (published to host; internal compose hostname is always "sftp") --- +SFTP_BIND_ADDRESS=127.0.0.1 SFTP_PUBLISH_PORT=2222 # SFTP_KEYS_DIR=./sftp/keys diff --git a/README.md b/README.md index 2e42692..7968601 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Syndicator Syndicator takes a blog post and: -1. Generate a static web site translated to languages EN, FR, ES, SP, IT, and Pirate Speak -2. Distribute the blog post to social media platforms Instagram, Facebook, Youtube, and X. +1. Generate a static web site translated to EN, DE, ES, FR, IT, and Pirate Speak +2. Distribute the blog post to social media platforms Instagram, Facebook, YouTube, and X. It uses AI extensively for various aspects like translation, post text generation, and media cropping. @@ -24,7 +24,7 @@ ASCII context (kept for LLM / text-only readers): Syndicator provides the `syndicate` interface specified in this document. * Syndicator uses [Postiz](https://postiz.com/) to schedule social media posts. -* Syndicator uses [OpenAI](https://openai.com/) for KI tasks. +* Syndicator uses [OpenAI](https://openai.com/) for AI tasks. * Syndicator uses [Hugo](https://gohugo.io/) to generate static blog post site. ## syndicate interface @@ -167,8 +167,11 @@ Owner account is provisioned from env on n8n start (`N8N_INSTANCE_OWNER_*`). Boo `init` writes `secrets/sftp_n8n_ed25519` (private), `sftp/keys/n8n.pub` (public), and `secrets/n8n_owner.env` (bcrypt hash for Compose). Extra client keys: copy any `.pub` into `sftp/keys/` and restart SFTP. Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`. +Published ports bind to loopback by default. Read the [operations runbook](docs/operations.md) before enabling LAN or internet access. + The `files-init` Compose service chowns the shared `n8n_files` volume to uid/gid `1000` on each `up` so n8n and pyautoflip can write under `/files`. -## Update Worfklows + +## Update workflows `bin/syndicator export` exports sanitized workflows from n8n into `n8n/workflows/`. @@ -188,9 +191,9 @@ An update gets a commit-based image tag and creates a consistent backup when the ## Architecture -The workflow engine, n8n, orchestrates all blog post processing via modular workflows. The most important non-functional requirements are automation and maintainability, as the goal is to minimize time spent managing social media platforms. The initial version of Syndicator was "custom-made" by LLMs, but quickly became unmaintainable. This experience highlighted the need to adopt a workflow engine and decompose the blog post processing into simple, easy-to-understand nodes. This approach not only streamlines debugging and scaling, but also leverages a higher-level runtime environment. +The workflow engine, n8n, orchestrates all blog post processing via modular workflows. The most important non-functional requirements are repeatability, testability, automation, and maintainability. The initial custom pipeline became difficult to change, which motivated decomposing processing into visible workflow nodes. -However, this comes with increased setup complexity—which is why everything is containerized, aiming for a "one-click" deployment to spin up new instances, including workflow instantiation and authentication setup. While achieving this seamless setup remains a work in progress, it is still uncertain whether the chosen technology stack can fully deliver on this vision. +Compose remains the application boundary because it isolates three different runtimes and provides the same topology on macOS and Linux. The operator lifecycle is intentionally separate and tested through `bin/syndicator`. The rationale and rejected alternatives are recorded in [ADR 0001](docs/adr/0001-deployment-model.md). ## Software Design @@ -205,8 +208,8 @@ The repo is the blueprint for a containerized instance: Compose defines the stac | `docker-compose.yml` | Compose stack: files-init + SFTP + n8n + pyautoflip | | `.env.example` | Env template for secrets and host paths | | `n8n/Dockerfile` | Custom n8n image (`ffmpeg` + community node seed) | -| `sftp/Dockerfile` | atmoz/sftp wrapper (host keys volume, key sync, chown) | -| `scripts/` | ensure-sftp-keys / ensure-n8n-owner / bootstrap / export / update | +| `sftp/setup.sh` | Supported atmoz startup hook for durable host keys, key sync, and ownership | +| `scripts/` | Focused lifecycle implementations behind `bin/syndicator` | | `n8n/workflows/` | Importable workflow exports (source of truth) | | `n8n/credentials/` | Credential templates (stable IDs; secrets from `.env`) | | `pyautoflip/` | Image/build context for the reframe sidecar | @@ -221,7 +224,8 @@ n8n/workflows/ n8n/credentials/*.template.json pyautoflip/ sftp/ -scripts/{ensure-sftp-keys,ensure-n8n-owner,bootstrap,export,update}.sh +scripts/{init,deploy,bootstrap,verify,backup,restore,update,rollback,export}.sh +docs/{operations.md,adr/} bin/syndicator ``` @@ -254,8 +258,8 @@ flowchart LR | Workflow | Role | |----------|------| -| Blog Post Publish | Webhook `/publish` → Hugo adapt + social feature adapt | -| Reel Publish | Webhook `/reel` → adapt → caption → Postiz | +| Blog Post Publish | Webhook `/webhook/publish` → Hugo adapt + social feature adapt | +| Reel Publish | Webhook `/webhook/reel` → adapt → caption → Postiz | For brevity, subworkflows invoked by these workflows are not listed here. diff --git a/docker-compose.yml b/docker-compose.yml index ab71288..1f90e7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,7 @@ services: platform: ${SFTP_PLATFORM:-linux/amd64} restart: unless-stopped ports: - - "${SFTP_PUBLISH_PORT:-2222}:22" + - "${SFTP_BIND_ADDRESS:-127.0.0.1}:${SFTP_PUBLISH_PORT:-2222}:22" volumes: - sftp_data:/home/sftp/syndicator # Client public keys (ensure-sftp-keys.sh writes n8n.pub here). @@ -66,7 +66,7 @@ services: image: syndicator-n8n:${SYNDICATOR_IMAGE_TAG:-local} restart: unless-stopped ports: - - "${N8N_HOST_PORT:-5678}:5678" + - "${N8N_BIND_ADDRESS:-127.0.0.1}:${N8N_HOST_PORT:-5678}:5678" # Password hash written by ./scripts/ensure-n8n-owner.sh (bcrypt; $ escaped as $$). env_file: - ${N8N_OWNER_ENV_FILE:-secrets/n8n_owner.env} diff --git a/docs/adr/0001-deployment-model.md b/docs/adr/0001-deployment-model.md new file mode 100644 index 0000000..3407b50 --- /dev/null +++ b/docs/adr/0001-deployment-model.md @@ -0,0 +1,118 @@ +# ADR 0001: Compose as the application boundary + +Status: accepted +Date: 2026-08-12 + +## Context + +Syndicator runs three materially different services on one machine: + +- n8n/Node with ffmpeg and two community node packages +- pyautoflip/Python with native media and machine-learning dependencies +- a chrooted OpenSSH SFTP endpoint + +The previous setup mixed a small declarative Compose file with a large, +stateful host bootstrap. Image tags floated, workflow IDs were duplicated in +scripts, bootstrap read n8n's private SQLite schema, and updates had no tested +backup or rollback. This made Docker appear to be the source of the +complexity, although most fragility was in the imperative lifecycle around it. + +The supported target for the next one to two years is a developer Mac and one +production Linux host. Repeatability, testability, and low operator effort are +more important than eliminating containers. + +## Decision + +Keep Docker Compose as the application packaging and runtime boundary. + +- Dockerfiles own language runtimes, native libraries, ffmpeg, community + packages, and model preparation. +- Compose owns service networking, health, startup dependencies, ports, and + persistent volumes. +- `bin/syndicator` is the only operator-facing lifecycle. It delegates to + focused scripts for initialization, deployment, reconciliation, + verification, backup, restore, update, and rollback. +- Runtime inputs are pinned. Dependency changes arrive as reviewable pull + requests and must pass an isolated full-stack test before deployment. +- Each changed release is tagged by Git revision, backed up before deployment, + and retains the previous images and matching state for rollback. + +Ansible may be added outside this boundary to prepare a Linux host: install +Docker, configure a firewall or reverse proxy, place the repository and +encrypted secrets, and invoke `bin/syndicator deploy`. It must not reproduce +the application installation, workflow import, backup, or update logic. + +Terraform is reserved for infrastructure resources such as a VM, DNS records, +firewall rules, and backup storage. It is not used to configure processes or +packages inside the host. + +## Alternatives considered + +### Native shell installer + +Rejected. It would need to reconcile Node/n8n, npm packages, two ffmpeg +installations, Python 3.12 and native ML libraries, model downloads, OpenSSH +users and chroot permissions, systemd units, and macOS/Linux differences. +Making that installer idempotent and reversible would recreate a container +runtime poorly. + +### Native Ansible services + +Viable only if removing Docker becomes a hard operational requirement. +Ansible improves idempotency over shell, but the role would still own all +language runtimes, system packages, users, permissions, and service units. A +Linux VM test matrix would also replace the current Mac/Linux parity. + +### Ansible wrapping Compose + +Compatible with this decision. It becomes worthwhile when rebuilding the +production host itself is frequent or when firewall, TLS, and off-host backup +configuration need to be managed. For one host it remains optional so the +application does not acquire a second mandatory control plane. + +### Puppet + +Rejected for the current scale. Its long-lived host convergence model is +valuable for fleets of managed machines, not one application host. + +### Terraform + +Rejected as an application installer. Provisioners or remote-exec would make +host changes less testable and less idempotent. Terraform can still create the +host around Syndicator. + +### Kubernetes or Nomad + +Rejected. The stack is single-host, uses SQLite and local shared storage, and +does not need scheduling or high availability. A cluster orchestrator would +add more state and failure modes than it removes. + +### Direct n8n access to the SFTP data volume + +Deferred. It could remove the internal SFTP credential and key exchange, but +it requires changing 17 workflow nodes and would couple workflows to the +single-host layout. The external SFTP interface stays stable; this optimization +can be reconsidered now that integration tests protect the behavior. + +## Consequences + +- Docker remains a prerequisite, but operators need only the lifecycle + commands documented in [operations.md](../operations.md). +- The SFTP base is amd64-only and runs through Docker Desktop emulation on + Apple Silicon. The production Linux host should preferably be amd64. +- n8n's internal JavaScript task runner is accepted for this trusted, + single-user deployment. External runners should be evaluated before + untrusted users can edit workflows. +- The stack does not provide TLS or webhook authentication. Safe defaults bind + published ports to loopback; exposing them requires an explicit network and + reverse-proxy decision. +- Backups contain secrets. File mode `0600` is only a local safeguard; off-host + copies must be encrypted. + +## Revisit when + +- more than a few hosts need centralized convergence +- n8n moves to PostgreSQL/queue mode or services move to separate machines +- untrusted users can author workflows +- zero-downtime deployment or high availability becomes a requirement +- the external SFTP contract is retired diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..88f8fe3 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,292 @@ +# Syndicator operations + +This runbook covers the supported topology: Docker Desktop on a developer Mac +and Docker Engine with the Compose plugin on one Linux host. + +## Prerequisites + +The host needs: + +- Docker Engine or Docker Desktop with `docker compose` +- Bash, Python 3, curl, OpenSSL, and an OpenSSH client +- enough disk for the n8n and pyautoflip images, media staging, and backups +- amd64 execution support for the SFTP image; Docker Desktop supplies + emulation on Apple Silicon + +Check the host without changing anything: + +```bash +bin/syndicator doctor +``` + +## First installation + +Create the local configuration: + +```bash +bin/syndicator init +``` + +On the first run this creates `.env`, generates `N8N_ENCRYPTION_KEY`, and stops +with a list of values that still need input. Fill in: + +- n8n owner email and password +- OpenAI API key +- Postiz API key +- the public URL and bind addresses appropriate for the host + +Then deploy: + +```bash +bin/syndicator deploy +``` + +`deploy` performs initialization and diagnostics again, builds immutable +inputs, starts the stack, reconciles n8n credentials and workflows, and runs +end-to-end health checks. Running it again is safe. If source configuration is +unchanged and all workflows remain published, n8n import is skipped. + +The first controlled deployment writes `secrets/release.env`. Unless +`SYNDICATOR_IMAGE_TAG` is explicitly set, images use the current 12-character +Git revision as their tag. + +## Local and network configuration + +The checked-in defaults bind n8n and SFTP to `127.0.0.1`. This is safe for +local development but not reachable by another LAN machine. + +For trusted LAN access, set the required bind addresses explicitly: + +```dotenv +N8N_BIND_ADDRESS=0.0.0.0 +SFTP_BIND_ADDRESS=0.0.0.0 +N8N_HOST=192.0.2.10 +N8N_WEBHOOK_URL=http://192.0.2.10:5678/ +``` + +Use the real host address, not the documentation address above. Restrict access +with the host firewall. + +For an internet-facing host: + +- keep `N8N_BIND_ADDRESS=127.0.0.1` +- terminate HTTPS in Caddy, nginx, or another reverse proxy +- set `N8N_WEBHOOK_URL=https://.../`, `N8N_PROTOCOL=https`, and + `N8N_SECURE_COOKIE=true` +- set `N8N_PROXY_HOPS=1` when there is one trusted reverse proxy +- expose SFTP only to required source addresses + +The current webhook workflows do not authenticate requests. Do not publish the +n8n port directly to an untrusted network. Adding webhook authentication is an +application contract change and must be coordinated with callers. + +## Routine commands + +Inspect status: + +```bash +bin/syndicator status +bin/syndicator logs +``` + +Run non-mutating service and contract checks: + +```bash +bin/syndicator verify +``` + +Reconcile n8n after a workflow or credential-template change: + +```bash +bin/syndicator bootstrap +``` + +Export workflows after editing them in n8n: + +```bash +bin/syndicator export +python3 -m unittest discover -s tests -p 'test_*.py' +``` + +Exports are normalized: pin data, instance IDs, project ownership, and version +metadata are removed. Review the resulting JSON before committing it. + +If the owner password changes, update `N8N_OWNER_PASSWORD` in `.env`, then run: + +```bash +scripts/ensure-n8n-owner.sh --force +bin/syndicator deploy +``` + +## Dependency updates + +Container, npm, pip, and GitHub Actions dependencies are proposed weekly by +Dependabot. Do not edit a floating `latest` or `stable` tag on the server. + +For an update: + +1. Review the release notes and dependency diff. +2. Let CI validate manifests, audit npm dependencies, build both images, deploy + an isolated stack twice, test SFTP I/O, restore a backup, and exercise + rollback. +3. Pull the reviewed Git revision on the server. +4. Run: + +```bash +bin/syndicator update +``` + +When the Git revision changes, update creates a consistent pre-update backup, +builds commit-tagged images, deploys, and verifies. Previous images are not +pruned because rollback needs them. + +An explicit tag is available for release testing: + +```bash +bin/syndicator update --tag release-candidate-1 +``` + +## Backups + +Create a backup: + +```bash +bin/syndicator backup +``` + +The command briefly stops stateful services so SQLite and SFTP data are +consistent. It archives: + +- `n8n_data` +- `sftp_data` +- `sftp_host_keys` +- `.env`, n8n owner/API/bootstrap state, SFTP client keys, and release state +- a manifest containing SHA-256 checksums and the Git revision + +The shared processing directory `n8n_files` is scratch space. The +`pyautoflip_home` model cache is reconstructible. Neither is backed up. + +Archives default to `backups/` and mode `0600`. They still contain plaintext +credentials. Copy them to encrypted off-host storage and apply an external +retention policy; the repository deliberately does not choose a storage +provider or encryption key lifecycle. + +To select a destination: + +```bash +bin/syndicator backup --output /secure/path/syndicator.tar.gz +``` + +## Restore and disaster recovery + +Restore is destructive and requires explicit confirmation: + +```bash +bin/syndicator restore --yes /secure/path/syndicator.tar.gz +``` + +Before changing state, restore rejects unsafe archive paths, unsupported +members, missing critical volume archives, unsupported formats, and checksum +mismatches. It replaces current configuration and critical volumes, rebuilds +the checked-out revision by default, starts the stack, reconciles n8n, and +verifies all services. + +For disaster recovery on a new host: + +1. Install the prerequisites. +2. Check out the Git revision recorded in `manifest.json` inside the backup. +3. Place the encrypted backup on the host and decrypt it locally. +4. Run the restore command. +5. Verify firewall, DNS, reverse proxy, and off-host backup scheduling. + +`--no-build` is reserved for rollback or for a restore where the exact tagged +images are already present. + +## Rollback + +Rollback is available after a release-changing update: + +```bash +bin/syndicator rollback +``` + +It requires: + +- `PREVIOUS_TAG` and `ROLLBACK_BACKUP` in `secrets/release.env` +- both previous application images still present locally +- the matching pre-update backup + +Before restoring the previous release, rollback backs up the current release. +This creates a reversible roll-forward point. It then restores matching data, +starts the previous image tags, verifies the stack, and swaps the current and +previous release records. + +Do not use `docker image prune -a` while rollback retention is required. + +## Testing + +Fast checks: + +```bash +python3 -m unittest discover -s tests -p 'test_*.py' +bash tests/test-init.sh +bash tests/validate-compose.sh +npm audit --prefix n8n +``` + +Full isolated test: + +```bash +PYAUTOFLIP_WARM_MODELS=0 bash tests/validate-compose.sh build n8n pyautoflip +bash tests/integration/stack.sh +``` + +The integration test uses random loopback ports and a unique Compose project. +It deploys twice, checks that API keys and resources are not duplicated, +uploads over SFTP, validates backup/restore, deploys a second release tag, +rolls back, and removes all test containers and volumes. + +## Troubleshooting + +If bootstrap reports n8n as unavailable, inspect readiness and logs: + +```bash +bin/syndicator status +bin/syndicator logs n8n +``` + +The stack uses `/healthz/readiness`, not `/healthz`, so first-start database +migrations must finish before provisioning starts. + +If SFTP host-key verification changes unexpectedly, do not delete the client +known-host entry until the cause is understood. Host keys are persistent state +in `sftp_host_keys` and are included in backups. + +If credentials cannot be decrypted after a restore, the +`N8N_ENCRYPTION_KEY` does not match `n8n_data`. Restore `.env` and the volume +from the same archive. + +If Apple Silicon reports an SFTP platform warning, confirm +`SFTP_PLATFORM=linux/amd64`; the image is intentionally emulated. + +The n8n image may log that its internal Python runner is absent. Syndicator +uses JavaScript Code nodes and pyautoflip as a separate Python service, so no +Python n8n runner is required. If untrusted users gain workflow-edit access, +move JavaScript execution to n8n's external runner model instead of adding +Python to the main container. + +## Optional host automation + +Ansible is intentionally not required for application operation. Add it when +the Linux machine itself must be recreated automatically. Its responsibilities +should stop at: + +- installing a reviewed Docker Engine/Compose version and host utilities +- creating the deployment user and directory +- configuring firewall, TLS proxy, and encrypted off-host backup transport +- placing `.env` and other bootstrap secrets from a vault +- checking out a reviewed Git revision and invoking `bin/syndicator deploy` + +Do not duplicate Compose services, Dockerfile package installation, n8n +bootstrap, or backup logic in Ansible. Terraform belongs one level further +out: VM, DNS, network rules, and storage resources only. diff --git a/pyautoflip/README.md b/pyautoflip/README.md index 362a23f..dc597cc 100644 --- a/pyautoflip/README.md +++ b/pyautoflip/README.md @@ -7,7 +7,8 @@ the `/files` volume so videos are not uploaded through HTTP bodies. Defined as the `pyautoflip` service in [`../docker-compose.yml`](../docker-compose.yml). ```bash -docker compose up -d --build pyautoflip +# From the repository root: +bin/syndicator deploy ``` ## API @@ -57,7 +58,4 @@ docker build -t syndicator-pyautoflip:local . ## n8n workflow **Adapt Reel Media** (`y9TTx7N8Iygn88ry`) calls this service. The live workflow -is edited in n8n; export back with `scripts/export-workflows.sh`. - -After cutover, remove any ad-hoc `pyautoflip` service from host `~/n8n/compose.yaml` -so `docker-compose.yml` is the single definition. +is edited in n8n; export back with `bin/syndicator export`. diff --git a/tests/test_repository.py b/tests/test_repository.py index 53afb54..d34fd64 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -5,6 +5,7 @@ import unittest from pathlib import Path from typing import Any +from urllib.parse import unquote, urlparse ROOT = Path(__file__).resolve().parents[1] @@ -152,6 +153,21 @@ def test_bootstrap_uses_supported_interfaces(self) -> None: library = (ROOT / "scripts" / "lib.sh").read_text(encoding="utf-8") self.assertIn("/healthz/readiness", library) + def test_local_markdown_links_resolve(self) -> None: + link_pattern = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") + for document in ROOT.rglob("*.md"): + if {".git", ".venv", "node_modules"}.intersection(document.parts): + continue + for raw_target in link_pattern.findall( + document.read_text(encoding="utf-8") + ): + target = raw_target.split(maxsplit=1)[0].strip("<>") + parsed = urlparse(target) + if parsed.scheme or target.startswith("#"): + continue + path = (document.parent / unquote(parsed.path)).resolve() + self.assertTrue(path.exists(), f"{document}: broken link {target}") + if __name__ == "__main__": unittest.main() From dc51da2cccc7c6ef5b6e482a15a8a43c9b45b292 Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Wed, 12 Aug 2026 13:36:02 +0200 Subject: [PATCH 06/16] Harden release and restore boundaries Stop partial deployments, bind rollback to exact source revisions, validate archives before mutation, and parse dotenv files without shell evaluation. Co-authored-by: Cursor --- scripts/backup.sh | 13 +++- scripts/bootstrap-n8n.sh | 41 ++++++----- scripts/deploy.sh | 88 ++++++++++++++++++++--- scripts/dotenv.py | 78 ++++++++++++++++++++ scripts/lib.sh | 57 +++++++++++++-- scripts/restore.sh | 144 ++++++++++++++++++++++++++++++------- scripts/rollback.sh | 39 +++++++++- scripts/verify.sh | 2 +- tests/integration/stack.sh | 37 ++++++++++ 9 files changed, 432 insertions(+), 67 deletions(-) create mode 100755 scripts/dotenv.py diff --git a/scripts/backup.sh b/scripts/backup.sh index c49fe43..8ae0b42 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -5,6 +5,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" # shellcheck source=scripts/lib.sh source "$ROOT/scripts/lib.sh" +umask 077 load_env "$ROOT/scripts/doctor.sh" --require-config >/dev/null @@ -108,8 +109,13 @@ if [[ -d "$keys_dir" ]]; then cp -Rp "$keys_dir" "$staging/config/sftp_keys" fi -git_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" -GIT_REVISION="$git_revision" python3 - "$staging" <<'PY' +load_release_state +git_revision="${CURRENT_GIT_REVISION:-${DEPLOYED_GIT_REVISION:-}}" +if [[ -z "$git_revision" ]]; then + git_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" +fi +GIT_REVISION="$git_revision" RELEASE_TAG="${CURRENT_TAG:-unknown}" \ + python3 - "$staging" <<'PY' from datetime import datetime, timezone import hashlib import json @@ -126,6 +132,7 @@ manifest = { "format_version": 1, "created_at": datetime.now(timezone.utc).isoformat(), "git_revision": os.environ["GIT_REVISION"], + "release_tag": os.environ["RELEASE_TAG"], "files": files, } (root / "manifest.json").write_text( @@ -134,7 +141,7 @@ manifest = { ) PY -temporary_output="${output}.tmp.$$" +temporary_output="$(mktemp "$(dirname "$output")/.syndicator-archive.XXXXXX")" tar -czf "$temporary_output" -C "$staging" . chmod 600 "$temporary_output" mv "$temporary_output" "$output" diff --git a/scripts/bootstrap-n8n.sh b/scripts/bootstrap-n8n.sh index 437776d..216f622 100755 --- a/scripts/bootstrap-n8n.sh +++ b/scripts/bootstrap-n8n.sh @@ -184,18 +184,20 @@ ensure_api_key() { } bootstrap_fingerprint() { - python3 - <<'PY' + SOURCE_ROOT="$SOURCE_ROOT" python3 - <<'PY' import glob import hashlib import os digest = hashlib.sha256() +root = os.environ["SOURCE_ROOT"] for pattern in ("n8n/credentials/*.template.json", "n8n/workflows/*.json"): - for path in sorted(glob.glob(pattern)): - digest.update(path.encode()) + for path in sorted(glob.glob(os.path.join(root, pattern))): + digest.update(os.path.relpath(path, root).encode()) with open(path, "rb") as handle: digest.update(handle.read()) for name in ( + "N8N_ENCRYPTION_KEY", "OPENAI_API_KEY", "POSTIZ_API_KEY", "SFTP_HOST", @@ -208,30 +210,37 @@ print(digest.hexdigest()) PY } -workflow_is_active() { +workflow_is_current() { local id="$1" + local source="$2" local body="$TMP_DIR/workflow-${id}.json" local code code="$(curl -sS -o "$body" -w '%{http_code}' \ -H "X-N8N-API-KEY: ${N8N_API_KEY}" \ "${N8N_BASE}/api/v1/workflows/${id}" || true)" [[ "$code" == "200" ]] || return 1 - python3 - "$body" <<'PY' + python3 - "$body" "$source" <<'PY' import json import sys with open(sys.argv[1], encoding="utf-8") as handle: body = json.load(handle) -data = body.get("data", body) -raise SystemExit(0 if data.get("active") is True else 1) +with open(sys.argv[2], encoding="utf-8") as handle: + desired = json.load(handle) +deployed = body.get("data", body) +keys = ("name", "nodes", "connections", "settings", "staticData") +matches = deployed.get("active") is True and all( + deployed.get(key) == desired.get(key) for key in keys +) +raise SystemExit(0 if matches else 1) PY } -all_workflows_active() { +all_workflows_current() { local file id for file in "${WORKFLOW_FILES[@]}"; do - id="$(workflow_id "$ROOT/$file")" - workflow_is_active "$id" || return 1 + id="$(workflow_id "$SOURCE_ROOT/$file")" + workflow_is_current "$id" "$SOURCE_ROOT/$file" || return 1 done } @@ -289,14 +298,14 @@ publish_workflow() { ensure_api_key fingerprint="$(bootstrap_fingerprint)" if [[ -s "$STATE_FILE" ]] && [[ "$(<"$STATE_FILE")" == "$fingerprint" ]] && \ - all_workflows_active; then + all_workflows_current; then echo "n8n bootstrap is already current." exit 0 fi OWNER_USER_ID="$(owner_user_id)" echo "Importing credentials for owner $OWNER_USER_ID..." -for template in n8n/credentials/*.template.json; do +for template in "$SOURCE_ROOT"/n8n/credentials/*.template.json; do base="$(basename "$template" .template.json)" rendered="$TMP_DIR/${base}.json" render_credential "$template" "$rendered" @@ -308,16 +317,16 @@ done echo "Importing and publishing workflows..." for file in "${WORKFLOW_FILES[@]}"; do - id="$(workflow_id "$ROOT/$file")" - copy_into_n8n "$ROOT/$file" /tmp/syndicator-workflow.json + id="$(workflow_id "$SOURCE_ROOT/$file")" + copy_into_n8n "$SOURCE_ROOT/$file" /tmp/syndicator-workflow.json compose exec -T -u node n8n \ n8n import:workflow --input=/tmp/syndicator-workflow.json --userId="$OWNER_USER_ID" compose exec -T -u node n8n rm -f /tmp/syndicator-workflow.json publish_workflow "$id" done -if ! all_workflows_active; then - echo "At least one imported workflow is not active." >&2 +if ! all_workflows_current; then + echo "At least one imported workflow differs from source or is inactive." >&2 exit 1 fi diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 4a57455..3d13f83 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -6,16 +6,12 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" source "$ROOT/scripts/lib.sh" pull=0 -backup_on_change=1 requested_tag="" while [[ "$#" -gt 0 ]]; do case "$1" in --pull) pull=1 ;; - --no-backup) - backup_on_change=0 - ;; --tag) if [[ -z "${2:-}" ]]; then echo "--tag requires a value." >&2 @@ -25,7 +21,7 @@ while [[ "$#" -gt 0 ]]; do shift ;; *) - echo "Usage: $0 [--pull] [--no-backup] [--tag TAG]" >&2 + echo "Usage: $0 [--pull] [--tag TAG]" >&2 exit 2 ;; esac @@ -40,6 +36,15 @@ load_release_state old_tag="${CURRENT_TAG:-}" old_previous_tag="${PREVIOUS_TAG:-}" old_rollback_backup="${ROLLBACK_BACKUP:-}" +old_revision="${CURRENT_GIT_REVISION:-${DEPLOYED_GIT_REVISION:-}}" +old_previous_revision="${PREVIOUS_GIT_REVISION:-}" +desired_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" + +if [[ "${SYNDICATOR_ALLOW_DIRTY:-0}" != "1" ]] && \ + [[ -n "$(git status --porcelain 2>/dev/null || true)" ]]; then + echo "Refusing to build a release from a dirty working tree." >&2 + exit 1 +fi if [[ -n "$requested_tag" ]]; then desired_tag="$requested_tag" @@ -52,12 +57,26 @@ if [[ ! "$desired_tag" =~ ^[a-zA-Z0-9_.-]+$ ]]; then echo "Invalid image tag: $desired_tag" >&2 exit 1 fi +if [[ -n "$old_tag" && "$old_tag" == "$desired_tag" && \ + -n "$old_revision" && "$old_revision" != "$desired_revision" ]]; then + echo "Image tag $desired_tag already belongs to Git revision $old_revision." >&2 + echo "Use a new --tag value for revision $desired_revision." >&2 + exit 1 +fi +if [[ -n "$old_previous_tag" && "$old_previous_tag" == "$desired_tag" && \ + -n "$old_previous_revision" && \ + "$old_previous_revision" != "$desired_revision" ]]; then + echo "Image tag $desired_tag is retained for rollback revision $old_previous_revision." >&2 + echo "Use a different --tag value for revision $desired_revision." >&2 + exit 1 +fi backup_path="" -existing_container="$(compose ps -a -q n8n)" -if [[ "$backup_on_change" -eq 1 && \ - ( -n "$existing_container" ) && \ - ( -z "$old_tag" || "$old_tag" != "$desired_tag" ) ]]; then +release_changed=0 +if [[ "$old_tag" != "$desired_tag" || "$old_revision" != "$desired_revision" ]]; then + release_changed=1 +fi +if [[ "$release_changed" -eq 1 ]] && persistent_state_exists; then backup_dir="$(resolve_from_root "${SYNDICATOR_BACKUP_DIR:-backups}")" from_tag="${old_tag:-legacy}" backup_path="$backup_dir/pre-update-${from_tag}-to-${desired_tag}-$(date -u +%Y%m%dT%H%M%SZ).tar.gz" @@ -70,18 +89,65 @@ if [[ "$pull" -eq 1 ]]; then else compose build fi + +pending_state="$(pending_release_file)" +mkdir -p "$(dirname "$pending_state")" +umask 077 +{ + printf 'PENDING_TAG=%q\n' "$desired_tag" + printf 'PENDING_GIT_REVISION=%q\n' "$desired_revision" + printf 'RECOVERY_BACKUP=%q\n' "$backup_path" +} >"$pending_state" +chmod 600 "$pending_state" + +runtime_mutated=0 +deployment_cleanup() { + status=$? + if [[ "$status" -ne 0 && "$runtime_mutated" -eq 1 ]]; then + if compose stop n8n pyautoflip sftp >/dev/null 2>&1; then + echo "Deployment failed; the unverified services were stopped." >&2 + else + echo "Deployment failed and automatic service shutdown also failed." >&2 + fi + for service in n8n pyautoflip sftp; do + if [[ -n "$(compose ps --status running -q "$service" 2>/dev/null || true)" ]]; then + echo "Unverified service is still running: $service" >&2 + fi + done + if [[ -n "$backup_path" ]]; then + echo "Recovery backup: $backup_path" >&2 + fi + fi + exit "$status" +} +trap deployment_cleanup EXIT + +runtime_mutated=1 compose up -d --remove-orphans +if [[ "${SYNDICATOR_TEST_FAIL_AFTER_START:-0}" == "1" ]]; then + echo "Deliberate post-start failure requested by integration test." >&2 + false +fi "$ROOT/scripts/bootstrap-n8n.sh" "$ROOT/scripts/verify.sh" -if [[ "$old_tag" != "$desired_tag" ]]; then +if [[ "$release_changed" -eq 1 ]]; then previous_tag="$old_tag" rollback_backup="$backup_path" + previous_revision="$old_revision" else previous_tag="$old_previous_tag" rollback_backup="$old_rollback_backup" + previous_revision="$old_previous_revision" fi -write_release_state "$desired_tag" "$previous_tag" "$rollback_backup" +write_release_state \ + "$desired_tag" \ + "$previous_tag" \ + "$rollback_backup" \ + "$desired_revision" \ + "$previous_revision" +rm -f "$pending_state" +trap - EXIT echo "Deployment $desired_tag is healthy." diff --git a/scripts/dotenv.py b/scripts/dotenv.py new file mode 100755 index 0000000..402ea87 --- /dev/null +++ b/scripts/dotenv.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Parse the supported Compose dotenv subset without evaluating shell code.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + + +NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def parse_value(raw: str, line_number: int) -> str: + value = raw.strip() + if not value: + return "" + + if value.startswith("'"): + end = value.find("'", 1) + if end < 0 or value[end + 1 :].strip().lstrip("#").strip(): + raise ValueError(f"line {line_number}: invalid single-quoted value") + return value[1:end] + + if value.startswith('"'): + decoder = json.JSONDecoder() + try: + parsed, end = decoder.raw_decode(value) + except json.JSONDecodeError as exc: + raise ValueError(f"line {line_number}: invalid double-quoted value") from exc + if not isinstance(parsed, str): + raise ValueError(f"line {line_number}: expected a string value") + if value[end:].strip().lstrip("#").strip(): + raise ValueError(f"line {line_number}: content after quoted value") + return parsed + + value = re.split(r"\s+#", value, maxsplit=1)[0].rstrip() + return value + + +def parse(path: Path) -> list[tuple[str, str]]: + values: dict[str, str] = {} + for line_number, raw_line in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].lstrip() + if "=" not in line: + raise ValueError(f"line {line_number}: expected NAME=VALUE") + name, raw_value = line.split("=", 1) + name = name.strip() + if not NAME.fullmatch(name): + raise ValueError(f"line {line_number}: invalid variable name {name!r}") + values[name] = parse_value(raw_value, line_number) + return list(values.items()) + + +def main() -> int: + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} FILE", file=sys.stderr) + return 2 + try: + values = parse(Path(sys.argv[1])) + except (OSError, UnicodeError, ValueError) as exc: + print(f"{sys.argv[1]}: {exc}", file=sys.stderr) + return 1 + output = sys.stdout.buffer + for name, value in values: + output.write(name.encode() + b"\0" + value.encode() + b"\0") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lib.sh b/scripts/lib.sh index a5f3a96..2f1007e 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -1,19 +1,32 @@ #!/usr/bin/env bash ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE_ROOT="${SYNDICATOR_SOURCE_ROOT:-$ROOT}" ENV_FILE="${SYNDICATOR_ENV_FILE:-$ROOT/.env}" +SYNDICATOR_LOADED_ENV_KEYS=() cd "$ROOT" || exit 1 load_env() { + local parsed key value if [[ ! -f "$ENV_FILE" ]]; then echo "Missing environment file: $ENV_FILE" >&2 return 1 fi - set -a - # shellcheck source=/dev/null - source "$ENV_FILE" - set +a + for key in "${SYNDICATOR_LOADED_ENV_KEYS[@]+"${SYNDICATOR_LOADED_ENV_KEYS[@]}"}"; do + unset "$key" + done + SYNDICATOR_LOADED_ENV_KEYS=() + parsed="$(mktemp)" + if ! python3 "$ROOT/scripts/dotenv.py" "$ENV_FILE" >"$parsed"; then + rm -f "$parsed" + return 1 + fi + while IFS= read -r -d '' key && IFS= read -r -d '' value; do + export "$key=$value" + SYNDICATOR_LOADED_ENV_KEYS+=("$key") + done <"$parsed" + rm -f "$parsed" } need_env() { @@ -37,6 +50,10 @@ release_state_file() { resolve_from_root "${SYNDICATOR_RELEASE_STATE_FILE:-secrets/release.env}" } +pending_release_file() { + printf '%s.pending\n' "$(release_state_file)" +} + load_release_state() { local state state="$(release_state_file)" @@ -50,7 +67,12 @@ write_release_state() { local current="$1" local previous="${2:-}" local rollback_backup="${3:-}" + local current_revision="${4:-}" + local previous_revision="${5:-}" local state temporary_state + if [[ -z "$current_revision" ]]; then + current_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" + fi state="$(release_state_file)" mkdir -p "$(dirname "$state")" umask 077 @@ -59,16 +81,37 @@ write_release_state() { printf 'CURRENT_TAG=%q\n' "$current" printf 'PREVIOUS_TAG=%q\n' "$previous" printf 'ROLLBACK_BACKUP=%q\n' "$rollback_backup" - printf 'DEPLOYED_GIT_REVISION=%q\n' \ - "$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" + printf 'CURRENT_GIT_REVISION=%q\n' "$current_revision" + printf 'PREVIOUS_GIT_REVISION=%q\n' "$previous_revision" printf 'DEPLOYED_AT=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" } >"$temporary_state" chmod 600 "$temporary_state" mv "$temporary_state" "$state" } +compose_project_name() { + compose config --format json | python3 -c ' +import json +import sys + +print(json.load(sys.stdin)["name"]) +' +} + +persistent_state_exists() { + local project + project="$(compose_project_name)" + [[ -n "$(docker volume ls -q \ + --filter "label=com.docker.compose.project=$project" \ + --filter "label=com.docker.compose.volume=n8n_data")" ]] +} + compose() { - local args=(--env-file "$ENV_FILE") + local args=( + --project-directory "$SOURCE_ROOT" + -f "$SOURCE_ROOT/docker-compose.yml" + --env-file "$ENV_FILE" + ) if [[ -z "${SYNDICATOR_IMAGE_TAG:-}" ]]; then load_release_state if [[ -n "${CURRENT_TAG:-}" ]]; then diff --git a/scripts/restore.sh b/scripts/restore.sh index 77430af..7a50bf9 100755 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -54,6 +54,7 @@ trap 'rm -rf "$staging"' EXIT python3 - "$archive" "$staging" <<'PY' import hashlib import json +import posixpath from pathlib import Path import sys import tarfile @@ -82,30 +83,133 @@ for relative, expected in manifest.get("files", {}).items(): actual = hashlib.sha256(path.read_bytes()).hexdigest() if actual != expected: raise SystemExit(f"Checksum mismatch: {relative}") + +for name in ("n8n_data", "sftp_data", "sftp_host_keys"): + volume_archive = destination / "volumes" / f"{name}.tar.gz" + try: + volume = tarfile.open(volume_archive, "r:gz") + except (OSError, tarfile.TarError) as exc: + raise SystemExit(f"Invalid volume archive {name}: {exc}") from exc + with volume: + for member in volume.getmembers(): + member_path = Path(member.name) + if member_path.is_absolute() or ".." in member_path.parts: + raise SystemExit(f"Unsafe {name} member: {member.name}") + if member.isdev(): + raise SystemExit(f"Unsupported {name} member: {member.name}") + if member.issym() or member.islnk(): + resolved = posixpath.normpath( + posixpath.join(posixpath.dirname(member.name), member.linkname) + ) + if member.linkname.startswith("/") or resolved == ".." or resolved.startswith("../"): + raise SystemExit(f"Unsafe {name} link: {member.name}") PY -for volume in n8n_data sftp_data sftp_host_keys; do - if [[ ! -f "$staging/volumes/${volume}.tar.gz" ]]; then - echo "Backup is missing volume archive: $volume" >&2 +for required in \ + volumes/n8n_data.tar.gz \ + volumes/sftp_data.tar.gz \ + volumes/sftp_host_keys.tar.gz \ + config/environment.env \ + config/n8n_owner.env \ + config/sftp_private_key; do + if [[ ! -f "$staging/$required" ]]; then + echo "Backup is missing required member: $required" >&2 exit 1 fi done -if [[ ! -f "$staging/config/environment.env" ]]; then - echo "Backup is missing environment configuration." >&2 + +manifest_revision="$(python3 - "$staging/manifest.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + print(json.load(handle).get("git_revision", "unknown")) +PY +)" +source_revision="${SYNDICATOR_SOURCE_REVISION:-}" +if [[ -z "$source_revision" ]]; then + source_revision="$(git -C "$SOURCE_ROOT" rev-parse HEAD 2>/dev/null || printf 'unknown')" +fi +if [[ "$manifest_revision" != "unknown" && \ + "$source_revision" != "$manifest_revision" ]]; then + echo "Backup requires Git revision $manifest_revision." >&2 + echo "Selected source is $source_revision; refusing a mixed-version restore." >&2 + exit 1 +fi +if [[ "${SYNDICATOR_ALLOW_DIRTY:-0}" != "1" ]] && \ + [[ -e "$SOURCE_ROOT/.git" ]] && \ + [[ -n "$(git -C "$SOURCE_ROOT" status --porcelain)" ]]; then + echo "Refusing to restore with a dirty source checkout." >&2 + exit 1 +fi + +target_env_file="$ENV_FILE" +ENV_FILE="$staging/config/environment.env" +load_env +environment_image_tag="${SYNDICATOR_IMAGE_TAG:-}" +archive_release_tag="" +if [[ -f "$staging/config/release.env" ]]; then + archive_release_tag="$(python3 - "$ROOT/scripts" "$staging/config/release.env" <<'PY' +from pathlib import Path +import sys + +sys.path.insert(0, sys.argv[1]) +from dotenv import parse + +print(dict(parse(Path(sys.argv[2]))).get("CURRENT_TAG", "")) +PY +)" +fi +if [[ -n "$forced_image_tag" ]]; then + desired_image_tag="$forced_image_tag" +elif [[ -n "$environment_image_tag" ]]; then + desired_image_tag="$environment_image_tag" +elif [[ -n "$archive_release_tag" ]]; then + desired_image_tag="$archive_release_tag" +else + desired_image_tag="$(git rev-parse --short=12 HEAD 2>/dev/null || printf 'local')" +fi +if [[ ! "$desired_image_tag" =~ ^[a-zA-Z0-9_.-]+$ ]]; then + echo "Backup selected an invalid image tag: $desired_image_tag" >&2 exit 1 fi -if [[ -f "$ENV_FILE" ]]; then - load_env - compose stop n8n sftp pyautoflip >/dev/null 2>&1 || true +if [[ ! -d "$staging/config/sftp_keys" ]]; then + mkdir -p "$staging/config/sftp_keys" + ssh-keygen -y -f "$staging/config/sftp_private_key" \ + >"$staging/config/sftp_keys/n8n.pub" fi +export N8N_OWNER_ENV_FILE="$staging/config/n8n_owner.env" +export SFTP_KEYS_DIR="$staging/config/sftp_keys" +export SYNDICATOR_IMAGE_TAG="$desired_image_tag" + +if [[ "$build_images" -eq 1 ]]; then + compose build +else + for image in \ + "syndicator-n8n:$desired_image_tag" \ + "syndicator-pyautoflip:$desired_image_tag"; do + if ! docker image inspect "$image" >/dev/null 2>&1; then + echo "Required restore image is missing: $image" >&2 + exit 1 + fi + done +fi + +compose stop n8n sftp pyautoflip >/dev/null +for service in n8n sftp pyautoflip; do + if [[ -n "$(compose ps --status running -q "$service")" ]]; then + echo "Service did not stop before restore: $service" >&2 + exit 1 + fi +done +ENV_FILE="$target_env_file" mkdir -p "$(dirname "$ENV_FILE")" cp "$staging/config/environment.env" "$ENV_FILE" chmod 600 "$ENV_FILE" -unset SYNDICATOR_IMAGE_TAG +unset N8N_OWNER_ENV_FILE SFTP_KEYS_DIR SYNDICATOR_IMAGE_TAG load_env -environment_image_tag="${SYNDICATOR_IMAGE_TAG:-}" restore_file() { local name="$1" @@ -140,22 +244,11 @@ rm -rf "$keys_dir" mkdir -p "$(dirname "$keys_dir")" if [[ -d "$staging/config/sftp_keys" ]]; then cp -Rp "$staging/config/sftp_keys" "$keys_dir" -else - mkdir -p "$keys_dir" - ssh-keygen -y -f "$private_key" >"$keys_dir/n8n.pub" fi -if [[ -n "$forced_image_tag" ]]; then - export SYNDICATOR_IMAGE_TAG="$forced_image_tag" -elif [[ -n "$environment_image_tag" ]]; then - export SYNDICATOR_IMAGE_TAG="$environment_image_tag" -else - unset SYNDICATOR_IMAGE_TAG CURRENT_TAG PREVIOUS_TAG ROLLBACK_BACKUP - load_release_state - if [[ -n "${CURRENT_TAG:-}" ]]; then - export SYNDICATOR_IMAGE_TAG="$CURRENT_TAG" - fi -fi +export N8N_OWNER_ENV_FILE="$owner_env" +export SFTP_KEYS_DIR="$keys_dir" +export SYNDICATOR_IMAGE_TAG="$desired_image_tag" for volume in n8n_data sftp_data sftp_host_keys; do echo "Restoring volume $volume..." @@ -170,9 +263,6 @@ for volume in n8n_data sftp_data sftp_host_keys; do ' >/dev/null done -if [[ "$build_images" -eq 1 ]]; then - compose build -fi compose up -d --remove-orphans "$ROOT/scripts/bootstrap-n8n.sh" "$ROOT/scripts/verify.sh" diff --git a/scripts/rollback.sh b/scripts/rollback.sh index 5798906..036b5e1 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -15,11 +15,24 @@ load_release_state current_tag="${CURRENT_TAG:-}" previous_tag="${PREVIOUS_TAG:-}" rollback_backup="${ROLLBACK_BACKUP:-}" +current_revision="${CURRENT_GIT_REVISION:-${DEPLOYED_GIT_REVISION:-}}" +previous_revision="${PREVIOUS_GIT_REVISION:-}" -if [[ -z "$current_tag" || -z "$previous_tag" || -z "$rollback_backup" ]]; then +if [[ -z "$current_tag" || -z "$previous_tag" || -z "$rollback_backup" || \ + -z "$previous_revision" ]]; then echo "No complete previous release and backup are recorded." >&2 exit 1 fi +checkout_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" +if [[ -n "$current_revision" && "$checkout_revision" != "$current_revision" ]]; then + echo "Rollback must run from the current release source: $current_revision" >&2 + exit 1 +fi +if [[ "${SYNDICATOR_ALLOW_DIRTY:-0}" != "1" ]] && \ + [[ -n "$(git status --porcelain)" ]]; then + echo "Refusing to roll back from a dirty working tree." >&2 + exit 1 +fi if [[ ! -f "$rollback_backup" ]]; then echo "Recorded rollback backup is missing: $rollback_backup" >&2 exit 1 @@ -36,8 +49,30 @@ backup_dir="$(resolve_from_root "${SYNDICATOR_BACKUP_DIR:-backups}")" forward_backup="$backup_dir/pre-rollback-${current_tag}-to-${previous_tag}-$(date -u +%Y%m%dT%H%M%SZ).tar.gz" "$ROOT/scripts/backup.sh" --output "$forward_backup" +source_bundle="$(mktemp -d)" +cleanup() { + status=$? + rm -rf "$source_bundle" + exit "$status" +} +trap cleanup EXIT +git archive "$previous_revision" | tar -x -C "$source_bundle" +if [[ ! -f "$source_bundle/docker-compose.yml" ]]; then + echo "Previous revision has no deployable Compose definition." >&2 + exit 1 +fi + export SYNDICATOR_IMAGE_TAG="$previous_tag" +export SYNDICATOR_SOURCE_ROOT="$source_bundle" +export SYNDICATOR_SOURCE_REVISION="$previous_revision" "$ROOT/scripts/restore.sh" --yes --no-build "$rollback_backup" -write_release_state "$previous_tag" "$current_tag" "$forward_backup" +write_release_state \ + "$previous_tag" \ + "$current_tag" \ + "$forward_backup" \ + "$previous_revision" \ + "$current_revision" +trap - EXIT +rm -rf "$source_bundle" echo "Rolled back from $current_tag to $previous_tag." diff --git a/scripts/verify.sh b/scripts/verify.sh index 5d516b0..f8f5650 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -21,7 +21,7 @@ tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT n8n_base="http://127.0.0.1:${N8N_HOST_PORT:-5678}" -for file in n8n/workflows/*.json; do +for file in "$SOURCE_ROOT"/n8n/workflows/*.json; do id="$(workflow_id "$file")" body="$tmp/workflow-${id}.json" code="$(curl -sS -o "$body" -w '%{http_code}' \ diff --git a/tests/integration/stack.sh b/tests/integration/stack.sh index db593fe..a189179 100755 --- a/tests/integration/stack.sh +++ b/tests/integration/stack.sh @@ -45,11 +45,13 @@ SFTP_KEYS_DIR=$tmp/keys PYAUTOFLIP_WARM_MODELS=0 SYNDICATOR_BACKUP_DIR=$tmp/backups SYNDICATOR_RELEASE_STATE_FILE=$tmp/release.env +SYNDICATOR_ALLOW_DIRTY=1 EOF chmod 600 "$env_file" export SYNDICATOR_ENV_FILE="$env_file" export SYNDICATOR_PROJECT="$project" +export SYNDICATOR_ALLOW_DIRTY=1 cleanup() { status=$? @@ -64,7 +66,40 @@ cleanup() { } trap cleanup EXIT +test_failed_deployment() { + printf '%s\n' 'SYNDICATOR_TEST_FAIL_AFTER_START=1' >>"$env_file" + set +e + "$ROOT/bin/syndicator" update --tag integration-failure \ + >"$tmp/failed-deploy.log" 2>&1 + failed_status=$? + set -e + if [[ "$failed_status" -eq 0 ]]; then + echo "Deliberately invalid deployment unexpectedly succeeded." >&2 + exit 1 + fi + if [[ ! -s "$tmp/release.env.pending" ]]; then + echo "Failed deployment did not record pending recovery state." >&2 + python3 - "$tmp/failed-deploy.log" <<'PY' >&2 +from pathlib import Path +import sys + +print(Path(sys.argv[1]).read_text(encoding="utf-8")) +PY + exit 1 + fi + if [[ -n "$(docker compose --env-file "$env_file" -p "$project" \ + ps --status running -q n8n)" ]]; then + echo "Failed deployment left unverified n8n running." >&2 + exit 1 + fi +} + "$ROOT/bin/syndicator" deploy +if [[ "${SYNDICATOR_INTEGRATION_FAILURE_ONLY:-0}" == "1" ]]; then + test_failed_deployment + echo "Failed deployment containment test passed." + exit 0 +fi cp "$tmp/n8n_api_key" "$tmp/n8n_api_key.before" if ! "$ROOT/bin/syndicator" deploy | tee "$tmp/second-deploy.log"; then @@ -163,4 +198,6 @@ if [[ "${CURRENT_TAG:-}" != "$initial_tag" || \ exit 1 fi +test_failed_deployment + echo "Isolated stack integration test passed." From 598d6be3579cdfcbd3bdfe40801b223fae8e6cb8 Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Wed, 12 Aug 2026 13:55:39 +0200 Subject: [PATCH 07/16] Finish review hardening for release sources and CI Persist release source trees for rollback, pin CPU-only pyautoflip deps and model checksums, and cover reframe/restore failure paths in CI. Co-authored-by: Cursor --- .env.example | 16 +- .github/dependabot.yml | 2 +- .github/workflows/ci.yml | 22 ++- README.md | 2 +- bin/syndicator | 10 ++ docker-compose.yml | 21 +-- docs/adr/0001-deployment-model.md | 4 + docs/operations.md | 64 ++++++-- n8n/Dockerfile | 7 +- pyautoflip/Dockerfile | 6 +- pyautoflip/README.md | 3 + pyautoflip/requirements.in | 2 + pyautoflip/requirements.txt | 234 ++++++++---------------------- pyautoflip/warm_models.py | 47 +++++- scripts/bootstrap-n8n.sh | 15 +- scripts/deploy.sh | 22 ++- scripts/lib.sh | 35 +++++ scripts/restore.sh | 63 +++++++- scripts/rollback.sh | 34 +++-- scripts/workflow_order.py | 65 +++++++++ tests/integration/reframe.sh | 70 +++++++++ tests/integration/stack.sh | 18 +++ tests/test_repository.py | 66 ++++++++- 23 files changed, 572 insertions(+), 256 deletions(-) create mode 100755 scripts/workflow_order.py create mode 100755 tests/integration/reframe.sh diff --git a/.env.example b/.env.example index 1513aec..98dfd21 100644 --- a/.env.example +++ b/.env.example @@ -11,16 +11,16 @@ N8N_BIND_ADDRESS=127.0.0.1 N8N_HOST_PORT=5678 N8N_WEBHOOK_URL=http://localhost:5678/ N8N_SECURE_COOKIE=false +N8N_PROXY_HOPS=0 # Encryption key for credentials at rest. # Reusing the existing n8n_data volume: copy encryptionKey from # docker compose exec -u node n8n cat /home/node/.n8n/config -# Fresh volume: generate with openssl rand -hex 16 and re-import credentials. +# Fresh volume: `bin/syndicator init` generates a 256-bit key. N8N_ENCRYPTION_KEY= # Instance owner (provisioned via N8N_INSTANCE_OWNER_* on n8n start). -# Run ./scripts/ensure-n8n-owner.sh before `docker compose up` to bcrypt-hash -# the password into secrets/n8n_owner.env for Compose. +# `bin/syndicator init` hashes the password into the Compose owner env file. N8N_OWNER_EMAIL= N8N_OWNER_PASSWORD= # N8N_OWNER_FIRST_NAME=Syndicator @@ -52,18 +52,12 @@ SFTP_PRIVATE_KEY_FILE=./secrets/sftp_n8n_ed25519 # Generated bcrypt environment file consumed by Compose. # N8N_OWNER_ENV_FILE=secrets/n8n_owner.env -# --- Image overrides --- -# Reviewed defaults are pinned in docker-compose.yml and the Dockerfiles. -# Override only while testing an explicit dependency update. +# --- Build/runtime overrides (normally leave unset) --- # SYNDICATOR_IMAGE_TAG=local -# N8N_BASE_IMAGE=docker.io/n8nio/n8n:2.33.7 -# FFMPEG_IMAGE=mwader/static-ffmpeg:7.1.1 -# PYAUTOFLIP_BASE_IMAGE=python:3.12-slim-bookworm # PYAUTOFLIP_WARM_MODELS=1 -# SFTP_BASE_IMAGE=atmoz/sftp:alpine # SFTP_PLATFORM=linux/amd64 -# FILES_INIT_IMAGE=alpine:3.20 # --- Operations --- # SYNDICATOR_BACKUP_DIR=./backups # SYNDICATOR_RELEASE_STATE_FILE=secrets/release.env +# SYNDICATOR_RELEASE_SOURCES_DIR=secrets/release-sources diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f3df24c..3796659 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ updates: schedule: interval: weekly - - package-ecosystem: docker + - package-ecosystem: docker-compose directory: / schedule: interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96fe3cf..d89d105 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,10 +46,28 @@ jobs: - uses: actions/checkout@v4 - name: Build application images - env: - PYAUTOFLIP_WARM_MODELS: "0" run: | bash tests/validate-compose.sh build n8n pyautoflip + - name: Exercise production reframe path + run: bash tests/integration/reframe.sh + - name: Exercise isolated stack twice run: bash tests/integration/stack.sh + + build-arm64: + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Build application images for Linux arm64 + run: | + docker buildx build --platform linux/arm64 ./n8n + docker buildx build \ + --platform linux/arm64 \ + --build-arg PYAUTOFLIP_WARM_MODELS=0 \ + ./pyautoflip diff --git a/README.md b/README.md index 7968601..9546843 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ bin/syndicator deploy Owner account is provisioned from env on n8n start (`N8N_INSTANCE_OWNER_*`). Bootstrap logs in with `N8N_OWNER_EMAIL` / `N8N_OWNER_PASSWORD` to create or reuse an API key at `secrets/n8n_api_key` (or uses `N8N_API_KEY` if set), then imports credentials/workflows and publishes webhooks. UI login uses the same owner credentials. -`init` writes `secrets/sftp_n8n_ed25519` (private), `sftp/keys/n8n.pub` (public), and `secrets/n8n_owner.env` (bcrypt hash for Compose). Extra client keys: copy any `.pub` into `sftp/keys/` and restart SFTP. Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`. +`init` writes `secrets/sftp_n8n_ed25519` (private), `sftp/keys/n8n.pub` (public), and `secrets/n8n_owner.env` (bcrypt hash for Compose). Extra client keys: copy any `.pub` into `sftp/keys/` and run `bin/syndicator restart sftp`. Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`. Published ports bind to loopback by default. Read the [operations runbook](docs/operations.md) before enabling LAN or internet access. diff --git a/bin/syndicator b/bin/syndicator index 96ffe6b..39447b1 100755 --- a/bin/syndicator +++ b/bin/syndicator @@ -20,6 +20,7 @@ Lifecycle: restore Restore a validated backup archive update Back up and deploy reviewed dependency changes rollback Restore the recorded previous release + restart Restart one or more services, then verify status Show Compose service status logs Follow Compose service logs EOF @@ -59,6 +60,15 @@ case "$command" in rollback) exec "$ROOT/scripts/rollback.sh" "$@" ;; + restart) + if [[ "$#" -eq 0 ]]; then + echo "Usage: bin/syndicator restart SERVICE..." >&2 + exit 2 + fi + load_env + compose restart "$@" + "$ROOT/scripts/verify.sh" + ;; status) load_env compose ps "$@" diff --git a/docker-compose.yml b/docker-compose.yml index 1f90e7b..4c93e49 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ name: syndicator services: # Docker named volumes are root-owned on first create; n8n + pyautoflip run as uid 1000. files-init: - image: ${FILES_INIT_IMAGE:-alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc} + image: alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc volumes: - n8n_files:/data command: @@ -21,7 +21,7 @@ services: # Explicitly invoked by backup/restore; never part of the runtime stack. volume-tool: - image: ${FILES_INIT_IMAGE:-alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc} + image: alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc profiles: [tools] volumes: - n8n_data:/volumes/n8n_data @@ -31,7 +31,7 @@ services: command: ["-c", "true"] sftp: - image: ${SFTP_BASE_IMAGE:-atmoz/sftp:alpine@sha256:81fa92512bf8ead4849f33c1c153907b86d32d77704d1c62a9c70b4316ae9e50} + image: atmoz/sftp:alpine@sha256:81fa92512bf8ead4849f33c1c153907b86d32d77704d1c62a9c70b4316ae9e50 # The upstream image is amd64-only; Docker Desktop emulates it on Apple Silicon. platform: ${SFTP_PLATFORM:-linux/amd64} restart: unless-stopped @@ -58,11 +58,7 @@ services: retries: 3 n8n: - build: - context: ./n8n - args: - N8N_BASE_IMAGE: ${N8N_BASE_IMAGE:-docker.io/n8nio/n8n:2.33.7@sha256:3989d9b8ebb77b4ee8f604519eb73e44f4384bfaa689526e0104eed79a237d30} - FFMPEG_IMAGE: ${FFMPEG_IMAGE:-mwader/static-ffmpeg:7.1.1@sha256:11a44711684c0b9f754c047dcd64235b8b52deab251bd0e0a86f22faa160749c} + build: ./n8n image: syndicator-n8n:${SYNDICATOR_IMAGE_TAG:-local} restart: unless-stopped ports: @@ -78,12 +74,15 @@ services: N8N_PORT: ${N8N_PORT:-5678} N8N_PROTOCOL: ${N8N_PROTOCOL:-http} N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-${WEBHOOK_URL:-http://localhost:5678/}} + N8N_PROXY_HOPS: ${N8N_PROXY_HOPS:-0} N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true" N8N_RESTRICT_FILE_ACCESS_TO: /files N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE:-false} N8N_COMMUNITY_PACKAGES_ENABLED: "true" N8N_UNVERIFIED_PACKAGES_ENABLED: "true" N8N_RUNNERS_TASK_TIMEOUT: "300" + N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES: "268435456" + N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES: "1000" N8N_INSTANCE_OWNER_MANAGED_BY_ENV: "true" N8N_INSTANCE_OWNER_EMAIL: ${N8N_OWNER_EMAIL:?set N8N_OWNER_EMAIL in .env} N8N_INSTANCE_OWNER_FIRST_NAME: ${N8N_OWNER_FIRST_NAME:-Syndicator} @@ -113,19 +112,16 @@ services: build: context: ./pyautoflip args: - PYAUTOFLIP_BASE_IMAGE: ${PYAUTOFLIP_BASE_IMAGE:-python:3.12-slim-bookworm@sha256:4766d8b510c428e595d74b9cc5bbb2fae8e26316fffb4adc89908d79aacd58a2} PYAUTOFLIP_WARM_MODELS: ${PYAUTOFLIP_WARM_MODELS:-1} image: syndicator-pyautoflip:${SYNDICATOR_IMAGE_TAG:-local} # Same uid as n8n (node) so /files writes are readable by both. - # HOME must be writable: compose user override otherwise sets HOME=/ and - # InsightFace fails creating /.insightface (EACCES). + # HOME contains the image-pinned InsightFace models and remains writable. user: "1000:1000" environment: HOME: /home/pyautoflip restart: unless-stopped volumes: - n8n_files:/files - - pyautoflip_home:/home/pyautoflip depends_on: files-init: condition: service_completed_successfully @@ -149,4 +145,3 @@ volumes: sftp_host_keys: n8n_data: n8n_files: - pyautoflip_home: diff --git a/docs/adr/0001-deployment-model.md b/docs/adr/0001-deployment-model.md index 3407b50..bf5f6ff 100644 --- a/docs/adr/0001-deployment-model.md +++ b/docs/adr/0001-deployment-model.md @@ -108,6 +108,10 @@ can be reconsidered now that integration tests protect the behavior. reverse-proxy decision. - Backups contain secrets. File mode `0600` is only a local safeguard; off-host copies must be encrypted. +- pyautoflip's Python graph and model archive are hash-pinned, but Debian media + packages still come from the live Bookworm repositories. Retained, + commit-tagged images are the rollback artifact; use a Debian snapshot if + bit-for-bit disaster rebuilds become a requirement. ## Revisit when diff --git a/docs/operations.md b/docs/operations.md index 88f8fe3..678618f 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -35,6 +35,10 @@ with a list of values that still need input. Fill in: - Postiz API key - the public URL and bind addresses appropriate for the host +The lifecycle parses `.env` as data, never as shell code. Quote values that +contain spaces or `#`; single quotes preserve `$`, backticks, and other +characters literally. + Then deploy: ```bash @@ -48,7 +52,9 @@ unchanged and all workflows remain published, n8n import is skipped. The first controlled deployment writes `secrets/release.env`. Unless `SYNDICATOR_IMAGE_TAG` is explicitly set, images use the current 12-character -Git revision as their tag. +Git revision as their tag. The exact Compose/workflow source is retained under +`secrets/release-sources/`, so routine commands continue to target the running +release even after the checkout moves forward or a rollback completes. ## Local and network configuration @@ -118,6 +124,13 @@ scripts/ensure-n8n-owner.sh --force bin/syndicator deploy ``` +After adding or removing a public key under `sftp/keys/`, apply it through the +supported hook: + +```bash +bin/syndicator restart sftp +``` + ## Dependency updates Container, npm, pip, and GitHub Actions dependencies are proposed weekly by @@ -146,6 +159,11 @@ An explicit tag is available for release testing: bin/syndicator update --tag release-candidate-1 ``` +If bootstrap or verification fails, the new services are stopped and recovery +details remain in `secrets/release.env.pending`. The last healthy release state +is not overwritten. Use the recorded backup with the matching Git revision; +do not simply restart the failed containers. + ## Backups Create a backup: @@ -163,8 +181,9 @@ consistent. It archives: - `.env`, n8n owner/API/bootstrap state, SFTP client keys, and release state - a manifest containing SHA-256 checksums and the Git revision -The shared processing directory `n8n_files` is scratch space. The -`pyautoflip_home` model cache is reconstructible. Neither is backed up. +The shared processing directory `n8n_files` is scratch space and is not backed +up. InsightFace models are checksum-pinned inside the pyautoflip image, not +stored in a mutable volume. Archives default to `backups/` and mode `0600`. They still contain plaintext credentials. Copy them to encrypted off-host storage and apply an external @@ -187,9 +206,15 @@ bin/syndicator restore --yes /secure/path/syndicator.tar.gz Before changing state, restore rejects unsafe archive paths, unsupported members, missing critical volume archives, unsupported formats, and checksum -mismatches. It replaces current configuration and critical volumes, rebuilds -the checked-out revision by default, starts the stack, reconciles n8n, and -verifies all services. +mismatches. It also requires the checkout to match the archive's Git revision. +Only after validating inner volume archives and building or locating the +required images does it stop services and cross the destructive boundary. It +then replaces current configuration and critical volumes, starts the stack, +reconciles n8n, and verifies all services. + +If a post-mutation restore step fails, all restored-but-unverified services are +stopped and recovery context is written beside `release.env` with the suffix +`.restore-pending`. For disaster recovery on a new host: @@ -213,13 +238,16 @@ bin/syndicator rollback It requires: - `PREVIOUS_TAG` and `ROLLBACK_BACKUP` in `secrets/release.env` +- a clean checkout at the recorded current Git revision - both previous application images still present locally - the matching pre-update backup -Before restoring the previous release, rollback backs up the current release. -This creates a reversible roll-forward point. It then restores matching data, -starts the previous image tags, verifies the stack, and swaps the current and -previous release records. +Rollback refuses dirty or mismatched current source. Before restoring the +previous release, it uses the current lifecycle to back up the current release. +It then selects the retained source bundle for the exact previous Git revision, +uses the current hardened restore implementation with that revision's +Compose/workflow definitions, restores matching data, starts the previous image +tags, verifies the stack, and swaps the current and previous release records. Do not use `docker image prune -a` while rollback retention is required. @@ -237,14 +265,20 @@ npm audit --prefix n8n Full isolated test: ```bash -PYAUTOFLIP_WARM_MODELS=0 bash tests/validate-compose.sh build n8n pyautoflip +bash tests/validate-compose.sh build n8n pyautoflip +bash tests/integration/reframe.sh bash tests/integration/stack.sh ``` -The integration test uses random loopback ports and a unique Compose project. -It deploys twice, checks that API keys and resources are not duplicated, -uploads over SFTP, validates backup/restore, deploys a second release tag, -rolls back, and removes all test containers and volumes. +CI first builds the production model-warmed image and performs a real reframe. +The stack integration test uses random loopback ports and a unique Compose +project. It deploys twice, checks that API keys and resources are not +duplicated, uploads over SFTP, validates backup/restore, deploys a second +release tag, rolls back, and removes all test containers and volumes. A +deliberately failed release also verifies that untrusted containers are +stopped and pending recovery state is recorded; the same containment is tested +for a failed restore. A separate Buildx job verifies n8n and pyautoflip for +Linux arm64. ## Troubleshooting diff --git a/n8n/Dockerfile b/n8n/Dockerfile index 5f66dd5..9f71011 100644 --- a/n8n/Dockerfile +++ b/n8n/Dockerfile @@ -1,11 +1,8 @@ # Custom n8n image: static ffmpeg/ffprobe + locked community nodes. -ARG N8N_BASE_IMAGE=docker.io/n8nio/n8n:2.33.7@sha256:3989d9b8ebb77b4ee8f604519eb73e44f4384bfaa689526e0104eed79a237d30 -ARG FFMPEG_IMAGE=mwader/static-ffmpeg:7.1.1@sha256:11a44711684c0b9f754c047dcd64235b8b52deab251bd0e0a86f22faa160749c +FROM mwader/static-ffmpeg:7.1.1@sha256:11a44711684c0b9f754c047dcd64235b8b52deab251bd0e0a86f22faa160749c AS ffmpeg -FROM ${FFMPEG_IMAGE} AS ffmpeg - -FROM ${N8N_BASE_IMAGE} +FROM docker.io/n8nio/n8n:2.33.7@sha256:3989d9b8ebb77b4ee8f604519eb73e44f4384bfaa689526e0104eed79a237d30 USER root diff --git a/pyautoflip/Dockerfile b/pyautoflip/Dockerfile index c035a25..0d8f190 100644 --- a/pyautoflip/Dockerfile +++ b/pyautoflip/Dockerfile @@ -1,5 +1,4 @@ -ARG PYAUTOFLIP_BASE_IMAGE=python:3.12-slim-bookworm@sha256:4766d8b510c428e595d74b9cc5bbb2fae8e26316fffb4adc89908d79aacd58a2 -FROM ${PYAUTOFLIP_BASE_IMAGE} +FROM python:3.12-slim-bookworm@sha256:4766d8b510c428e595d74b9cc5bbb2fae8e26316fffb4adc89908d79aacd58a2 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -23,8 +22,7 @@ ENV PYAUTOFLIP_FILES_ROOT=/files USER pyautoflip -# Warm InsightFace packs into $HOME/.insightface so runtime needs no downloads. -# pyautoflip's face detector defaults to buffalo_s (not buffalo_l). +# Install and verify pyautoflip's buffalo_s model in the immutable image. ARG PYAUTOFLIP_WARM_MODELS=1 COPY --chown=pyautoflip:pyautoflip warm_models.py . RUN if [ "$PYAUTOFLIP_WARM_MODELS" = "1" ]; then \ diff --git a/pyautoflip/README.md b/pyautoflip/README.md index dc597cc..5728889 100644 --- a/pyautoflip/README.md +++ b/pyautoflip/README.md @@ -50,6 +50,9 @@ Encode knobs (optional env on the `pyautoflip` service): ## Local image build +The build downloads the InsightFace `buffalo_s` archive, verifies its pinned +SHA-256 checksum, and stores the extracted model in the image. + ```bash cd pyautoflip docker build -t syndicator-pyautoflip:local . diff --git a/pyautoflip/requirements.in b/pyautoflip/requirements.in index 77f2496..13cd84a 100644 --- a/pyautoflip/requirements.in +++ b/pyautoflip/requirements.in @@ -2,3 +2,5 @@ pyautoflip==0.2.1 fastapi==0.115.12 uvicorn[standard]==0.34.2 pydantic==2.11.3 +torch==2.13.0+cpu +torchvision==0.28.0+cpu diff --git a/pyautoflip/requirements.txt b/pyautoflip/requirements.txt index 6e9d5fa..0c2baf5 100644 --- a/pyautoflip/requirements.txt +++ b/pyautoflip/requirements.txt @@ -1,5 +1,8 @@ # This file was autogenerated by uv via the following command: -# uv pip compile pyautoflip/requirements.in --universal --python-version 3.12 --generate-hashes --output-file pyautoflip/requirements.txt +# uv pip compile pyautoflip/requirements.in --universal --python-version 3.12 --index https://download.pytorch.org/whl/cpu --index-strategy unsafe-best-match --emit-index-url --generate-hashes --output-file pyautoflip/requirements.txt +--index-url https://pypi.org/simple +--extra-index-url https://download.pytorch.org/whl/cpu + absl-py==2.5.0 \ --hash=sha256:0c996f25c0490700fadabe6351630f6111534fa0ae252cc6d2014ea3b141135f \ --hash=sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba @@ -306,32 +309,6 @@ contourpy==1.3.3 \ --hash=sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9 \ --hash=sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a # via matplotlib -cuda-bindings==13.3.1 ; python_full_version < '3.15' and sys_platform == 'linux' \ - --hash=sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708 \ - --hash=sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86 \ - --hash=sha256:18c8c167c8907b8f02531ca810534315c458dabef31f7965095619bf647b9202 \ - --hash=sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8 \ - --hash=sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7 \ - --hash=sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9 \ - --hash=sha256:507b0e19e7f934c5e30f30f0244ad70a75812619a7d3a0d742543caae1bd50f1 \ - --hash=sha256:61120b5e4f4a63f67efd7e7396914cb9ef871bb1f0021e990fb70277be240a4d \ - --hash=sha256:8de12ef60bf40756852cb62bbb40460609269f6ece522903d1cc93d73a3ececb \ - --hash=sha256:9851b0caa8bfd3bc6fa054eaf57bea7c8e9c3a62db2d2621224677f49f3c53d0 \ - --hash=sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf \ - --hash=sha256:b134dd8c5c66ae4c4ad814f7aee88fd215353c077010cbc47e3b55ed35ec9eff \ - --hash=sha256:c0c4b1a995098c46695c24257a342dc97d6e6d3f3050b944c9f43bd26d734051 \ - --hash=sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76 \ - --hash=sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474 \ - --hash=sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49 \ - --hash=sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a \ - --hash=sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80 - # via torch -cuda-pathfinder==1.6.0 ; python_full_version < '3.15' and sys_platform == 'linux' \ - --hash=sha256:1503af579d8379c24bdd65528379bc57039b0455be9f5f9686cf8e473a1fce51 - # via cuda-bindings -cuda-toolkit==13.0.3.0 ; sys_platform == 'linux' \ - --hash=sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f - # via torch cycler==0.12.1 \ --hash=sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30 \ --hash=sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c @@ -955,89 +932,6 @@ numpy==2.5.2 \ # tensorboardx # tifffile # torchvision -nvidia-cublas==13.1.1.3 ; sys_platform == 'linux' \ - --hash=sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436 \ - --hash=sha256:b6cdce694e47ff6aadf0a69df1cab6628d696f5ff56e8d16af50309d855fa20f \ - --hash=sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5 - # via - # cuda-toolkit - # nvidia-cudnn-cu13 - # nvidia-cusolver -nvidia-cuda-cupti==13.0.85 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ - --hash=sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8 \ - --hash=sha256:683f58d301548deeefcb8f6fac1b8d907691b9d8b18eccab417f51e362102f00 \ - --hash=sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151 - # via cuda-toolkit -nvidia-cuda-nvrtc==13.0.88 ; sys_platform == 'linux' \ - --hash=sha256:6bcd4e7f8e205cbe644f5a98f2f799bef9556fefc89dd786e79a16312ce49872 \ - --hash=sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575 \ - --hash=sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b - # via - # cuda-toolkit - # nvidia-cublas -nvidia-cuda-runtime==13.0.96 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ - --hash=sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548 \ - --hash=sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55 \ - --hash=sha256:f79298c8a098cec150a597c8eba58ecdab96e3bdc4b9bc4f9983635031740492 - # via cuda-toolkit -nvidia-cudnn-cu13==9.20.0.48 ; sys_platform == 'linux' \ - --hash=sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304 \ - --hash=sha256:af8139732b99c0118be65ea5aac97f0d46018f8c552889e49d2fb0c6261a4a24 \ - --hash=sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1 - # via torch -nvidia-cufft==12.0.0.61 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ - --hash=sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5 \ - --hash=sha256:2abce5b39d2f5ae12730fb7e5db6696533e36c26e2d3e8fd1750bdd2853364eb \ - --hash=sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3 - # via cuda-toolkit -nvidia-cufile==1.15.1.6 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ - --hash=sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44 \ - --hash=sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1 - # via cuda-toolkit -nvidia-curand==10.4.0.35 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ - --hash=sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a \ - --hash=sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc \ - --hash=sha256:65b1710aa6961d326b411e314b374290904c5ddf41dc3f766ebc3f1d7d4ca69f - # via cuda-toolkit -nvidia-cusolver==12.0.4.66 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ - --hash=sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2 \ - --hash=sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112 \ - --hash=sha256:16515bd33a8e76bb54d024cfa068fa68d30e80fc34b9e1090813ea9362e0cb65 - # via cuda-toolkit -nvidia-cusparse==12.6.3.3 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ - --hash=sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b \ - --hash=sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c \ - --hash=sha256:cbcf42feb737bd7ec15b4c0a63e62351886bd3f975027b8815d7f720a2b5ea79 - # via - # cuda-toolkit - # nvidia-cusolver -nvidia-cusparselt-cu13==0.8.1 ; sys_platform == 'linux' \ - --hash=sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f \ - --hash=sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0 \ - --hash=sha256:dccbd362f91a7b9024d1f55ee9f548ac065027ff15d8c8b0db889ab3a8f31215 - # via torch -nvidia-nccl-cu13==2.29.7 ; sys_platform == 'linux' \ - --hash=sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5 \ - --hash=sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d - # via torch -nvidia-nvjitlink==13.3.33 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ - --hash=sha256:26a6de7fb4c8fdaa7703d3dad720d6d427ddfea5c48a528fd97c11733ad830e5 \ - --hash=sha256:4297ee49639b4f2e07255a1d69b3acc7ab2d011bb892b403e91ac98368962e3b \ - --hash=sha256:ce48b37dfeb3cb1eae4cf85adacb47d7a6539ea2272870c9a3628ce275c2037e - # via - # cuda-toolkit - # nvidia-cufft - # nvidia-cusolver - # nvidia-cusparse -nvidia-nvshmem-cu13==3.4.5 ; sys_platform == 'linux' \ - --hash=sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80 \ - --hash=sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9 - # via torch -nvidia-nvtx==13.0.85 ; (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') \ - --hash=sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4 \ - --hash=sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6 \ - --hash=sha256:d66ea44254dd3c6eacc300047af6e1288d2269dd072b417e0adffbf479e18519 - # via cuda-toolkit onnx==1.22.0 \ --hash=sha256:19e45e4af88e3fe3261458d4b8cc461957ae2782a358a3560503569bf3b23b72 \ --hash=sha256:1d0a2bdb15eb2b3cb65c438f3423d9620d14fdce32f92380e6bb1b2e09568ef5 \ @@ -1629,60 +1523,64 @@ tifffile==2026.7.31 \ --hash=sha256:79b1f4b1aba3ef3e6b6f1691a32abb62f5d7383faa52a12771c695232ba40bee \ --hash=sha256:81adfa08012be1c478f99b83cda2f529eef8620cfbdf94fc41eef6f1d7b47dc5 # via scikit-image -torch==2.13.0 \ - --hash=sha256:024c6cc0c1b085f2f91f20a3dc27b0471d021c31ce84b81be3afdc39f791fd9d \ - --hash=sha256:092790c696a760c729fd5722835f50b9d81fd7c8f141571f3f3cf4081a8f664c \ - --hash=sha256:0ab4b69f3ee03a62a002cfbf77b1ca5e88aceb4ea64cb4388bb28f638ddbb045 \ - --hash=sha256:1e09d6a722504957c694faceca843acde562786df1144ebcc5a74075ec7f6005 \ - --hash=sha256:2bd30b6b730d987fa386ce3898933762c5cb8cc82eb0535211d787cc3ce2dfeb \ - --hash=sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027 \ - --hash=sha256:31061ff56ed8fbf26c749806905aeb749ebeb819810fd5d52508aa5afd90dddc \ - --hash=sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09 \ - --hash=sha256:49b58f1e2c52440abb6f17c28f0335fe6c6d01ad1a7f55b0183b81e4b34d64e6 \ - --hash=sha256:49f1ea385c754e54919408a9bb3b5a72b0b755bbe2c916c1d6f70afbec4908a2 \ - --hash=sha256:4f8573e3ce9ebcd53fe922f01077a6085ccdfbe5f12fd215883a9d87d7a744fd \ - --hash=sha256:572df8be8ffb4599c88cbd6a0726f1f854f4da65d2e3c09f0e2c2283333cd6d4 \ - --hash=sha256:60fcdcb2f3876e21146cb4524ef06397d727ca9ad5f020818547e25075fe3cb7 \ - --hash=sha256:796633c4cdf0fe2cdced72d8f88f22e73dbcfce83132763162f6d4bff13b820b \ - --hash=sha256:94f0de129916f77b8dc2c7a8eff644cfeddfe59e39c9f55e9f6e17543410281d \ - --hash=sha256:a0d8b11f16a48d60e2015d8213aa0390744cbebb98e58b62b3514dddc656e330 \ - --hash=sha256:a3893dc2da0a972a8ca5d698c85a9f967559ac5f8ee1797b77408aa8734d073c \ - --hash=sha256:a3a9a21312872af8a26950b2c15680335a386a1f56ed03e780653d78b9607e9e \ - --hash=sha256:a7de8a313090dc5c7d7ba4bfe5c3be222528f9a4dba1acc83bddb1157360c4b8 \ - --hash=sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1 \ - --hash=sha256:c78b7b4d04461855a764cf01bae9a462bb88bc93defcfa11235cbc8fdf3e12c4 \ - --hash=sha256:cc26eead4cf51d0b544e31e364dcf000846549c273bd148936fe9d24d29acb92 \ - --hash=sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c \ - --hash=sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8 +torch==2.13.0+cpu \ + --hash=sha256:0555fde6108ca90247ae33d4e1237cbae475c86a223bb2f0f91d9addf1f611bd \ + --hash=sha256:0b8f7d0423027ae8b90c7977c627f3379f325363a08224dffad9b4b2d684a83d \ + --hash=sha256:10717d8b3b67c45a4788bf7ffc0bab1ea1e5ebbedd24466be6100102d141fac1 \ + --hash=sha256:1a3a35229fdc13446b4eab50e7fcf9399ff941e89a3b761497786297a5d8dde5 \ + --hash=sha256:222a6681467cc7f6f05cd3068dfbc603def3a1e46d1d4620c1c8cdf6178bd563 \ + --hash=sha256:2b3d093abd919ad934c43d47e73ba63ceba7cbd7269fc2e9c1e4fc29e8fe45fa \ + --hash=sha256:3bbb357161e8db43ba7cdcc7e03561eba0c449392f2f27d3566887198fcb4ead \ + --hash=sha256:3fbf9c9d1f3c10c2d59d04aca426dee9ccc6ceb32d255c61e93acc3b4f75fae6 \ + --hash=sha256:4ca4a9394b0c771238a4f73590fdbbc4debad85ed0fa63d026ae1b085da7d6e2 \ + --hash=sha256:6746dbcbeb526eb61330b76b41ff1b4eb848951103a892eeb080dfa2b264667b \ + --hash=sha256:6e9817dbdf5ea76789babd46e457eac5bf14ff566cf85f8addbfdff2d56601ce \ + --hash=sha256:6f307c2c32d764ffc6ff6893b801fad6d4752f3e67966cb8abf1843427c02604 \ + --hash=sha256:7b8d26e29bceafbdaa8d63bfe7612f23875b5af2cc07e13f809c3ed890bbe1d8 \ + --hash=sha256:84453b69508ec79902f899c5ed9495acb9e2bbe9fda5f1d5d6f19e3c3842e1a7 \ + --hash=sha256:8e109528e6bab044815daebaf71770fbaace3a66ef1c816cb55c875350f78a60 \ + --hash=sha256:8eb5002ca81af00ae69b57540f615b58b8ae922b6d4848176b366a52bd2196e6 \ + --hash=sha256:966d020354f465672dc7dd10d3a5c6cd17d7eb48620aa1d265b48a1f78f06898 \ + --hash=sha256:991cc14b39e751122c01f017be6448533989868731cb5eecd1006893d26787c2 \ + --hash=sha256:a17ff48608634db245e17e8bb00a9558554a49aeb1e4f5fe6cd039af2a10515b \ + --hash=sha256:a43376bd094124ef626bfdd3d4c2c62eacb0b5ddc99776f4a32d4fd16f1f3420 \ + --hash=sha256:a8b450c1e58e5800e5b4691dac412f8d2d65a1dc3298166f91596603a3531e6f \ + --hash=sha256:ac7aaf322be4777765a53bed7264a214dd81b3a1d276b93150515a3c5f75e4b0 \ + --hash=sha256:b222c15a0fc2ce207d1c1a59700b46c8fa6748df1f447ad11e5c870dde0933d9 \ + --hash=sha256:ca021f9eb2f8345c83fa03e3a04587308afb8df71bd472670b3ece00df58621c \ + --hash=sha256:d20fa53ee744502fa4c69818a720b05ca0d37abd055d4f6e66cae155114bc691 \ + --hash=sha256:dec241fef3984c0d1edadd1f58708e218d4eae881ceef7bc10cf9964d41b68b9 \ + --hash=sha256:e2e5134decf00e218da62318f3dc5df156231d367871918e91eba95ab0ad43ab \ + --hash=sha256:f028e428bddee95cdb86e2470254e95c9af629362488550c200ed4793125a817 \ + --hash=sha256:f5cbb61180a9793d9e12fe115a2310d2600bd449dfb9a01ec5640e21359fa5ea \ + --hash=sha256:fa0762705b933624d59f6823db9ce7ec2e35b3e1e9c319c9db51fbeecfc3e319 \ + --hash=sha256:ffadde149901c8afa138daa38d898264003cfcf1a3336ca5cd964b5af227d867 # via + # -r requirements.in # pyautoflip # torchvision -torchvision==0.28.0 \ - --hash=sha256:028a3d481b37d785605620d7cdad897064c5a55bae2aa1f2658766333e291940 \ - --hash=sha256:09ce8f56e81f19b9c378ae7bb109f83f6659fd8bc3cd14241a48e4af46e9ed49 \ - --hash=sha256:2a1ef4b6f4bf5828b48cfad97372c8982db906830884b2868ba5c3df937a7d81 \ - --hash=sha256:3557cc7b539f46dabcda2b6f2b14017ccbeef024de466d4fc5835fc3f287f769 \ - --hash=sha256:36beb0782976906069ca03d4c9aacaf4b6b838b06ed6c20960ea9c51cce7acdd \ - --hash=sha256:3bd9dba55224a9db4a2d77f6feaa5651770d8c8e86d3d0ddb0fa6bec54c8712b \ - --hash=sha256:46f581979c010ad6da6bd85ee602aa707e1ff44312670223b7a0ee517ad06d47 \ - --hash=sha256:546fd85345cf8652f6cd099d4f9884b0ca5c2f3fae78689a21dd2f35ea6b622f \ - --hash=sha256:5a38bc6da3d72621be003400b66f66a2b4c6d644fde05f680c2cb7ca8cf8dd6c \ - --hash=sha256:5cf78ebc401ce64ae19b8c55de866bb836797d559a4de9c25ccbe74cfa642d3a \ - --hash=sha256:62c7d110f86a039245b587e4fae60278c649f3bd42ff79cfbc1178eca4e72542 \ - --hash=sha256:6dfb0f45e2b4ceb4e76f158c3fbb5f44387099f3c466e3423a09ab665a194aba \ - --hash=sha256:7e80f543b22503d9415e126db5f0ff3917036925e38560ee6b9ae38c571a4002 \ - --hash=sha256:7e9dd6f60d6e15f8dc27d4f877fdb6002fc70d70272412135f1c2ff9cfa08d3b \ - --hash=sha256:7fad44dc9582570c7d92c4487d36ac46998f40cc39b438e8b8f5111a935ce4e8 \ - --hash=sha256:83fe6c020866a85acd7d97deccc45ff11d66daf42916d04396a4309c66c0ccb8 \ - --hash=sha256:87dc16b2df427c1318ad335f1e2be2b3b15b2cf20f7934c83b0505a48425ee5d \ - --hash=sha256:89f90e29b0966352811b12589f3a3c61943bf2bb9487b9d7bbec10efb1096bb5 \ - --hash=sha256:904cf89af220f8c6b2ed0296bb5065b474ce43b77558e48b2bf9de8b0ba17204 \ - --hash=sha256:9a45ea67235d965ef52187130d20002a4de20c54ea3d927a24286961d268dc37 \ - --hash=sha256:ad7b3a439265cc3739a4ab5b4c998c0e38ea99c0ee7ca4dea35c5d0b099ec237 \ - --hash=sha256:bb6dd6918460ed89cc7644adcc2402991474d6933cf1ce92b390641cb233fddf \ - --hash=sha256:d483b4aa3f5237569053f749cd1a2b5bb548ca456e40461a5dd087f21149d123 \ - --hash=sha256:e9f54c30cd52e3ef7fd034cc69b7bb7e0964e1c8f8743e018ab92e95b40f9eee - # via pyautoflip +torchvision==0.28.0+cpu \ + --hash=sha256:1aa741ae0eb8668b6287dd667548e2dd10179c828db68bfdee1519763b9c5b99 \ + --hash=sha256:1dad604dfc0177ecebe0891bd9701fe2c62ec3f7819a247be541b3fb6effee99 \ + --hash=sha256:22958193d72444ed7cbcc665ba4821a31e5279f9c4d1ad08520918b30896b78a \ + --hash=sha256:2f768c4f6d5adf6d5535061fd69ec44827608bac0e96e12114942a6fdfce1107 \ + --hash=sha256:3a1a76c8decb1d7bbedd3588bccc90fb269944b7321a773db181735b42115422 \ + --hash=sha256:7b6667fd0172463be2a271fb0dbd44b31a7891afd549a66208613ce4cdd79f88 \ + --hash=sha256:7d81da2804da52c9788f2d5a8d0aaddcea9fce6eb5d7c6e19a32b40b4ed0b75a \ + --hash=sha256:82dbffb63d61cd43d9c7a311588e665aa2b21173a05f852b4d384d6782fd88ef \ + --hash=sha256:870d0d42f2eb80f4870cd35e51eea52f596a408a671b28136f06a808846f24c5 \ + --hash=sha256:879ae6d4e2e3651582fb7187eafd535601cb5d019595d47e2c874262a000e88e \ + --hash=sha256:8d8b98608779c770ede5e20609772453ebc7487ebb8697445d1856466c542f45 \ + --hash=sha256:b545d46f4d2f9d30381281cf22874bfe1d32a8a7b0ee8396fccde89f30c6a9d9 \ + --hash=sha256:c6373ec4c2f922e89f45ac91889404d312ba29a31f205b0ad9a725a3894ca246 \ + --hash=sha256:cf5d1c4c355c5b487e7d1c681ef28b9caa2ff0dfa3a32fdcd9e98206a1462bf4 \ + --hash=sha256:d2a0171faa211b506c4dcf3a036942a41077c5d2d3d94883dafbda7b8624a3eb \ + --hash=sha256:d63eae114b4d1fca2b294d300cea3f0d6c71b6d132641e0c4cab1aa06a467b0d \ + --hash=sha256:d88db83abbdfb97199979ec94dd427bc372c1b9ab01f0dbed20af05b0bd644b1 \ + --hash=sha256:fc38699d69d11e563a5b1c02f621f639c4caa9e2ffe6b1ddd4aceedffc0d0578 + # via + # -r requirements.in + # pyautoflip tqdm==4.70.0 \ --hash=sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220 \ --hash=sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953 @@ -1690,20 +1588,6 @@ tqdm==4.70.0 \ # insightface # pyautoflip # scenedetect -triton==3.7.1 ; python_full_version < '3.15' and sys_platform == 'linux' \ - --hash=sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68 \ - --hash=sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2 \ - --hash=sha256:3daf64305d6cea88d3334c65ebc9bcd0c64c9564a977084366aa768d57cbcf64 \ - --hash=sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb \ - --hash=sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5 \ - --hash=sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728 \ - --hash=sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1 \ - --hash=sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7 \ - --hash=sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a \ - --hash=sha256:d4a0e1cd4c4a76370ed74a8432a53cea28716827d19e40ffc732233e35ceb3f6 \ - --hash=sha256:ee89fbf782ec2ad50391dd1cf26cbea4f4467154c37f4773026da8fc31c0f58e \ - --hash=sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa - # via torch typing-extensions==4.16.0 \ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 diff --git a/pyautoflip/warm_models.py b/pyautoflip/warm_models.py index 8794cd7..c630993 100644 --- a/pyautoflip/warm_models.py +++ b/pyautoflip/warm_models.py @@ -1,7 +1,46 @@ +from __future__ import annotations + +import hashlib +import shutil +import tempfile +import urllib.request +import zipfile +from pathlib import Path + from insightface.app import FaceAnalysis -for name in ("buffalo_s", "buffalo_l"): - app = FaceAnalysis(name=name, providers=["CPUExecutionProvider"]) - app.prepare(ctx_id=-1, det_size=(640, 640)) - print(f"insightface {name} ready") +NAME = "buffalo_s" +URL = "https://github.com/deepinsight/insightface/releases/download/v0.7/buffalo_s.zip" +SHA256 = "d85a87f503f691807cd8bb97128bdf7a0660326cd9cd02657127fa978bab8b5e" +MODEL_DIR = Path.home() / ".insightface" / "models" / NAME + + +def install() -> None: + if MODEL_DIR.is_dir(): + return + MODEL_DIR.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(suffix=".zip") as archive: + digest = hashlib.sha256() + with urllib.request.urlopen(URL) as response: + while chunk := response.read(1024 * 1024): + archive.write(chunk) + digest.update(chunk) + archive.flush() + actual = digest.hexdigest() + if actual != SHA256: + raise RuntimeError(f"{NAME} checksum mismatch: {actual}") + temporary = Path(tempfile.mkdtemp(dir=MODEL_DIR.parent)) + try: + with zipfile.ZipFile(archive.name) as bundle: + bundle.extractall(temporary) + temporary.rename(MODEL_DIR) + except BaseException: + shutil.rmtree(temporary, ignore_errors=True) + raise + + +install() +app = FaceAnalysis(name=NAME, providers=["CPUExecutionProvider"]) +app.prepare(ctx_id=-1, det_size=(640, 640)) +print(f"insightface {NAME} ready ({SHA256})") diff --git a/scripts/bootstrap-n8n.sh b/scripts/bootstrap-n8n.sh index 216f622..8a32f7b 100755 --- a/scripts/bootstrap-n8n.sh +++ b/scripts/bootstrap-n8n.sh @@ -18,13 +18,14 @@ for name in \ need_env "$name" done -WORKFLOW_FILES=( - "n8n/workflows/Adapt Hugo Media.json" - "n8n/workflows/Adapt Feature Image.json" - "n8n/workflows/Adapt Reel Media.json" - "n8n/workflows/Blog Post Publish.json" - "n8n/workflows/Reel Publish.json" -) +WORKFLOW_FILES=() +while IFS= read -r file; do + WORKFLOW_FILES+=("$file") +done < <(python3 "$ROOT/scripts/workflow_order.py" "$SOURCE_ROOT") +if [[ -z "${WORKFLOW_FILES[*]-}" ]]; then + echo "No workflow exports found under $SOURCE_ROOT." >&2 + exit 1 +fi if [[ -z "${SFTP_PRIVATE_KEY:-}" ]]; then key_file="$(resolve_from_root "${SFTP_PRIVATE_KEY_FILE:-secrets/sftp_n8n_ed25519}")" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 3d13f83..896a8b5 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -38,13 +38,25 @@ old_previous_tag="${PREVIOUS_TAG:-}" old_rollback_backup="${ROLLBACK_BACKUP:-}" old_revision="${CURRENT_GIT_REVISION:-${DEPLOYED_GIT_REVISION:-}}" old_previous_revision="${PREVIOUS_GIT_REVISION:-}" +old_source_root="${CURRENT_SOURCE_ROOT:-}" +old_previous_source_root="${PREVIOUS_SOURCE_ROOT:-}" desired_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" +if [[ -n "$old_source_root" && ! -d "$old_source_root" ]]; then + old_source_root="" +fi if [[ "${SYNDICATOR_ALLOW_DIRTY:-0}" != "1" ]] && \ [[ -n "$(git status --porcelain 2>/dev/null || true)" ]]; then echo "Refusing to build a release from a dirty working tree." >&2 exit 1 fi +if [[ -z "$old_source_root" && -n "$old_revision" && "$old_revision" != "unknown" ]]; then + old_source_root="$(materialize_release_source "$old_revision")" +fi +if [[ -n "$old_source_root" ]]; then + export SYNDICATOR_SOURCE_ROOT="$old_source_root" + SOURCE_ROOT="$old_source_root" +fi if [[ -n "$requested_tag" ]]; then desired_tag="$requested_tag" @@ -83,6 +95,9 @@ if [[ "$release_changed" -eq 1 ]] && persistent_state_exists; then "$ROOT/scripts/backup.sh" --output "$backup_path" fi +desired_source_root="$(materialize_release_source "$desired_revision")" +export SYNDICATOR_SOURCE_ROOT="$ROOT" +SOURCE_ROOT="$ROOT" export SYNDICATOR_IMAGE_TAG="$desired_tag" if [[ "$pull" -eq 1 ]]; then compose build --pull @@ -96,6 +111,7 @@ umask 077 { printf 'PENDING_TAG=%q\n' "$desired_tag" printf 'PENDING_GIT_REVISION=%q\n' "$desired_revision" + printf 'PENDING_SOURCE_ROOT=%q\n' "$desired_source_root" printf 'RECOVERY_BACKUP=%q\n' "$backup_path" } >"$pending_state" chmod 600 "$pending_state" @@ -135,10 +151,12 @@ if [[ "$release_changed" -eq 1 ]]; then previous_tag="$old_tag" rollback_backup="$backup_path" previous_revision="$old_revision" + previous_source_root="$old_source_root" else previous_tag="$old_previous_tag" rollback_backup="$old_rollback_backup" previous_revision="$old_previous_revision" + previous_source_root="$old_previous_source_root" fi write_release_state \ @@ -146,7 +164,9 @@ write_release_state \ "$previous_tag" \ "$rollback_backup" \ "$desired_revision" \ - "$previous_revision" + "$previous_revision" \ + "$desired_source_root" \ + "$previous_source_root" rm -f "$pending_state" trap - EXIT diff --git a/scripts/lib.sh b/scripts/lib.sh index 2f1007e..3986c4b 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -50,6 +50,10 @@ release_state_file() { resolve_from_root "${SYNDICATOR_RELEASE_STATE_FILE:-secrets/release.env}" } +release_sources_dir() { + resolve_from_root "${SYNDICATOR_RELEASE_SOURCES_DIR:-secrets/release-sources}" +} + pending_release_file() { printf '%s.pending\n' "$(release_state_file)" } @@ -60,7 +64,34 @@ load_release_state() { if [[ -s "$state" ]]; then # shellcheck source=/dev/null source "$state" + if [[ -z "${SYNDICATOR_SOURCE_ROOT:-}" && \ + -n "${CURRENT_SOURCE_ROOT:-}" && \ + -d "$CURRENT_SOURCE_ROOT" ]]; then + SOURCE_ROOT="$CURRENT_SOURCE_ROOT" + fi + fi +} + +materialize_release_source() { + local revision="$1" + local base target temporary + base="$(release_sources_dir)" + target="$base/$revision" + if [[ -d "$target" ]]; then + printf '%s\n' "$target" + return + fi + mkdir -p "$base" + chmod 700 "$base" + temporary="$(mktemp -d "$base/.source.XXXXXX")" + if ! git archive "$revision" | tar -x -C "$temporary"; then + rm -rf "$temporary" + return 1 fi + printf '%s\n' "$revision" >"$temporary/.syndicator-revision" + chmod -R go-rwx "$temporary" + mv "$temporary" "$target" + printf '%s\n' "$target" } write_release_state() { @@ -69,6 +100,8 @@ write_release_state() { local rollback_backup="${3:-}" local current_revision="${4:-}" local previous_revision="${5:-}" + local current_source_root="${6:-}" + local previous_source_root="${7:-}" local state temporary_state if [[ -z "$current_revision" ]]; then current_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" @@ -83,6 +116,8 @@ write_release_state() { printf 'ROLLBACK_BACKUP=%q\n' "$rollback_backup" printf 'CURRENT_GIT_REVISION=%q\n' "$current_revision" printf 'PREVIOUS_GIT_REVISION=%q\n' "$previous_revision" + printf 'CURRENT_SOURCE_ROOT=%q\n' "$current_source_root" + printf 'PREVIOUS_SOURCE_ROOT=%q\n' "$previous_source_root" printf 'DEPLOYED_AT=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" } >"$temporary_state" chmod 600 "$temporary_state" diff --git a/scripts/restore.sh b/scripts/restore.sh index 7a50bf9..dc7b783 100755 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -50,7 +50,27 @@ if ! docker info >/dev/null 2>&1; then fi staging="$(mktemp -d)" -trap 'rm -rf "$staging"' EXIT +restore_mutated=0 +restore_pending="" +cleanup() { + status=$? + if [[ "$status" -ne 0 && "$restore_mutated" -eq 1 ]]; then + compose stop n8n pyautoflip sftp >/dev/null 2>&1 || true + if [[ -n "$restore_pending" ]]; then + mkdir -p "$(dirname "$restore_pending")" + { + printf 'RESTORE_ARCHIVE=%q\n' "$archive" + printf 'RESTORE_SOURCE_REVISION=%q\n' "${source_revision:-unknown}" + printf 'RESTORE_IMAGE_TAG=%q\n' "${desired_image_tag:-unknown}" + } >"$restore_pending" + chmod 600 "$restore_pending" + fi + echo "Restore failed after mutation; unverified services were stopped." >&2 + fi + rm -rf "$staging" + exit "$status" +} +trap cleanup EXIT python3 - "$archive" "$staging" <<'PY' import hashlib import json @@ -146,6 +166,7 @@ fi target_env_file="$ENV_FILE" ENV_FILE="$staging/config/environment.env" load_env +restore_pending="$(release_state_file).restore-pending" environment_image_tag="${SYNDICATOR_IMAGE_TAG:-}" archive_release_tag="" if [[ -f "$staging/config/release.env" ]]; then @@ -204,6 +225,16 @@ for service in n8n sftp pyautoflip; do fi done +mkdir -p "$(dirname "$restore_pending")" +umask 077 +{ + printf 'RESTORE_ARCHIVE=%q\n' "$archive" + printf 'RESTORE_SOURCE_REVISION=%q\n' "$source_revision" + printf 'RESTORE_IMAGE_TAG=%q\n' "$desired_image_tag" +} >"$restore_pending" +chmod 600 "$restore_pending" +restore_mutated=1 + ENV_FILE="$target_env_file" mkdir -p "$(dirname "$ENV_FILE")" cp "$staging/config/environment.env" "$ENV_FILE" @@ -264,6 +295,36 @@ for volume in n8n_data sftp_data sftp_host_keys; do done compose up -d --remove-orphans +if [[ "${SYNDICATOR_TEST_FAIL_RESTORE_AFTER_START:-0}" == "1" ]]; then + echo "Deliberate post-start restore failure requested by integration test." >&2 + false +fi "$ROOT/scripts/bootstrap-n8n.sh" "$ROOT/scripts/verify.sh" + +unset CURRENT_TAG PREVIOUS_TAG ROLLBACK_BACKUP \ + CURRENT_GIT_REVISION PREVIOUS_GIT_REVISION \ + CURRENT_SOURCE_ROOT PREVIOUS_SOURCE_ROOT +load_release_state +restored_current_tag="${CURRENT_TAG:-$desired_image_tag}" +restored_current_revision="${CURRENT_GIT_REVISION:-$manifest_revision}" +restored_previous_tag="${PREVIOUS_TAG:-}" +restored_previous_revision="${PREVIOUS_GIT_REVISION:-}" +restored_rollback_backup="${ROLLBACK_BACKUP:-}" +restored_current_source="$(materialize_release_source "$restored_current_revision")" +restored_previous_source="" +if [[ -n "$restored_previous_revision" ]] && \ + git cat-file -e "${restored_previous_revision}^{commit}" 2>/dev/null; then + restored_previous_source="$(materialize_release_source "$restored_previous_revision")" +fi +write_release_state \ + "$restored_current_tag" \ + "$restored_previous_tag" \ + "$restored_rollback_backup" \ + "$restored_current_revision" \ + "$restored_previous_revision" \ + "$restored_current_source" \ + "$restored_previous_source" +rm -f "$restore_pending" +restore_mutated=0 echo "Restore from $archive completed." diff --git a/scripts/rollback.sh b/scripts/rollback.sh index 036b5e1..a6b65b8 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -17,6 +17,8 @@ previous_tag="${PREVIOUS_TAG:-}" rollback_backup="${ROLLBACK_BACKUP:-}" current_revision="${CURRENT_GIT_REVISION:-${DEPLOYED_GIT_REVISION:-}}" previous_revision="${PREVIOUS_GIT_REVISION:-}" +current_source_root="${CURRENT_SOURCE_ROOT:-}" +previous_source_root="${PREVIOUS_SOURCE_ROOT:-}" if [[ -z "$current_tag" || -z "$previous_tag" || -z "$rollback_backup" || \ -z "$previous_revision" ]]; then @@ -24,8 +26,9 @@ if [[ -z "$current_tag" || -z "$previous_tag" || -z "$rollback_backup" || \ exit 1 fi checkout_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" -if [[ -n "$current_revision" && "$checkout_revision" != "$current_revision" ]]; then - echo "Rollback must run from the current release source: $current_revision" >&2 +if [[ "$checkout_revision" != "$current_revision" && \ + "$checkout_revision" != "$previous_revision" ]]; then + echo "Checkout $checkout_revision is unrelated to the retained releases." >&2 exit 1 fi if [[ "${SYNDICATOR_ALLOW_DIRTY:-0}" != "1" ]] && \ @@ -33,6 +36,12 @@ if [[ "${SYNDICATOR_ALLOW_DIRTY:-0}" != "1" ]] && \ echo "Refusing to roll back from a dirty working tree." >&2 exit 1 fi +if [[ -z "$current_source_root" || ! -d "$current_source_root" ]]; then + current_source_root="$(materialize_release_source "$current_revision")" +fi +if [[ -z "$previous_source_root" || ! -d "$previous_source_root" ]]; then + previous_source_root="$(materialize_release_source "$previous_revision")" +fi if [[ ! -f "$rollback_backup" ]]; then echo "Recorded rollback backup is missing: $rollback_backup" >&2 exit 1 @@ -45,25 +54,20 @@ for image in "syndicator-n8n:$previous_tag" "syndicator-pyautoflip:$previous_tag done export SYNDICATOR_IMAGE_TAG="$current_tag" +export SYNDICATOR_SOURCE_ROOT="$current_source_root" +SOURCE_ROOT="$current_source_root" backup_dir="$(resolve_from_root "${SYNDICATOR_BACKUP_DIR:-backups}")" forward_backup="$backup_dir/pre-rollback-${current_tag}-to-${previous_tag}-$(date -u +%Y%m%dT%H%M%SZ).tar.gz" "$ROOT/scripts/backup.sh" --output "$forward_backup" -source_bundle="$(mktemp -d)" -cleanup() { - status=$? - rm -rf "$source_bundle" - exit "$status" -} -trap cleanup EXIT -git archive "$previous_revision" | tar -x -C "$source_bundle" -if [[ ! -f "$source_bundle/docker-compose.yml" ]]; then +if [[ ! -f "$previous_source_root/docker-compose.yml" ]]; then echo "Previous revision has no deployable Compose definition." >&2 exit 1 fi export SYNDICATOR_IMAGE_TAG="$previous_tag" -export SYNDICATOR_SOURCE_ROOT="$source_bundle" +export SYNDICATOR_SOURCE_ROOT="$previous_source_root" +SOURCE_ROOT="$previous_source_root" export SYNDICATOR_SOURCE_REVISION="$previous_revision" "$ROOT/scripts/restore.sh" --yes --no-build "$rollback_backup" write_release_state \ @@ -71,8 +75,8 @@ write_release_state \ "$current_tag" \ "$forward_backup" \ "$previous_revision" \ - "$current_revision" -trap - EXIT -rm -rf "$source_bundle" + "$current_revision" \ + "$previous_source_root" \ + "$current_source_root" echo "Rolled back from $current_tag to $previous_tag." diff --git a/scripts/workflow_order.py b/scripts/workflow_order.py new file mode 100755 index 0000000..ae47c6d --- /dev/null +++ b/scripts/workflow_order.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""List workflow files with referenced sub-workflows before their parents.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + + +def reference(node: dict[str, Any]) -> str | None: + value = node.get("parameters", {}).get("workflowId") + if isinstance(value, str): + return value + if isinstance(value, dict) and isinstance(value.get("value"), str): + return value["value"] + return None + + +def main() -> int: + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} SOURCE_ROOT", file=sys.stderr) + return 2 + source_root = Path(sys.argv[1]) + paths = sorted((source_root / "n8n" / "workflows").glob("*.json")) + workflows = {path: json.loads(path.read_text(encoding="utf-8")) for path in paths} + by_id = {workflow["id"]: path for path, workflow in workflows.items()} + + ordered: list[Path] = [] + visiting: set[Path] = set() + visited: set[Path] = set() + + def visit(path: Path) -> None: + if path in visited: + return + if path in visiting: + raise ValueError(f"workflow dependency cycle at {path.name}") + visiting.add(path) + dependencies = { + dependency + for node in workflows[path].get("nodes", []) + if (dependency := reference(node)) is not None + } + for dependency in sorted(dependencies): + if dependency not in by_id: + raise ValueError(f"{path.name} references unknown workflow {dependency}") + visit(by_id[dependency]) + visiting.remove(path) + visited.add(path) + ordered.append(path) + + for path in paths: + visit(path) + for path in ordered: + print(path.relative_to(source_root)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, KeyError, ValueError, json.JSONDecodeError) as exc: + print(exc, file=sys.stderr) + raise SystemExit(1) from exc diff --git a/tests/integration/reframe.sh b/tests/integration/reframe.sh new file mode 100755 index 0000000..7c0752d --- /dev/null +++ b/tests/integration/reframe.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="${1:-syndicator-pyautoflip:local}" +tmp="$(mktemp -d)" +name="syndicator-reframe-${RANDOM}" +port="$(python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" +chmod 777 "$tmp" + +cleanup() { + status=$? + if [[ "$status" -ne 0 ]]; then + docker logs "$name" >&2 || true + fi + docker rm -f "$name" >/dev/null 2>&1 || true + rm -rf "$tmp" + exit "$status" +} +trap cleanup EXIT + +docker run -d --rm \ + --name "$name" \ + -p "127.0.0.1:${port}:8080" \ + -v "$tmp:/files" \ + "$image" >/dev/null + +for _ in $(seq 1 60); do + if curl -fsS "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then + break + fi + sleep 1 +done +curl -fsS "http://127.0.0.1:${port}/health" >/dev/null + +docker exec "$name" ffmpeg \ + -hide_banner -loglevel error -y \ + -f lavfi -i "testsrc=size=320x180:rate=5" \ + -t 1 -pix_fmt yuv420p /files/input.mp4 + +curl -fsS --max-time 180 \ + -H 'Content-Type: application/json' \ + -d '{ + "input_path": "/files/input.mp4", + "output_path": "/files/output.mp4", + "aspect_ratio": "9:16", + "method": "saliency" + }' \ + "http://127.0.0.1:${port}/reframe" >"$tmp/response.json" + +python3 - "$tmp/response.json" "$tmp/output.mp4" <<'PY' +import json +from pathlib import Path +import sys + +response = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +output = Path(sys.argv[2]) +if not output.is_file() or output.stat().st_size == 0: + raise SystemExit("reframe did not create output media") +if response.get("width", 0) <= 0 or response.get("height", 0) <= 0: + raise SystemExit(f"invalid reframe dimensions: {response}") +PY + +echo "Production pyautoflip reframe smoke test passed." diff --git a/tests/integration/stack.sh b/tests/integration/stack.sh index a189179..ff72c48 100755 --- a/tests/integration/stack.sh +++ b/tests/integration/stack.sh @@ -45,6 +45,7 @@ SFTP_KEYS_DIR=$tmp/keys PYAUTOFLIP_WARM_MODELS=0 SYNDICATOR_BACKUP_DIR=$tmp/backups SYNDICATOR_RELEASE_STATE_FILE=$tmp/release.env +SYNDICATOR_RELEASE_SOURCES_DIR=$tmp/release-sources SYNDICATOR_ALLOW_DIRTY=1 EOF chmod 600 "$env_file" @@ -171,6 +172,23 @@ sftp -q -b "$tmp/sftp-remove.batch" \ -o "UserKnownHostsFile=$tmp/known_hosts" \ sftp@127.0.0.1 +set +e +SYNDICATOR_TEST_FAIL_RESTORE_AFTER_START=1 \ + "$ROOT/bin/syndicator" restore --yes --no-build "$backup_archive" \ + >"$tmp/failed-restore.log" 2>&1 +failed_restore_status=$? +set -e +if [[ "$failed_restore_status" -eq 0 || \ + ! -s "$tmp/release.env.restore-pending" ]]; then + echo "Failed restore was not contained and recorded." >&2 + exit 1 +fi +if [[ -n "$(docker compose --env-file "$env_file" -p "$project" \ + ps --status running -q n8n)" ]]; then + echo "Failed restore left unverified n8n running." >&2 + exit 1 +fi + "$ROOT/bin/syndicator" restore --yes --no-build "$backup_archive" cat >"$tmp/sftp-restored.batch" < None: f"{path.name}: {node.get('name')} references an unknown workflow", ) + def test_workflow_order_places_dependencies_first(self) -> None: + result = subprocess.run( + ["python3", str(ROOT / "scripts" / "workflow_order.py"), str(ROOT)], + check=True, + capture_output=True, + text=True, + ) + ordered_paths = [ROOT / line for line in result.stdout.splitlines()] + positions = { + load_json(path)["id"]: position + for position, path in enumerate(ordered_paths) + } + self.assertEqual(set(ordered_paths), set(self.workflow_paths)) + for path, workflow in self.workflows.items(): + for node in workflow.get("nodes", []): + dependency = workflow_reference(node) + if dependency is not None: + self.assertLess( + positions[dependency], + positions[workflow["id"]], + f"{path.name}: dependency must be imported first", + ) + def test_workflow_exports_exclude_instance_state(self) -> None: for path, workflow in self.workflows.items(): self.assertFalse(workflow.get("pinData"), path) @@ -124,9 +149,11 @@ def test_runtime_dependencies_are_locked(self) -> None: self.assertNotIn(":stable", runtime_config) self.assertGreaterEqual( len(re.findall(r"@sha256:[0-9a-f]{64}", runtime_config)), - 7, + 6, "container bases should be immutable by default", ) + self.assertNotRegex(runtime_config, r"ARG [A-Z0-9_]+_IMAGE=") + self.assertNotIn("pyautoflip_home", runtime_config) package = load_json(ROOT / "n8n" / "package.json") for version in package["dependencies"].values(): @@ -137,8 +164,15 @@ def test_runtime_dependencies_are_locked(self) -> None: encoding="utf-8" ) self.assertIn("--hash=sha256:", requirements) + self.assertIn("torch==2.13.0+cpu", requirements) + self.assertNotIn("\nnvidia-", requirements) self.assertTrue((ROOT / "pyautoflip" / "requirements.in").is_file()) + dependabot = (ROOT / ".github" / "dependabot.yml").read_text( + encoding="utf-8" + ) + self.assertIn("package-ecosystem: docker-compose", dependabot) + def test_example_configuration_is_host_neutral(self) -> None: example = (ROOT / ".env.example").read_text(encoding="utf-8") self.assertNotRegex(example, r"\b192\.168\.\d{1,3}\.\d{1,3}\b") @@ -168,6 +202,36 @@ def test_local_markdown_links_resolve(self) -> None: path = (document.parent / unquote(parsed.path)).resolve() self.assertTrue(path.exists(), f"{document}: broken link {target}") + def test_dotenv_parser_does_not_evaluate_values(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + marker = root / "executed" + dotenv = root / ".env" + dotenv.write_text( + "\n".join( + [ + f"LITERAL='$(touch {marker})'", + "DOLLAR=prefix$HOME", + 'SPACED="hello world"', + "COMMENTED=value # ignored", + ] + ) + + "\n", + encoding="utf-8", + ) + result = subprocess.run( + ["python3", str(ROOT / "scripts" / "dotenv.py"), str(dotenv)], + check=True, + capture_output=True, + ) + fields = result.stdout.split(b"\0") + values = dict(zip(fields[0::2], fields[1::2])) + self.assertEqual(values[b"LITERAL"], f"$(touch {marker})".encode()) + self.assertEqual(values[b"DOLLAR"], b"prefix$HOME") + self.assertEqual(values[b"SPACED"], b"hello world") + self.assertEqual(values[b"COMMENTED"], b"value") + self.assertFalse(marker.exists()) + if __name__ == "__main__": unittest.main() From 26516740508cf3242d0bf1aeb534509b62962281 Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Thu, 13 Aug 2026 19:02:35 +0200 Subject: [PATCH 08/16] Refactor deployment lifecycle by removing backup, restore, update, and rollback scripts This commit simplifies the deployment process by eliminating the backup, restore, update, and rollback scripts, which are no longer part of the lifecycle. The `.env.example` and `.gitignore` files have been updated to reflect these changes. The README and documentation have been revised to clarify the new disaster recovery approach, emphasizing the need for external management of identity files and environment variables. The `bin/syndicator` script has been streamlined to focus on initialization and deployment only. --- .env.example | 2 - .gitignore | 3 +- README.md | 18 +- bin/syndicator | 16 -- docker-compose.yml | 11 - docs/adr/0001-deployment-model.md | 32 +-- docs/adr/0002-disposable-instances.md | 53 +++++ docs/operations.md | 148 +++--------- scripts/backup.sh | 157 ------------ scripts/deploy.sh | 65 +---- scripts/lib.sh | 60 +---- scripts/restore.sh | 330 -------------------------- scripts/rollback.sh | 82 ------- scripts/update.sh | 6 - tests/integration/stack.sh | 68 +----- tests/test_repository.py | 2 +- 16 files changed, 121 insertions(+), 932 deletions(-) create mode 100644 docs/adr/0002-disposable-instances.md delete mode 100755 scripts/backup.sh delete mode 100755 scripts/restore.sh delete mode 100755 scripts/rollback.sh delete mode 100755 scripts/update.sh diff --git a/.env.example b/.env.example index 98dfd21..82937e6 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,4 @@ SFTP_PRIVATE_KEY_FILE=./secrets/sftp_n8n_ed25519 # SFTP_PLATFORM=linux/amd64 # --- Operations --- -# SYNDICATOR_BACKUP_DIR=./backups # SYNDICATOR_RELEASE_STATE_FILE=secrets/release.env -# SYNDICATOR_RELEASE_SOURCES_DIR=secrets/release-sources diff --git a/.gitignore b/.gitignore index eb813c1..4935d0d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,8 @@ node_modules/ config.local.yaml .mypy_cache/ -# Compose secrets / local keys / update log +# Compose secrets / local keys secrets/* !secrets/.gitkeep sftp/keys/* !sftp/keys/.gitkeep -backups/ diff --git a/README.md b/README.md index 9546843..7729b8b 100644 --- a/README.md +++ b/README.md @@ -177,23 +177,17 @@ The `files-init` Compose service chowns the shared `n8n_files` volume to uid/gid ## Updates and recovery -Dependencies are pinned and proposed through reviewed dependency PRs; there are no unattended production upgrades. +Instances are disposable. `.env`, SFTP host keys, and authorized client keys are identity; everything else can be rebuilt from git. -```bash -bin/syndicator backup -bin/syndicator update -bin/syndicator rollback -# Destructive and explicit: -bin/syndicator restore --yes backups/.tar.gz -``` +Pull a reviewed revision and run `bin/syndicator deploy` (add `--pull` to refresh base images). A failed deploy stops the unverified services; fix the checkout and deploy again. -An update gets a commit-based image tag and creates a consistent backup when the release changes. Rollback requires both the retained previous images and their matching backup. Backup archives contain credentials and are written with mode `0600`; copy them to encrypted off-host storage. +Disaster recovery is a new instance: reprovide `.env`, run `init` and `deploy`, and regenerate SFTP keys unless you kept them outside Syndicator. Callers may need to accept a new SSH host key and re-upload files. ## Architecture The workflow engine, n8n, orchestrates all blog post processing via modular workflows. The most important non-functional requirements are repeatability, testability, automation, and maintainability. The initial custom pipeline became difficult to change, which motivated decomposing processing into visible workflow nodes. -Compose remains the application boundary because it isolates three different runtimes and provides the same topology on macOS and Linux. The operator lifecycle is intentionally separate and tested through `bin/syndicator`. The rationale and rejected alternatives are recorded in [ADR 0001](docs/adr/0001-deployment-model.md). +Compose remains the application boundary because it isolates three different runtimes and provides the same topology on macOS and Linux. The operator lifecycle is intentionally separate and tested through `bin/syndicator`. The rationale and rejected alternatives are recorded in [ADR 0001](docs/adr/0001-deployment-model.md); disposable instances are [ADR 0002](docs/adr/0002-disposable-instances.md). ## Software Design @@ -214,7 +208,7 @@ The repo is the blueprint for a containerized instance: Compose defines the stac | `n8n/credentials/` | Credential templates (stable IDs; secrets from `.env`) | | `pyautoflip/` | Image/build context for the reframe sidecar | | `sftp/keys/` | Authorized client public keys (refreshed into `authorized_keys` on each sftp start) | -| `bin/syndicator` | Checked lifecycle: deploy, verify, backup, restore, update, rollback | +| `bin/syndicator` | Checked lifecycle: init, deploy, bootstrap, verify, export | ``` docker-compose.yml @@ -224,7 +218,7 @@ n8n/workflows/ n8n/credentials/*.template.json pyautoflip/ sftp/ -scripts/{init,deploy,bootstrap,verify,backup,restore,update,rollback,export}.sh +scripts/{init,deploy,bootstrap,verify,export}.sh docs/{operations.md,adr/} bin/syndicator ``` diff --git a/bin/syndicator b/bin/syndicator index 39447b1..d7e6c7a 100755 --- a/bin/syndicator +++ b/bin/syndicator @@ -16,10 +16,6 @@ Lifecycle: bootstrap Reconcile n8n credentials and workflows verify Verify health, workflows, pyautoflip, and SFTP export Export sanitized workflows from n8n - backup Archive critical volumes, configuration, and secrets - restore Restore a validated backup archive - update Back up and deploy reviewed dependency changes - rollback Restore the recorded previous release restart Restart one or more services, then verify status Show Compose service status logs Follow Compose service logs @@ -48,18 +44,6 @@ case "$command" in export) exec "$ROOT/scripts/export-workflows.sh" "$@" ;; - backup) - exec "$ROOT/scripts/backup.sh" "$@" - ;; - restore) - exec "$ROOT/scripts/restore.sh" "$@" - ;; - update) - exec "$ROOT/scripts/update.sh" "$@" - ;; - rollback) - exec "$ROOT/scripts/rollback.sh" "$@" - ;; restart) if [[ "$#" -eq 0 ]]; then echo "Usage: bin/syndicator restart SERVICE..." >&2 diff --git a/docker-compose.yml b/docker-compose.yml index 4c93e49..3640a9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,17 +19,6 @@ services: ] restart: "no" - # Explicitly invoked by backup/restore; never part of the runtime stack. - volume-tool: - image: alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc - profiles: [tools] - volumes: - - n8n_data:/volumes/n8n_data - - sftp_data:/volumes/sftp_data - - sftp_host_keys:/volumes/sftp_host_keys - entrypoint: ["sh"] - command: ["-c", "true"] - sftp: image: atmoz/sftp:alpine@sha256:81fa92512bf8ead4849f33c1c153907b86d32d77704d1c62a9c70b4316ae9e50 # The upstream image is amd64-only; Docker Desktop emulates it on Apple Silicon. diff --git a/docs/adr/0001-deployment-model.md b/docs/adr/0001-deployment-model.md index bf5f6ff..0f9ab40 100644 --- a/docs/adr/0001-deployment-model.md +++ b/docs/adr/0001-deployment-model.md @@ -13,9 +13,9 @@ Syndicator runs three materially different services on one machine: The previous setup mixed a small declarative Compose file with a large, stateful host bootstrap. Image tags floated, workflow IDs were duplicated in -scripts, bootstrap read n8n's private SQLite schema, and updates had no tested -backup or rollback. This made Docker appear to be the source of the -complexity, although most fragility was in the imperative lifecycle around it. +scripts, and bootstrap read n8n's private SQLite schema. This made Docker +appear to be the source of the complexity, although most fragility was in the +imperative lifecycle around it. The supported target for the next one to two years is a developer Mac and one production Linux host. Repeatability, testability, and low operator effort are @@ -30,17 +30,19 @@ Keep Docker Compose as the application packaging and runtime boundary. - Compose owns service networking, health, startup dependencies, ports, and persistent volumes. - `bin/syndicator` is the only operator-facing lifecycle. It delegates to - focused scripts for initialization, deployment, reconciliation, - verification, backup, restore, update, and rollback. + focused scripts for initialization, deployment, reconciliation, and + verification. - Runtime inputs are pinned. Dependency changes arrive as reviewable pull requests and must pass an isolated full-stack test before deployment. -- Each changed release is tagged by Git revision, backed up before deployment, - and retains the previous images and matching state for rollback. +- Each changed release is tagged by Git revision. Application volumes are + disposable; identity (`.env` and SFTP keys) is supplied at instantiate time. + Volume backup and rollback are out of scope; see + [ADR 0002](0002-disposable-instances.md). Ansible may be added outside this boundary to prepare a Linux host: install Docker, configure a firewall or reverse proxy, place the repository and encrypted secrets, and invoke `bin/syndicator deploy`. It must not reproduce -the application installation, workflow import, backup, or update logic. +the application installation, workflow import, or update logic. Terraform is reserved for infrastructure resources such as a VM, DNS records, firewall rules, and backup storage. It is not used to configure processes or @@ -66,9 +68,9 @@ Linux VM test matrix would also replace the current Mac/Linux parity. ### Ansible wrapping Compose Compatible with this decision. It becomes worthwhile when rebuilding the -production host itself is frequent or when firewall, TLS, and off-host backup -configuration need to be managed. For one host it remains optional so the -application does not acquire a second mandatory control plane. +production host itself is frequent or when firewall, TLS, and secret placement +need to be managed. For one host it remains optional so the application does +not acquire a second mandatory control plane. ### Puppet @@ -106,12 +108,10 @@ can be reconsidered now that integration tests protect the behavior. - The stack does not provide TLS or webhook authentication. Safe defaults bind published ports to loopback; exposing them requires an explicit network and reverse-proxy decision. -- Backups contain secrets. File mode `0600` is only a local safeguard; off-host - copies must be encrypted. - pyautoflip's Python graph and model archive are hash-pinned, but Debian media - packages still come from the live Bookworm repositories. Retained, - commit-tagged images are the rollback artifact; use a Debian snapshot if - bit-for-bit disaster rebuilds become a requirement. + packages still come from the live Bookworm repositories. Rebuild from the + same Git revision if a bit-for-bit image recreate is required; use a Debian + snapshot if that recreate must be independent of current Bookworm. ## Revisit when diff --git a/docs/adr/0002-disposable-instances.md b/docs/adr/0002-disposable-instances.md new file mode 100644 index 0000000..77d95a6 --- /dev/null +++ b/docs/adr/0002-disposable-instances.md @@ -0,0 +1,53 @@ +# ADR 0002: Disposable instances + +Status: accepted +Date: 2026-08-13 + +## Context + +ADR 0001 kept Compose as the application boundary and originally treated +volume backup, restore, and rollback as first-class `bin/syndicator` commands. +That assumed n8n SQLite, SFTP uploads, and host keys were unique state that +had to survive a host loss or a bad update. + +Workflows, credentials, and webhook paths are already reconstructed from git +and `.env` by `init` / `deploy` / `bootstrap`. Uploaded SFTP files can be +re-provided by callers. The remaining identity is `.env`, SFTP host keys, and +authorized client keys — which belong next to other host secrets, not inside +the application lifecycle. + +## Decision + +Instances are disposable. Create them at will from the current checkout. + +- Application volumes (`n8n_data`, `sftp_data`, `n8n_files`) are not backed up + or restored by Syndicator. They may be lost on disaster and on update. +- `.env` and SFTP keys are ingested when an instance is created. Keeping + callers unaware of an update means leaving that identity in place. +- Disaster recovery is a new instance: reprovide `.env`, run `init` and + `deploy`, and regenerate SFTP keys unless they were saved outside + Syndicator. Callers re-upload files and may need to accept a new SSH host + key. +- If `.env` and SFTP keys should survive a host loss, back them up outside + this repository. Syndicator does not choose a storage provider or encryption + key lifecycle. + +`bin/syndicator` therefore has no `backup`, `restore`, `update`, or `rollback` +commands. A software update is `bin/syndicator deploy` (optionally `--pull`) +on the reviewed revision. + +## Consequences + +- Failed deploys still stop unverified services; recovery is another deploy, + not a volume restore. +- SFTP host keys remain in the `sftp_host_keys` volume so a normal container + recreate does not change the SSH identity. Wiping that volume is visible to + callers. +- Operators who want secret durability use their own backup of `.env` and + `sftp/keys/`, not an application archive. + +## Revisit when + +- callers cannot re-upload staged files +- n8n execution history or unpublished UI edits become source of truth +- zero-downtime updates become a requirement diff --git a/docs/operations.md b/docs/operations.md index 678618f..12d2f5b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -9,7 +9,7 @@ The host needs: - Docker Engine or Docker Desktop with `docker compose` - Bash, Python 3, curl, OpenSSL, and an OpenSSH client -- enough disk for the n8n and pyautoflip images, media staging, and backups +- enough disk for the n8n and pyautoflip images and media staging - amd64 execution support for the SFTP image; Docker Desktop supplies emulation on Apple Silicon @@ -52,9 +52,7 @@ unchanged and all workflows remain published, n8n import is skipped. The first controlled deployment writes `secrets/release.env`. Unless `SYNDICATOR_IMAGE_TAG` is explicitly set, images use the current 12-character -Git revision as their tag. The exact Compose/workflow source is retained under -`secrets/release-sources/`, so routine commands continue to target the running -release even after the checkout moves forward or a rollback completes. +Git revision as their tag. ## Local and network configuration @@ -140,116 +138,46 @@ For an update: 1. Review the release notes and dependency diff. 2. Let CI validate manifests, audit npm dependencies, build both images, deploy - an isolated stack twice, test SFTP I/O, restore a backup, and exercise - rollback. + an isolated stack twice, and test SFTP I/O. 3. Pull the reviewed Git revision on the server. 4. Run: ```bash -bin/syndicator update +bin/syndicator deploy --pull ``` -When the Git revision changes, update creates a consistent pre-update backup, -builds commit-tagged images, deploys, and verifies. Previous images are not -pruned because rollback needs them. +Deploy rebuilds commit-tagged images from the current checkout, starts the +stack, reconciles n8n, and verifies. Instance volumes are not snapshotted; +callers keep working when `.env`, SFTP host keys, and authorized client keys +stay in place. An explicit tag is available for release testing: ```bash -bin/syndicator update --tag release-candidate-1 +bin/syndicator deploy --tag release-candidate-1 ``` -If bootstrap or verification fails, the new services are stopped and recovery +If bootstrap or verification fails, the new services are stopped and pending details remain in `secrets/release.env.pending`. The last healthy release state -is not overwritten. Use the recorded backup with the matching Git revision; -do not simply restart the failed containers. +is not overwritten. Do not simply restart the failed containers; fix the +checkout and deploy again. -## Backups +## Disaster recovery -Create a backup: - -```bash -bin/syndicator backup -``` - -The command briefly stops stateful services so SQLite and SFTP data are -consistent. It archives: - -- `n8n_data` -- `sftp_data` -- `sftp_host_keys` -- `.env`, n8n owner/API/bootstrap state, SFTP client keys, and release state -- a manifest containing SHA-256 checksums and the Git revision - -The shared processing directory `n8n_files` is scratch space and is not backed -up. InsightFace models are checksum-pinned inside the pyautoflip image, not -stored in a mutable volume. - -Archives default to `backups/` and mode `0600`. They still contain plaintext -credentials. Copy them to encrypted off-host storage and apply an external -retention policy; the repository deliberately does not choose a storage -provider or encryption key lifecycle. - -To select a destination: - -```bash -bin/syndicator backup --output /secure/path/syndicator.tar.gz -``` - -## Restore and disaster recovery - -Restore is destructive and requires explicit confirmation: - -```bash -bin/syndicator restore --yes /secure/path/syndicator.tar.gz -``` - -Before changing state, restore rejects unsafe archive paths, unsupported -members, missing critical volume archives, unsupported formats, and checksum -mismatches. It also requires the checkout to match the archive's Git revision. -Only after validating inner volume archives and building or locating the -required images does it stop services and cross the destructive boundary. It -then replaces current configuration and critical volumes, starts the stack, -reconciles n8n, and verifies all services. - -If a post-mutation restore step fails, all restored-but-unverified services are -stopped and recovery context is written beside `release.env` with the suffix -`.restore-pending`. - -For disaster recovery on a new host: +Syndicator does not back up application volumes. A lost host is a new instance: 1. Install the prerequisites. -2. Check out the Git revision recorded in `manifest.json` inside the backup. -3. Place the encrypted backup on the host and decrypt it locally. -4. Run the restore command. -5. Verify firewall, DNS, reverse proxy, and off-host backup scheduling. - -`--no-build` is reserved for rollback or for a restore where the exact tagged -images are already present. - -## Rollback - -Rollback is available after a release-changing update: - -```bash -bin/syndicator rollback -``` - -It requires: - -- `PREVIOUS_TAG` and `ROLLBACK_BACKUP` in `secrets/release.env` -- a clean checkout at the recorded current Git revision -- both previous application images still present locally -- the matching pre-update backup - -Rollback refuses dirty or mismatched current source. Before restoring the -previous release, it uses the current lifecycle to back up the current release. -It then selects the retained source bundle for the exact previous Git revision, -uses the current hardened restore implementation with that revision's -Compose/workflow definitions, restores matching data, starts the previous image -tags, verifies the stack, and swaps the current and previous release records. - -Do not use `docker image prune -a` while rollback retention is required. +2. Check out the desired Git revision. +3. Restore `.env` from wherever you keep secrets, or recreate it and fill the + required values. +4. Run `bin/syndicator init` and `bin/syndicator deploy`. +5. Restore authorized client public keys under `sftp/keys/` if you kept them. +6. Verify firewall, DNS, and reverse proxy. + +SFTP host keys are generated on first start. Callers must accept the new host +key unless you restore the `sftp_host_keys` volume yourself. Uploaded files are +gone; callers re-upload. If you want `.env` and SFTP keys to survive a host +loss, back them up outside Syndicator. ## Testing @@ -273,12 +201,10 @@ bash tests/integration/stack.sh CI first builds the production model-warmed image and performs a real reframe. The stack integration test uses random loopback ports and a unique Compose project. It deploys twice, checks that API keys and resources are not -duplicated, uploads over SFTP, validates backup/restore, deploys a second -release tag, rolls back, and removes all test containers and volumes. A -deliberately failed release also verifies that untrusted containers are -stopped and pending recovery state is recorded; the same containment is tested -for a failed restore. A separate Buildx job verifies n8n and pyautoflip for -Linux arm64. +duplicated, uploads over SFTP, and removes all test containers and volumes. A +deliberately failed release also verifies that unverified containers are +stopped and pending recovery state is recorded. A separate Buildx job verifies +n8n and pyautoflip for Linux arm64. ## Troubleshooting @@ -294,11 +220,11 @@ migrations must finish before provisioning starts. If SFTP host-key verification changes unexpectedly, do not delete the client known-host entry until the cause is understood. Host keys are persistent state -in `sftp_host_keys` and are included in backups. +in `sftp_host_keys` and survive container recreate, but not a volume wipe. -If credentials cannot be decrypted after a restore, the -`N8N_ENCRYPTION_KEY` does not match `n8n_data`. Restore `.env` and the volume -from the same archive. +If credentials cannot be decrypted, `N8N_ENCRYPTION_KEY` in `.env` does not +match the existing `n8n_data` volume. Use the original key, or remove the +volume and let bootstrap recreate credentials. If Apple Silicon reports an SFTP platform warning, confirm `SFTP_PLATFORM=linux/amd64`; the image is intentionally emulated. @@ -317,10 +243,10 @@ should stop at: - installing a reviewed Docker Engine/Compose version and host utilities - creating the deployment user and directory -- configuring firewall, TLS proxy, and encrypted off-host backup transport +- configuring firewall, TLS proxy, and optional off-host secret backup - placing `.env` and other bootstrap secrets from a vault - checking out a reviewed Git revision and invoking `bin/syndicator deploy` -Do not duplicate Compose services, Dockerfile package installation, n8n -bootstrap, or backup logic in Ansible. Terraform belongs one level further -out: VM, DNS, network rules, and storage resources only. +Do not duplicate Compose services, Dockerfile package installation, or n8n +bootstrap in Ansible. Terraform belongs one level further out: VM, DNS, +network rules, and storage resources only. diff --git a/scripts/backup.sh b/scripts/backup.sh deleted file mode 100755 index 8ae0b42..0000000 --- a/scripts/backup.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# shellcheck source=scripts/lib.sh -source "$ROOT/scripts/lib.sh" - -umask 077 -load_env -"$ROOT/scripts/doctor.sh" --require-config >/dev/null - -output="" -if [[ "${1:-}" == "--output" && -n "${2:-}" && "$#" -eq 2 ]]; then - output="$2" -elif [[ "$#" -ne 0 ]]; then - echo "Usage: $0 [--output ARCHIVE.tar.gz]" >&2 - exit 2 -fi - -backup_dir="$(resolve_from_root "${SYNDICATOR_BACKUP_DIR:-backups}")" -mkdir -p "$backup_dir" -chmod 700 "$backup_dir" -if [[ -z "$output" ]]; then - output="$backup_dir/syndicator-$(date -u +%Y%m%dT%H%M%SZ).tar.gz" -else - output="$(resolve_from_root "$output")" - mkdir -p "$(dirname "$output")" -fi -if [[ -e "$output" ]]; then - echo "Backup already exists: $output" >&2 - exit 1 -fi - -staging="$(mktemp -d "$backup_dir/.syndicator-backup.XXXXXX")" -mkdir -p "$staging/config" "$staging/volumes" -chmod 700 "$staging" "$staging/config" "$staging/volumes" -temporary_output="" - -running_services=() -for service in sftp pyautoflip n8n; do - if [[ -n "$(compose ps --status running -q "$service")" ]]; then - running_services+=("$service") - fi -done -restarted=0 - -restart_services() { - if [[ "$restarted" -eq 0 && "${#running_services[@]}" -gt 0 ]]; then - compose start "${running_services[@]}" >/dev/null - restarted=1 - fi -} - -cleanup() { - status=$? - restart_services || true - rm -rf "$staging" - if [[ -n "$temporary_output" ]]; then - rm -f "$temporary_output" - fi - exit "$status" -} -trap cleanup EXIT - -if [[ "${#running_services[@]}" -gt 0 ]]; then - echo "Stopping stateful services for a consistent backup..." - compose stop "${running_services[@]}" >/dev/null -fi - -host_uid="$(id -u)" -host_gid="$(id -g)" -for volume in n8n_data sftp_data sftp_host_keys; do - echo "Archiving volume $volume..." - # shellcheck disable=SC2016 - compose run --rm --no-deps --user root \ - -e "BACKUP_VOLUME=$volume" \ - -e "HOST_UID=$host_uid" \ - -e "HOST_GID=$host_gid" \ - -v "$staging/volumes:/backup" \ - --entrypoint sh volume-tool -c ' - tar -czf "/backup/${BACKUP_VOLUME}.tar.gz" \ - -C "/volumes/${BACKUP_VOLUME}" . && - chown "${HOST_UID}:${HOST_GID}" "/backup/${BACKUP_VOLUME}.tar.gz" - ' >/dev/null -done - -copy_file() { - local source="$1" - local name="$2" - if [[ -f "$source" ]]; then - cp -p "$source" "$staging/config/$name" - fi -} - -owner_env="$(resolve_from_root "${N8N_OWNER_ENV_FILE:-secrets/n8n_owner.env}")" -api_key="$(resolve_from_root "${N8N_API_KEY_FILE:-secrets/n8n_api_key}")" -bootstrap_state="$(resolve_from_root "${N8N_BOOTSTRAP_STATE_FILE:-secrets/bootstrap.sha256}")" -private_key="$(resolve_from_root "${SFTP_PRIVATE_KEY_FILE:-secrets/sftp_n8n_ed25519}")" -keys_dir="$(resolve_from_root "${SFTP_KEYS_DIR:-sftp/keys}")" -release_state="$(release_state_file)" - -copy_file "$ENV_FILE" environment.env -copy_file "$owner_env" n8n_owner.env -copy_file "$api_key" n8n_api_key -copy_file "$bootstrap_state" bootstrap.sha256 -copy_file "$private_key" sftp_private_key -copy_file "$release_state" release.env -if [[ -d "$keys_dir" ]]; then - cp -Rp "$keys_dir" "$staging/config/sftp_keys" -fi - -load_release_state -git_revision="${CURRENT_GIT_REVISION:-${DEPLOYED_GIT_REVISION:-}}" -if [[ -z "$git_revision" ]]; then - git_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" -fi -GIT_REVISION="$git_revision" RELEASE_TAG="${CURRENT_TAG:-unknown}" \ - python3 - "$staging" <<'PY' -from datetime import datetime, timezone -import hashlib -import json -import os -from pathlib import Path -import sys - -root = Path(sys.argv[1]) -files = {} -for path in sorted(root.rglob("*")): - if path.is_file() and path.name != "manifest.json": - files[str(path.relative_to(root))] = hashlib.sha256(path.read_bytes()).hexdigest() -manifest = { - "format_version": 1, - "created_at": datetime.now(timezone.utc).isoformat(), - "git_revision": os.environ["GIT_REVISION"], - "release_tag": os.environ["RELEASE_TAG"], - "files": files, -} -(root / "manifest.json").write_text( - json.dumps(manifest, indent=2, sort_keys=True) + "\n", - encoding="utf-8", -) -PY - -temporary_output="$(mktemp "$(dirname "$output")/.syndicator-archive.XXXXXX")" -tar -czf "$temporary_output" -C "$staging" . -chmod 600 "$temporary_output" -mv "$temporary_output" "$output" -temporary_output="" - -restart_services -if [[ " ${running_services[*]} " == *" n8n "* && \ - " ${running_services[*]} " == *" sftp "* && \ - " ${running_services[*]} " == *" pyautoflip "* ]]; then - "$ROOT/scripts/verify.sh" >/dev/null -fi - -echo "Backup written to $output" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 896a8b5..c733201 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -34,29 +34,14 @@ load_env load_release_state old_tag="${CURRENT_TAG:-}" -old_previous_tag="${PREVIOUS_TAG:-}" -old_rollback_backup="${ROLLBACK_BACKUP:-}" -old_revision="${CURRENT_GIT_REVISION:-${DEPLOYED_GIT_REVISION:-}}" -old_previous_revision="${PREVIOUS_GIT_REVISION:-}" -old_source_root="${CURRENT_SOURCE_ROOT:-}" -old_previous_source_root="${PREVIOUS_SOURCE_ROOT:-}" +old_revision="${CURRENT_GIT_REVISION:-}" desired_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" -if [[ -n "$old_source_root" && ! -d "$old_source_root" ]]; then - old_source_root="" -fi if [[ "${SYNDICATOR_ALLOW_DIRTY:-0}" != "1" ]] && \ [[ -n "$(git status --porcelain 2>/dev/null || true)" ]]; then echo "Refusing to build a release from a dirty working tree." >&2 exit 1 fi -if [[ -z "$old_source_root" && -n "$old_revision" && "$old_revision" != "unknown" ]]; then - old_source_root="$(materialize_release_source "$old_revision")" -fi -if [[ -n "$old_source_root" ]]; then - export SYNDICATOR_SOURCE_ROOT="$old_source_root" - SOURCE_ROOT="$old_source_root" -fi if [[ -n "$requested_tag" ]]; then desired_tag="$requested_tag" @@ -75,29 +60,7 @@ if [[ -n "$old_tag" && "$old_tag" == "$desired_tag" && \ echo "Use a new --tag value for revision $desired_revision." >&2 exit 1 fi -if [[ -n "$old_previous_tag" && "$old_previous_tag" == "$desired_tag" && \ - -n "$old_previous_revision" && \ - "$old_previous_revision" != "$desired_revision" ]]; then - echo "Image tag $desired_tag is retained for rollback revision $old_previous_revision." >&2 - echo "Use a different --tag value for revision $desired_revision." >&2 - exit 1 -fi - -backup_path="" -release_changed=0 -if [[ "$old_tag" != "$desired_tag" || "$old_revision" != "$desired_revision" ]]; then - release_changed=1 -fi -if [[ "$release_changed" -eq 1 ]] && persistent_state_exists; then - backup_dir="$(resolve_from_root "${SYNDICATOR_BACKUP_DIR:-backups}")" - from_tag="${old_tag:-legacy}" - backup_path="$backup_dir/pre-update-${from_tag}-to-${desired_tag}-$(date -u +%Y%m%dT%H%M%SZ).tar.gz" - "$ROOT/scripts/backup.sh" --output "$backup_path" -fi -desired_source_root="$(materialize_release_source "$desired_revision")" -export SYNDICATOR_SOURCE_ROOT="$ROOT" -SOURCE_ROOT="$ROOT" export SYNDICATOR_IMAGE_TAG="$desired_tag" if [[ "$pull" -eq 1 ]]; then compose build --pull @@ -111,8 +74,6 @@ umask 077 { printf 'PENDING_TAG=%q\n' "$desired_tag" printf 'PENDING_GIT_REVISION=%q\n' "$desired_revision" - printf 'PENDING_SOURCE_ROOT=%q\n' "$desired_source_root" - printf 'RECOVERY_BACKUP=%q\n' "$backup_path" } >"$pending_state" chmod 600 "$pending_state" @@ -130,9 +91,6 @@ deployment_cleanup() { echo "Unverified service is still running: $service" >&2 fi done - if [[ -n "$backup_path" ]]; then - echo "Recovery backup: $backup_path" >&2 - fi fi exit "$status" } @@ -147,26 +105,7 @@ fi "$ROOT/scripts/bootstrap-n8n.sh" "$ROOT/scripts/verify.sh" -if [[ "$release_changed" -eq 1 ]]; then - previous_tag="$old_tag" - rollback_backup="$backup_path" - previous_revision="$old_revision" - previous_source_root="$old_source_root" -else - previous_tag="$old_previous_tag" - rollback_backup="$old_rollback_backup" - previous_revision="$old_previous_revision" - previous_source_root="$old_previous_source_root" -fi - -write_release_state \ - "$desired_tag" \ - "$previous_tag" \ - "$rollback_backup" \ - "$desired_revision" \ - "$previous_revision" \ - "$desired_source_root" \ - "$previous_source_root" +write_release_state "$desired_tag" "$desired_revision" rm -f "$pending_state" trap - EXIT diff --git a/scripts/lib.sh b/scripts/lib.sh index 3986c4b..733ce9b 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -50,10 +50,6 @@ release_state_file() { resolve_from_root "${SYNDICATOR_RELEASE_STATE_FILE:-secrets/release.env}" } -release_sources_dir() { - resolve_from_root "${SYNDICATOR_RELEASE_SOURCES_DIR:-secrets/release-sources}" -} - pending_release_file() { printf '%s.pending\n' "$(release_state_file)" } @@ -64,44 +60,12 @@ load_release_state() { if [[ -s "$state" ]]; then # shellcheck source=/dev/null source "$state" - if [[ -z "${SYNDICATOR_SOURCE_ROOT:-}" && \ - -n "${CURRENT_SOURCE_ROOT:-}" && \ - -d "$CURRENT_SOURCE_ROOT" ]]; then - SOURCE_ROOT="$CURRENT_SOURCE_ROOT" - fi - fi -} - -materialize_release_source() { - local revision="$1" - local base target temporary - base="$(release_sources_dir)" - target="$base/$revision" - if [[ -d "$target" ]]; then - printf '%s\n' "$target" - return - fi - mkdir -p "$base" - chmod 700 "$base" - temporary="$(mktemp -d "$base/.source.XXXXXX")" - if ! git archive "$revision" | tar -x -C "$temporary"; then - rm -rf "$temporary" - return 1 fi - printf '%s\n' "$revision" >"$temporary/.syndicator-revision" - chmod -R go-rwx "$temporary" - mv "$temporary" "$target" - printf '%s\n' "$target" } write_release_state() { local current="$1" - local previous="${2:-}" - local rollback_backup="${3:-}" - local current_revision="${4:-}" - local previous_revision="${5:-}" - local current_source_root="${6:-}" - local previous_source_root="${7:-}" + local current_revision="${2:-}" local state temporary_state if [[ -z "$current_revision" ]]; then current_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" @@ -112,35 +76,13 @@ write_release_state() { temporary_state="${state}.tmp.$$" { printf 'CURRENT_TAG=%q\n' "$current" - printf 'PREVIOUS_TAG=%q\n' "$previous" - printf 'ROLLBACK_BACKUP=%q\n' "$rollback_backup" printf 'CURRENT_GIT_REVISION=%q\n' "$current_revision" - printf 'PREVIOUS_GIT_REVISION=%q\n' "$previous_revision" - printf 'CURRENT_SOURCE_ROOT=%q\n' "$current_source_root" - printf 'PREVIOUS_SOURCE_ROOT=%q\n' "$previous_source_root" printf 'DEPLOYED_AT=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" } >"$temporary_state" chmod 600 "$temporary_state" mv "$temporary_state" "$state" } -compose_project_name() { - compose config --format json | python3 -c ' -import json -import sys - -print(json.load(sys.stdin)["name"]) -' -} - -persistent_state_exists() { - local project - project="$(compose_project_name)" - [[ -n "$(docker volume ls -q \ - --filter "label=com.docker.compose.project=$project" \ - --filter "label=com.docker.compose.volume=n8n_data")" ]] -} - compose() { local args=( --project-directory "$SOURCE_ROOT" diff --git a/scripts/restore.sh b/scripts/restore.sh deleted file mode 100755 index dc7b783..0000000 --- a/scripts/restore.sh +++ /dev/null @@ -1,330 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# shellcheck source=scripts/lib.sh -source "$ROOT/scripts/lib.sh" - -forced_image_tag="${SYNDICATOR_IMAGE_TAG:-}" -confirmed=0 -build_images=1 -while [[ "${1:-}" == --* ]]; do - case "$1" in - --yes) - confirmed=1 - ;; - --no-build) - build_images=0 - ;; - *) - echo "Unknown option: $1" >&2 - exit 2 - ;; - esac - shift -done -if [[ "$#" -ne 1 ]]; then - echo "Usage: $0 --yes [--no-build] ARCHIVE.tar.gz" >&2 - exit 2 -fi -if [[ "$confirmed" -ne 1 ]]; then - echo "Restore replaces current configuration and persistent data; pass --yes." >&2 - exit 2 -fi - -archive="$(resolve_from_root "$1")" -if [[ ! -f "$archive" ]]; then - echo "Backup archive not found: $archive" >&2 - exit 1 -fi - -for command in docker python3 tar; do - if ! command -v "$command" >/dev/null 2>&1; then - echo "Missing required command: $command" >&2 - exit 1 - fi -done -if ! docker info >/dev/null 2>&1; then - echo "Docker daemon is unavailable." >&2 - exit 1 -fi - -staging="$(mktemp -d)" -restore_mutated=0 -restore_pending="" -cleanup() { - status=$? - if [[ "$status" -ne 0 && "$restore_mutated" -eq 1 ]]; then - compose stop n8n pyautoflip sftp >/dev/null 2>&1 || true - if [[ -n "$restore_pending" ]]; then - mkdir -p "$(dirname "$restore_pending")" - { - printf 'RESTORE_ARCHIVE=%q\n' "$archive" - printf 'RESTORE_SOURCE_REVISION=%q\n' "${source_revision:-unknown}" - printf 'RESTORE_IMAGE_TAG=%q\n' "${desired_image_tag:-unknown}" - } >"$restore_pending" - chmod 600 "$restore_pending" - fi - echo "Restore failed after mutation; unverified services were stopped." >&2 - fi - rm -rf "$staging" - exit "$status" -} -trap cleanup EXIT -python3 - "$archive" "$staging" <<'PY' -import hashlib -import json -import posixpath -from pathlib import Path -import sys -import tarfile - -archive = Path(sys.argv[1]) -destination = Path(sys.argv[2]).resolve() -with tarfile.open(archive, "r:gz") as bundle: - for member in bundle.getmembers(): - target = (destination / member.name).resolve() - if destination != target and destination not in target.parents: - raise SystemExit(f"Unsafe archive member: {member.name}") - if member.issym() or member.islnk() or member.isdev(): - raise SystemExit(f"Unsupported archive member: {member.name}") - bundle.extractall(destination) - -manifest_path = destination / "manifest.json" -if not manifest_path.is_file(): - raise SystemExit("Backup has no manifest.json") -manifest = json.loads(manifest_path.read_text(encoding="utf-8")) -if manifest.get("format_version") != 1: - raise SystemExit(f"Unsupported backup format: {manifest.get('format_version')}") -for relative, expected in manifest.get("files", {}).items(): - path = destination / relative - if not path.is_file(): - raise SystemExit(f"Backup member is missing: {relative}") - actual = hashlib.sha256(path.read_bytes()).hexdigest() - if actual != expected: - raise SystemExit(f"Checksum mismatch: {relative}") - -for name in ("n8n_data", "sftp_data", "sftp_host_keys"): - volume_archive = destination / "volumes" / f"{name}.tar.gz" - try: - volume = tarfile.open(volume_archive, "r:gz") - except (OSError, tarfile.TarError) as exc: - raise SystemExit(f"Invalid volume archive {name}: {exc}") from exc - with volume: - for member in volume.getmembers(): - member_path = Path(member.name) - if member_path.is_absolute() or ".." in member_path.parts: - raise SystemExit(f"Unsafe {name} member: {member.name}") - if member.isdev(): - raise SystemExit(f"Unsupported {name} member: {member.name}") - if member.issym() or member.islnk(): - resolved = posixpath.normpath( - posixpath.join(posixpath.dirname(member.name), member.linkname) - ) - if member.linkname.startswith("/") or resolved == ".." or resolved.startswith("../"): - raise SystemExit(f"Unsafe {name} link: {member.name}") -PY - -for required in \ - volumes/n8n_data.tar.gz \ - volumes/sftp_data.tar.gz \ - volumes/sftp_host_keys.tar.gz \ - config/environment.env \ - config/n8n_owner.env \ - config/sftp_private_key; do - if [[ ! -f "$staging/$required" ]]; then - echo "Backup is missing required member: $required" >&2 - exit 1 - fi -done - -manifest_revision="$(python3 - "$staging/manifest.json" <<'PY' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - print(json.load(handle).get("git_revision", "unknown")) -PY -)" -source_revision="${SYNDICATOR_SOURCE_REVISION:-}" -if [[ -z "$source_revision" ]]; then - source_revision="$(git -C "$SOURCE_ROOT" rev-parse HEAD 2>/dev/null || printf 'unknown')" -fi -if [[ "$manifest_revision" != "unknown" && \ - "$source_revision" != "$manifest_revision" ]]; then - echo "Backup requires Git revision $manifest_revision." >&2 - echo "Selected source is $source_revision; refusing a mixed-version restore." >&2 - exit 1 -fi -if [[ "${SYNDICATOR_ALLOW_DIRTY:-0}" != "1" ]] && \ - [[ -e "$SOURCE_ROOT/.git" ]] && \ - [[ -n "$(git -C "$SOURCE_ROOT" status --porcelain)" ]]; then - echo "Refusing to restore with a dirty source checkout." >&2 - exit 1 -fi - -target_env_file="$ENV_FILE" -ENV_FILE="$staging/config/environment.env" -load_env -restore_pending="$(release_state_file).restore-pending" -environment_image_tag="${SYNDICATOR_IMAGE_TAG:-}" -archive_release_tag="" -if [[ -f "$staging/config/release.env" ]]; then - archive_release_tag="$(python3 - "$ROOT/scripts" "$staging/config/release.env" <<'PY' -from pathlib import Path -import sys - -sys.path.insert(0, sys.argv[1]) -from dotenv import parse - -print(dict(parse(Path(sys.argv[2]))).get("CURRENT_TAG", "")) -PY -)" -fi -if [[ -n "$forced_image_tag" ]]; then - desired_image_tag="$forced_image_tag" -elif [[ -n "$environment_image_tag" ]]; then - desired_image_tag="$environment_image_tag" -elif [[ -n "$archive_release_tag" ]]; then - desired_image_tag="$archive_release_tag" -else - desired_image_tag="$(git rev-parse --short=12 HEAD 2>/dev/null || printf 'local')" -fi -if [[ ! "$desired_image_tag" =~ ^[a-zA-Z0-9_.-]+$ ]]; then - echo "Backup selected an invalid image tag: $desired_image_tag" >&2 - exit 1 -fi - -if [[ ! -d "$staging/config/sftp_keys" ]]; then - mkdir -p "$staging/config/sftp_keys" - ssh-keygen -y -f "$staging/config/sftp_private_key" \ - >"$staging/config/sftp_keys/n8n.pub" -fi -export N8N_OWNER_ENV_FILE="$staging/config/n8n_owner.env" -export SFTP_KEYS_DIR="$staging/config/sftp_keys" -export SYNDICATOR_IMAGE_TAG="$desired_image_tag" - -if [[ "$build_images" -eq 1 ]]; then - compose build -else - for image in \ - "syndicator-n8n:$desired_image_tag" \ - "syndicator-pyautoflip:$desired_image_tag"; do - if ! docker image inspect "$image" >/dev/null 2>&1; then - echo "Required restore image is missing: $image" >&2 - exit 1 - fi - done -fi - -compose stop n8n sftp pyautoflip >/dev/null -for service in n8n sftp pyautoflip; do - if [[ -n "$(compose ps --status running -q "$service")" ]]; then - echo "Service did not stop before restore: $service" >&2 - exit 1 - fi -done - -mkdir -p "$(dirname "$restore_pending")" -umask 077 -{ - printf 'RESTORE_ARCHIVE=%q\n' "$archive" - printf 'RESTORE_SOURCE_REVISION=%q\n' "$source_revision" - printf 'RESTORE_IMAGE_TAG=%q\n' "$desired_image_tag" -} >"$restore_pending" -chmod 600 "$restore_pending" -restore_mutated=1 - -ENV_FILE="$target_env_file" -mkdir -p "$(dirname "$ENV_FILE")" -cp "$staging/config/environment.env" "$ENV_FILE" -chmod 600 "$ENV_FILE" -unset N8N_OWNER_ENV_FILE SFTP_KEYS_DIR SYNDICATOR_IMAGE_TAG -load_env - -restore_file() { - local name="$1" - local target="$2" - mkdir -p "$(dirname "$target")" - if [[ -f "$staging/config/$name" ]]; then - cp "$staging/config/$name" "$target" - chmod 600 "$target" - else - rm -f "$target" - fi -} - -owner_env="$(resolve_from_root "${N8N_OWNER_ENV_FILE:-secrets/n8n_owner.env}")" -api_key="$(resolve_from_root "${N8N_API_KEY_FILE:-secrets/n8n_api_key}")" -bootstrap_state="$(resolve_from_root "${N8N_BOOTSTRAP_STATE_FILE:-secrets/bootstrap.sha256}")" -private_key="$(resolve_from_root "${SFTP_PRIVATE_KEY_FILE:-secrets/sftp_n8n_ed25519}")" -keys_dir="$(resolve_from_root "${SFTP_KEYS_DIR:-sftp/keys}")" -release_state="$(release_state_file)" - -restore_file n8n_owner.env "$owner_env" -restore_file n8n_api_key "$api_key" -restore_file bootstrap.sha256 "$bootstrap_state" -restore_file sftp_private_key "$private_key" -restore_file release.env "$release_state" -if [[ ! -s "$owner_env" || ! -s "$private_key" ]]; then - echo "Backup is missing required owner or SFTP credentials." >&2 - exit 1 -fi - -rm -rf "$keys_dir" -mkdir -p "$(dirname "$keys_dir")" -if [[ -d "$staging/config/sftp_keys" ]]; then - cp -Rp "$staging/config/sftp_keys" "$keys_dir" -fi - -export N8N_OWNER_ENV_FILE="$owner_env" -export SFTP_KEYS_DIR="$keys_dir" -export SYNDICATOR_IMAGE_TAG="$desired_image_tag" - -for volume in n8n_data sftp_data sftp_host_keys; do - echo "Restoring volume $volume..." - # shellcheck disable=SC2016 - compose run --rm --no-deps --user root \ - -e "RESTORE_VOLUME=$volume" \ - -v "$staging/volumes:/backup:ro" \ - --entrypoint sh volume-tool -c ' - target="/volumes/${RESTORE_VOLUME}" && - rm -rf "$target"/* "$target"/.[!.]* "$target"/..?* && - tar -xzf "/backup/${RESTORE_VOLUME}.tar.gz" -C "$target" - ' >/dev/null -done - -compose up -d --remove-orphans -if [[ "${SYNDICATOR_TEST_FAIL_RESTORE_AFTER_START:-0}" == "1" ]]; then - echo "Deliberate post-start restore failure requested by integration test." >&2 - false -fi -"$ROOT/scripts/bootstrap-n8n.sh" -"$ROOT/scripts/verify.sh" - -unset CURRENT_TAG PREVIOUS_TAG ROLLBACK_BACKUP \ - CURRENT_GIT_REVISION PREVIOUS_GIT_REVISION \ - CURRENT_SOURCE_ROOT PREVIOUS_SOURCE_ROOT -load_release_state -restored_current_tag="${CURRENT_TAG:-$desired_image_tag}" -restored_current_revision="${CURRENT_GIT_REVISION:-$manifest_revision}" -restored_previous_tag="${PREVIOUS_TAG:-}" -restored_previous_revision="${PREVIOUS_GIT_REVISION:-}" -restored_rollback_backup="${ROLLBACK_BACKUP:-}" -restored_current_source="$(materialize_release_source "$restored_current_revision")" -restored_previous_source="" -if [[ -n "$restored_previous_revision" ]] && \ - git cat-file -e "${restored_previous_revision}^{commit}" 2>/dev/null; then - restored_previous_source="$(materialize_release_source "$restored_previous_revision")" -fi -write_release_state \ - "$restored_current_tag" \ - "$restored_previous_tag" \ - "$restored_rollback_backup" \ - "$restored_current_revision" \ - "$restored_previous_revision" \ - "$restored_current_source" \ - "$restored_previous_source" -rm -f "$restore_pending" -restore_mutated=0 -echo "Restore from $archive completed." diff --git a/scripts/rollback.sh b/scripts/rollback.sh deleted file mode 100755 index a6b65b8..0000000 --- a/scripts/rollback.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# shellcheck source=scripts/lib.sh -source "$ROOT/scripts/lib.sh" - -if [[ "$#" -ne 0 ]]; then - echo "Usage: $0" >&2 - exit 2 -fi - -load_env -load_release_state -current_tag="${CURRENT_TAG:-}" -previous_tag="${PREVIOUS_TAG:-}" -rollback_backup="${ROLLBACK_BACKUP:-}" -current_revision="${CURRENT_GIT_REVISION:-${DEPLOYED_GIT_REVISION:-}}" -previous_revision="${PREVIOUS_GIT_REVISION:-}" -current_source_root="${CURRENT_SOURCE_ROOT:-}" -previous_source_root="${PREVIOUS_SOURCE_ROOT:-}" - -if [[ -z "$current_tag" || -z "$previous_tag" || -z "$rollback_backup" || \ - -z "$previous_revision" ]]; then - echo "No complete previous release and backup are recorded." >&2 - exit 1 -fi -checkout_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" -if [[ "$checkout_revision" != "$current_revision" && \ - "$checkout_revision" != "$previous_revision" ]]; then - echo "Checkout $checkout_revision is unrelated to the retained releases." >&2 - exit 1 -fi -if [[ "${SYNDICATOR_ALLOW_DIRTY:-0}" != "1" ]] && \ - [[ -n "$(git status --porcelain)" ]]; then - echo "Refusing to roll back from a dirty working tree." >&2 - exit 1 -fi -if [[ -z "$current_source_root" || ! -d "$current_source_root" ]]; then - current_source_root="$(materialize_release_source "$current_revision")" -fi -if [[ -z "$previous_source_root" || ! -d "$previous_source_root" ]]; then - previous_source_root="$(materialize_release_source "$previous_revision")" -fi -if [[ ! -f "$rollback_backup" ]]; then - echo "Recorded rollback backup is missing: $rollback_backup" >&2 - exit 1 -fi -for image in "syndicator-n8n:$previous_tag" "syndicator-pyautoflip:$previous_tag"; do - if ! docker image inspect "$image" >/dev/null 2>&1; then - echo "Previous release image is missing: $image" >&2 - exit 1 - fi -done - -export SYNDICATOR_IMAGE_TAG="$current_tag" -export SYNDICATOR_SOURCE_ROOT="$current_source_root" -SOURCE_ROOT="$current_source_root" -backup_dir="$(resolve_from_root "${SYNDICATOR_BACKUP_DIR:-backups}")" -forward_backup="$backup_dir/pre-rollback-${current_tag}-to-${previous_tag}-$(date -u +%Y%m%dT%H%M%SZ).tar.gz" -"$ROOT/scripts/backup.sh" --output "$forward_backup" - -if [[ ! -f "$previous_source_root/docker-compose.yml" ]]; then - echo "Previous revision has no deployable Compose definition." >&2 - exit 1 -fi - -export SYNDICATOR_IMAGE_TAG="$previous_tag" -export SYNDICATOR_SOURCE_ROOT="$previous_source_root" -SOURCE_ROOT="$previous_source_root" -export SYNDICATOR_SOURCE_REVISION="$previous_revision" -"$ROOT/scripts/restore.sh" --yes --no-build "$rollback_backup" -write_release_state \ - "$previous_tag" \ - "$current_tag" \ - "$forward_backup" \ - "$previous_revision" \ - "$current_revision" \ - "$previous_source_root" \ - "$current_source_root" - -echo "Rolled back from $current_tag to $previous_tag." diff --git a/scripts/update.sh b/scripts/update.sh deleted file mode 100755 index 982d4de..0000000 --- a/scripts/update.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -# Deploy the reviewed, pinned sources in the current checkout. -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -exec "$ROOT/scripts/deploy.sh" --pull "$@" diff --git a/tests/integration/stack.sh b/tests/integration/stack.sh index ff72c48..bb4dd2e 100755 --- a/tests/integration/stack.sh +++ b/tests/integration/stack.sh @@ -43,9 +43,7 @@ SFTP_USERNAME=sftp SFTP_PRIVATE_KEY_FILE=$tmp/sftp_n8n_ed25519 SFTP_KEYS_DIR=$tmp/keys PYAUTOFLIP_WARM_MODELS=0 -SYNDICATOR_BACKUP_DIR=$tmp/backups SYNDICATOR_RELEASE_STATE_FILE=$tmp/release.env -SYNDICATOR_RELEASE_SOURCES_DIR=$tmp/release-sources SYNDICATOR_ALLOW_DIRTY=1 EOF chmod 600 "$env_file" @@ -70,7 +68,7 @@ trap cleanup EXIT test_failed_deployment() { printf '%s\n' 'SYNDICATOR_TEST_FAIL_AFTER_START=1' >>"$env_file" set +e - "$ROOT/bin/syndicator" update --tag integration-failure \ + "$ROOT/bin/syndicator" deploy --tag integration-failure \ >"$tmp/failed-deploy.log" 2>&1 failed_status=$? set -e @@ -143,8 +141,9 @@ if actual != expected: printf '%s\n' "integration payload" >"$tmp/upload.txt" ssh-keyscan -p "$sftp_port" 127.0.0.1 >"$tmp/known_hosts" 2>/dev/null cat >"$tmp/sftp.batch" <"$tmp/sftp-remove.batch" <"$tmp/failed-restore.log" 2>&1 -failed_restore_status=$? -set -e -if [[ "$failed_restore_status" -eq 0 || \ - ! -s "$tmp/release.env.restore-pending" ]]; then - echo "Failed restore was not contained and recorded." >&2 - exit 1 -fi -if [[ -n "$(docker compose --env-file "$env_file" -p "$project" \ - ps --status running -q n8n)" ]]; then - echo "Failed restore left unverified n8n running." >&2 - exit 1 -fi - -"$ROOT/bin/syndicator" restore --yes --no-build "$backup_archive" -cat >"$tmp/sftp-restored.batch" <&2 - exit 1 -fi - test_failed_deployment echo "Isolated stack integration test passed." diff --git a/tests/test_repository.py b/tests/test_repository.py index 98da918..dd39c53 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -149,7 +149,7 @@ def test_runtime_dependencies_are_locked(self) -> None: self.assertNotIn(":stable", runtime_config) self.assertGreaterEqual( len(re.findall(r"@sha256:[0-9a-f]{64}", runtime_config)), - 6, + 5, "container bases should be immutable by default", ) self.assertNotRegex(runtime_config, r"ARG [A-Z0-9_]+_IMAGE=") From 69f4efe309e1e4b80412690aff101572723303ee Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Thu, 13 Aug 2026 19:49:49 +0200 Subject: [PATCH 09/16] Move n8n credential and workflow reconcile into Compose. Host-side API-key login is replaced by an in-container n8n-reconcile service so instantiate no longer copies files into n8n or persist bootstrap keys. Co-authored-by: Cursor --- .env.example | 7 +- README.md | 14 +- bin/syndicator | 10 +- docker-compose.yml | 25 ++ docs/adr/0002-disposable-instances.md | 2 +- docs/operations.md | 10 +- n8n/.dockerignore | 3 + n8n/Dockerfile | 6 +- n8n/reconcile.js | 328 +++++++++++++++++++++++++ scripts/bootstrap-n8n.sh | 338 -------------------------- scripts/deploy.sh | 3 +- scripts/lib.sh | 4 + scripts/verify.sh | 37 +-- tests/integration/stack.sh | 26 -- tests/test_repository.py | 12 +- tests/validate-compose.sh | 3 + 16 files changed, 399 insertions(+), 429 deletions(-) create mode 100644 n8n/reconcile.js delete mode 100755 scripts/bootstrap-n8n.sh diff --git a/.env.example b/.env.example index 82937e6..1956102 100644 --- a/.env.example +++ b/.env.example @@ -21,17 +21,12 @@ N8N_ENCRYPTION_KEY= # Instance owner (provisioned via N8N_INSTANCE_OWNER_* on n8n start). # `bin/syndicator init` hashes the password into the Compose owner env file. +# The n8n-reconcile service logs in with these values to import workflows. N8N_OWNER_EMAIL= N8N_OWNER_PASSWORD= # N8N_OWNER_FIRST_NAME=Syndicator # N8N_OWNER_LAST_NAME=Owner -# Optional override for workflow publish. Bootstrap otherwise preserves a -# generated key in secrets/n8n_api_key (label syndicator-bootstrap). -# N8N_API_KEY= -# N8N_API_KEY_FILE=secrets/n8n_api_key -# N8N_BOOTSTRAP_STATE_FILE=secrets/bootstrap.sha256 - # --- SFTP (published to host; internal compose hostname is always "sftp") --- SFTP_BIND_ADDRESS=127.0.0.1 SFTP_PUBLISH_PORT=2222 diff --git a/README.md b/README.md index 7729b8b..87e8244 100644 --- a/README.md +++ b/README.md @@ -161,9 +161,9 @@ bin/syndicator init bin/syndicator deploy ``` -`deploy` checks prerequisites, generates local-only keys, builds and starts the stack, reconciles n8n credentials/workflows, and verifies n8n, pyautoflip, and SFTP. It is safe to run repeatedly; an unchanged bootstrap is skipped. +`deploy` checks prerequisites, generates local-only keys, builds and starts the stack, reconciles n8n credentials/workflows inside Compose, and verifies n8n, pyautoflip, and SFTP. It is safe to run repeatedly; an unchanged bootstrap is skipped. -Owner account is provisioned from env on n8n start (`N8N_INSTANCE_OWNER_*`). Bootstrap logs in with `N8N_OWNER_EMAIL` / `N8N_OWNER_PASSWORD` to create or reuse an API key at `secrets/n8n_api_key` (or uses `N8N_API_KEY` if set), then imports credentials/workflows and publishes webhooks. UI login uses the same owner credentials. +Owner account is provisioned from env on n8n start (`N8N_INSTANCE_OWNER_*`). After n8n is healthy, the `n8n-reconcile` service logs in with `N8N_OWNER_EMAIL` / `N8N_OWNER_PASSWORD`, imports credentials and workflows from git, and publishes webhooks. UI login uses the same owner credentials. `init` writes `secrets/sftp_n8n_ed25519` (private), `sftp/keys/n8n.pub` (public), and `secrets/n8n_owner.env` (bcrypt hash for Compose). Extra client keys: copy any `.pub` into `sftp/keys/` and run `bin/syndicator restart sftp`. Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`. @@ -195,13 +195,14 @@ Software design is split into **instantiation** (how an instance is built and st ### Instantiation -The repo is the blueprint for a containerized instance: Compose defines the stack, scripts bootstrap credentials and import workflows, and the rest is source material those steps consume. +The repo is the blueprint for a containerized instance: Compose defines the stack, an in-container reconcile step imports credentials and workflows, and the rest is source material those steps consume. | Piece | Role | |-------|------| -| `docker-compose.yml` | Compose stack: files-init + SFTP + n8n + pyautoflip | +| `docker-compose.yml` | Compose stack: files-init + SFTP + n8n + n8n-reconcile + pyautoflip | | `.env.example` | Env template for secrets and host paths | -| `n8n/Dockerfile` | Custom n8n image (`ffmpeg` + community node seed) | +| `n8n/Dockerfile` | Custom n8n image (`ffmpeg` + community node seed + reconcile) | +| `n8n/reconcile.js` | In-container credential/workflow import and webhook publish | | `sftp/setup.sh` | Supported atmoz startup hook for durable host keys, key sync, and ownership | | `scripts/` | Focused lifecycle implementations behind `bin/syndicator` | | `n8n/workflows/` | Importable workflow exports (source of truth) | @@ -214,11 +215,12 @@ The repo is the blueprint for a containerized instance: Compose defines the stac docker-compose.yml .env.example n8n/Dockerfile +n8n/reconcile.js n8n/workflows/ n8n/credentials/*.template.json pyautoflip/ sftp/ -scripts/{init,deploy,bootstrap,verify,export}.sh +scripts/{init,deploy,verify,export}.sh docs/{operations.md,adr/} bin/syndicator ``` diff --git a/bin/syndicator b/bin/syndicator index d7e6c7a..71d4f7f 100755 --- a/bin/syndicator +++ b/bin/syndicator @@ -12,9 +12,9 @@ Usage: bin/syndicator Lifecycle: doctor Check host prerequisites init Create and validate local configuration and keys - deploy Build, start, bootstrap, and verify the stack - bootstrap Reconcile n8n credentials and workflows - verify Verify health, workflows, pyautoflip, and SFTP + deploy Build, start, reconcile, and verify the stack + bootstrap Re-run in-container n8n credential and workflow reconcile + verify Verify health, reconcile, pyautoflip, and SFTP export Export sanitized workflows from n8n restart Restart one or more services, then verify status Show Compose service status @@ -36,7 +36,9 @@ case "$command" in exec "$ROOT/scripts/deploy.sh" "$@" ;; bootstrap) - exec "$ROOT/scripts/bootstrap-n8n.sh" "$@" + load_env + wait_for_n8n + run_reconcile ;; verify) exec "$ROOT/scripts/verify.sh" "$@" diff --git a/docker-compose.yml b/docker-compose.yml index 3640a9a..d910007 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -97,6 +97,31 @@ services: retries: 5 start_period: 60s + n8n-reconcile: + build: ./n8n + image: syndicator-n8n:${SYNDICATOR_IMAGE_TAG:-local} + restart: "no" + profiles: ["reconcile"] + user: "node" + depends_on: + n8n: + condition: service_healthy + environment: + N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY:?set N8N_ENCRYPTION_KEY in .env} + N8N_OWNER_EMAIL: ${N8N_OWNER_EMAIL:?set N8N_OWNER_EMAIL in .env} + N8N_OWNER_PASSWORD: ${N8N_OWNER_PASSWORD:?set N8N_OWNER_PASSWORD in .env} + OPENAI_API_KEY: ${OPENAI_API_KEY:?set OPENAI_API_KEY in .env} + POSTIZ_API_KEY: ${POSTIZ_API_KEY:?set POSTIZ_API_KEY in .env} + SFTP_HOST: ${SFTP_HOST:-sftp} + SFTP_USERNAME: ${SFTP_USERNAME:-sftp} + N8N_INTERNAL_URL: http://n8n:5678 + volumes: + - n8n_data:/home/node/.n8n + - ./n8n/workflows:/opt/syndicator/workflows:ro + - ./n8n/credentials:/opt/syndicator/credentials:ro + - ${SFTP_PRIVATE_KEY_FILE:-./secrets/sftp_n8n_ed25519}:/run/secrets/sftp_private_key:ro + entrypoint: ["node", "/opt/syndicator/reconcile.js"] + pyautoflip: build: context: ./pyautoflip diff --git a/docs/adr/0002-disposable-instances.md b/docs/adr/0002-disposable-instances.md index 77d95a6..acb4e8e 100644 --- a/docs/adr/0002-disposable-instances.md +++ b/docs/adr/0002-disposable-instances.md @@ -11,7 +11,7 @@ That assumed n8n SQLite, SFTP uploads, and host keys were unique state that had to survive a host loss or a bad update. Workflows, credentials, and webhook paths are already reconstructed from git -and `.env` by `init` / `deploy` / `bootstrap`. Uploaded SFTP files can be +and `.env` by `init` / `deploy` / in-container reconcile. Uploaded SFTP files can be re-provided by callers. The remaining identity is `.env`, SFTP host keys, and authorized client keys — which belong next to other host secrets, not inside the application lifecycle. diff --git a/docs/operations.md b/docs/operations.md index 12d2f5b..4e5d46c 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -46,7 +46,7 @@ bin/syndicator deploy ``` `deploy` performs initialization and diagnostics again, builds immutable -inputs, starts the stack, reconciles n8n credentials and workflows, and runs +inputs, starts the stack, waits for the in-container n8n reconcile, and runs end-to-end health checks. Running it again is safe. If source configuration is unchanged and all workflows remain published, n8n import is skipped. @@ -200,15 +200,15 @@ bash tests/integration/stack.sh CI first builds the production model-warmed image and performs a real reframe. The stack integration test uses random loopback ports and a unique Compose -project. It deploys twice, checks that API keys and resources are not -duplicated, uploads over SFTP, and removes all test containers and volumes. A +project. It deploys twice, checks that an unchanged reconcile is skipped, +uploads over SFTP, and removes all test containers and volumes. A deliberately failed release also verifies that unverified containers are stopped and pending recovery state is recorded. A separate Buildx job verifies n8n and pyautoflip for Linux arm64. ## Troubleshooting -If bootstrap reports n8n as unavailable, inspect readiness and logs: +If n8n is unavailable during reconcile, inspect readiness and logs: ```bash bin/syndicator status @@ -224,7 +224,7 @@ in `sftp_host_keys` and survive container recreate, but not a volume wipe. If credentials cannot be decrypted, `N8N_ENCRYPTION_KEY` in `.env` does not match the existing `n8n_data` volume. Use the original key, or remove the -volume and let bootstrap recreate credentials. +volume and let reconcile recreate credentials. If Apple Silicon reports an SFTP platform warning, confirm `SFTP_PLATFORM=linux/amd64`; the image is intentionally emulated. diff --git a/n8n/.dockerignore b/n8n/.dockerignore index 4df0d62..0f6285c 100644 --- a/n8n/.dockerignore +++ b/n8n/.dockerignore @@ -1,5 +1,8 @@ * !Dockerfile !entrypoint.sh +!reconcile.js !package.json !package-lock.json +!workflows/*.json +!credentials/*.template.json diff --git a/n8n/Dockerfile b/n8n/Dockerfile index 9f71011..bdf2ed3 100644 --- a/n8n/Dockerfile +++ b/n8n/Dockerfile @@ -17,7 +17,11 @@ RUN npm ci --omit=dev \ && chown -R node:node /opt/n8n-nodes-seed COPY entrypoint.sh /entrypoint-syndicator.sh -RUN chmod +x /entrypoint-syndicator.sh +COPY reconcile.js /opt/syndicator/reconcile.js +COPY workflows /opt/syndicator/workflows +COPY credentials /opt/syndicator/credentials +RUN chmod +x /entrypoint-syndicator.sh \ + && chown -R node:node /opt/syndicator USER node WORKDIR /home/node diff --git a/n8n/reconcile.js b/n8n/reconcile.js new file mode 100644 index 0000000..9502bff --- /dev/null +++ b/n8n/reconcile.js @@ -0,0 +1,328 @@ +#!/usr/bin/env node +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const BUNDLE = process.env.SYNDICATOR_BUNDLE || "/opt/syndicator"; +const STATE_FILE = + process.env.SYNDICATOR_RECONCILE_STATE || + "/home/node/.n8n/.syndicator-reconcile.sha256"; +const N8N_BASE = (process.env.N8N_INTERNAL_URL || "http://n8n:5678").replace( + /\/$/, + "", +); +const KEY_FILE = + process.env.SFTP_PRIVATE_KEY_FILE || "/run/secrets/sftp_private_key"; + +function fail(message) { + console.error(message); + process.exit(1); +} + +function runN8n(args) { + const result = spawnSync("n8n", args, { encoding: "utf8" }); + if (result.status !== 0) { + fail(`n8n ${args.join(" ")} failed:\n${result.stderr || result.stdout}`); + } + return result.stdout; +} + +function loadPrivateKey() { + if (process.env.SFTP_PRIVATE_KEY) { + return process.env.SFTP_PRIVATE_KEY; + } + if (!fs.existsSync(KEY_FILE)) { + fail(`Missing SFTP private key: ${KEY_FILE}`); + } + return fs.readFileSync(KEY_FILE, "utf8"); +} + +function listBundle(kind, suffix) { + const dir = path.join(BUNDLE, kind); + return fs + .readdirSync(dir) + .filter((name) => name.endsWith(suffix)) + .sort() + .map((name) => path.join(dir, name)); +} + +function fingerprint() { + const digest = crypto.createHash("sha256"); + for (const filePath of [ + ...listBundle("credentials", ".template.json"), + ...listBundle("workflows", ".json"), + ]) { + digest.update(path.relative(BUNDLE, filePath)); + digest.update(fs.readFileSync(filePath)); + } + for (const name of [ + "N8N_ENCRYPTION_KEY", + "OPENAI_API_KEY", + "POSTIZ_API_KEY", + "SFTP_HOST", + "SFTP_USERNAME", + "SFTP_PRIVATE_KEY", + ]) { + digest.update(name); + digest.update(process.env[name] || ""); + } + return digest.digest("hex"); +} + +function workflowFiles() { + const files = listBundle("workflows", ".json"); + const workflows = new Map( + files.map((filePath) => [ + filePath, + JSON.parse(fs.readFileSync(filePath, "utf8")), + ]), + ); + const byId = new Map( + [...workflows].map(([filePath, workflow]) => [workflow.id, filePath]), + ); + + function reference(node) { + const value = node.parameters && node.parameters.workflowId; + if (typeof value === "string") { + return value; + } + if (value && typeof value.value === "string") { + return value.value; + } + return null; + } + + const ordered = []; + const visiting = new Set(); + const visited = new Set(); + + function visit(filePath) { + if (visited.has(filePath)) { + return; + } + if (visiting.has(filePath)) { + fail(`workflow dependency cycle at ${path.basename(filePath)}`); + } + visiting.add(filePath); + const dependencies = new Set(); + for (const node of workflows.get(filePath).nodes || []) { + const dependency = reference(node); + if (dependency) { + dependencies.add(dependency); + } + } + for (const dependency of [...dependencies].sort()) { + if (!byId.has(dependency)) { + fail( + `${path.basename(filePath)} references unknown workflow ${dependency}`, + ); + } + visit(byId.get(dependency)); + } + visiting.delete(filePath); + visited.add(filePath); + ordered.push(filePath); + } + + for (const filePath of files) { + visit(filePath); + } + return ordered; +} + +function renderCredential(templatePath) { + const raw = fs.readFileSync(templatePath, "utf8"); + const rendered = raw.replace(/\$\{([A-Z0-9_]+)\}/g, (_, key) => { + if (process.env[key] === undefined) { + fail(`Missing environment value for template: ${key}`); + } + return JSON.stringify(process.env[key]).slice(1, -1); + }); + JSON.parse(rendered); + return rendered; +} + +function cookieHeader(res) { + const raw = + typeof res.headers.getSetCookie === "function" + ? res.headers.getSetCookie() + : []; + const header = res.headers.get("set-cookie"); + const list = raw.length ? raw : header ? [header] : []; + return list.map((item) => item.split(";")[0]).join("; "); +} + +async function request(url, { method = "GET", headers = {}, body, cookie } = {}) { + const res = await fetch(url, { + method, + headers: { + ...headers, + ...(cookie ? { Cookie: cookie } : {}), + }, + body, + }); + const text = await res.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { res, text, json, cookie: cookieHeader(res) || cookie || "" }; +} + +async function login() { + const { res, json, text, cookie } = await request(`${N8N_BASE}/rest/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + emailOrLdapLoginId: process.env.N8N_OWNER_EMAIL, + password: process.env.N8N_OWNER_PASSWORD, + }), + }); + if (!res.ok) { + fail(`n8n login failed (HTTP ${res.status}): ${text}`); + } + const data = (json && json.data) || json || {}; + if (!data.id) { + fail(`Login response has no owner id: ${text}`); + } + return { userId: data.id, cookie }; +} + +async function rest(cookie, method, urlPath, body) { + return request(`${N8N_BASE}${urlPath}`, { + method, + headers: body ? { "Content-Type": "application/json" } : {}, + body: body ? JSON.stringify(body) : undefined, + cookie, + }); +} + +async function publish(cookie, id) { + const cli = spawnSync("n8n", ["publish:workflow", `--id=${id}`], { + encoding: "utf8", + }); + if (cli.status === 0) { + return; + } + + for (const urlPath of [ + `/rest/workflows/${id}/publish`, + `/rest/workflows/${id}/activate`, + `/api/v1/workflows/${id}/publish`, + `/api/v1/workflows/${id}/activate`, + ]) { + const result = await rest(cookie, "POST", urlPath); + if (result.res.status === 200) { + return; + } + } + fail( + `Failed to publish workflow ${id} (CLI: ${cli.stderr || cli.stdout})`, + ); +} + +function same(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +async function workflowMatches(cookie, id, sourcePath) { + let payload = null; + for (const urlPath of [`/rest/workflows/${id}`, `/api/v1/workflows/${id}`]) { + const result = await rest(cookie, "GET", urlPath); + if (result.res.status === 200) { + payload = (result.json && result.json.data) || result.json; + break; + } + } + if (!payload) { + return false; + } + const desired = JSON.parse(fs.readFileSync(sourcePath, "utf8")); + if (payload.active !== true) { + return false; + } + return ["name", "nodes", "connections", "settings", "staticData"].every( + (key) => same(payload[key], desired[key]), + ); +} + +async function allWorkflowsCurrent(cookie, files) { + for (const filePath of files) { + const id = JSON.parse(fs.readFileSync(filePath, "utf8")).id; + if (!(await workflowMatches(cookie, id, filePath))) { + return false; + } + } + return true; +} + +async function main() { + for (const name of [ + "N8N_ENCRYPTION_KEY", + "N8N_OWNER_EMAIL", + "N8N_OWNER_PASSWORD", + "OPENAI_API_KEY", + "POSTIZ_API_KEY", + "SFTP_HOST", + "SFTP_USERNAME", + ]) { + if (!process.env[name]) { + fail(`Missing required environment value: ${name}`); + } + } + + process.env.SFTP_PRIVATE_KEY = loadPrivateKey(); + const files = workflowFiles(); + if (!files.length) { + fail(`No workflow exports found under ${BUNDLE}`); + } + + const digest = fingerprint(); + const { userId, cookie } = await login(); + if ( + fs.existsSync(STATE_FILE) && + fs.readFileSync(STATE_FILE, "utf8").trim() === digest && + (await allWorkflowsCurrent(cookie, files)) + ) { + console.log("n8n bootstrap is already current."); + return; + } + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "syndicator-")); + try { + console.log(`Importing credentials for owner ${userId}...`); + for (const templatePath of listBundle("credentials", ".template.json")) { + const out = path.join( + tmp, + path.basename(templatePath, ".template.json") + ".json", + ); + fs.writeFileSync(out, renderCredential(templatePath)); + runN8n(["import:credentials", `--input=${out}`, `--userId=${userId}`]); + } + + console.log("Importing and publishing workflows..."); + for (const filePath of files) { + const id = JSON.parse(fs.readFileSync(filePath, "utf8")).id; + runN8n(["import:workflow", `--input=${filePath}`, `--userId=${userId}`]); + await publish(cookie, id); + } + + if (!(await allWorkflowsCurrent(cookie, files))) { + fail("At least one imported workflow differs from source or is inactive."); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + + fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); + fs.writeFileSync(STATE_FILE, `${digest}\n`); + console.log("n8n bootstrap complete."); +} + +main().catch((error) => fail(error.stack || String(error))); diff --git a/scripts/bootstrap-n8n.sh b/scripts/bootstrap-n8n.sh deleted file mode 100755 index 8a32f7b..0000000 --- a/scripts/bootstrap-n8n.sh +++ /dev/null @@ -1,338 +0,0 @@ -#!/usr/bin/env bash -# Idempotently import credentials and workflows into a running n8n instance. -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# shellcheck source=scripts/lib.sh -source "$ROOT/scripts/lib.sh" - -load_env -for name in \ - N8N_ENCRYPTION_KEY \ - N8N_OWNER_EMAIL \ - N8N_OWNER_PASSWORD \ - OPENAI_API_KEY \ - POSTIZ_API_KEY \ - SFTP_HOST \ - SFTP_USERNAME; do - need_env "$name" -done - -WORKFLOW_FILES=() -while IFS= read -r file; do - WORKFLOW_FILES+=("$file") -done < <(python3 "$ROOT/scripts/workflow_order.py" "$SOURCE_ROOT") -if [[ -z "${WORKFLOW_FILES[*]-}" ]]; then - echo "No workflow exports found under $SOURCE_ROOT." >&2 - exit 1 -fi - -if [[ -z "${SFTP_PRIVATE_KEY:-}" ]]; then - key_file="$(resolve_from_root "${SFTP_PRIVATE_KEY_FILE:-secrets/sftp_n8n_ed25519}")" - if [[ ! -f "$key_file" ]]; then - echo "Missing SFTP private key: $key_file" >&2 - exit 1 - fi - SFTP_PRIVATE_KEY="$(<"$key_file")" - export SFTP_PRIVATE_KEY -fi - -wait_for_n8n - -N8N_BASE="http://127.0.0.1:${N8N_HOST_PORT:-5678}" -API_KEY_LABEL="syndicator-bootstrap" -API_KEY_FILE="$(resolve_from_root "${N8N_API_KEY_FILE:-secrets/n8n_api_key}")" -STATE_FILE="$(resolve_from_root "${N8N_BOOTSTRAP_STATE_FILE:-secrets/bootstrap.sha256}")" -TMP_DIR="$(mktemp -d)" -COOKIE_JAR="$TMP_DIR/n8n-cookies.txt" -LOGIN_BODY="$TMP_DIR/login.json" -trap 'rm -rf "$TMP_DIR"' EXIT - -login_n8n() { - if [[ -s "$LOGIN_BODY" ]]; then - return - fi - - local code - code="$(curl -sS -o "$LOGIN_BODY" -w '%{http_code}' \ - -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ - -X POST \ - -H 'Content-Type: application/json' \ - -d "$(python3 -c 'import json,os; print(json.dumps({"emailOrLdapLoginId":os.environ["N8N_OWNER_EMAIL"],"password":os.environ["N8N_OWNER_PASSWORD"]}))')" \ - "${N8N_BASE}/rest/login" || true)" - if [[ "$code" != "200" ]]; then - echo "n8n login failed (HTTP $code): $(<"$LOGIN_BODY")" >&2 - exit 1 - fi -} - -owner_user_id() { - login_n8n - python3 - "$LOGIN_BODY" <<'PY' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - body = json.load(handle) -data = body.get("data", body) -user_id = data.get("id", "") -if not user_id: - raise SystemExit(f"Login response has no owner id: {body!r}") -print(user_id) -PY -} - -api_key_valid() { - local key="$1" - local code - code="$(curl -sS -o /dev/null -w '%{http_code}' \ - -H "X-N8N-API-KEY: $key" \ - "${N8N_BASE}/api/v1/workflows?limit=1" || true)" - [[ "$code" == "200" ]] -} - -provision_api_key() { - login_n8n - - local scopes_json key_id raw_key create_body - scopes_json="$(curl -fsS -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ - "${N8N_BASE}/rest/api-keys/scopes")" - scopes_json="$(python3 -c ' -import json,sys -body=json.load(sys.stdin) -scopes=body.get("data", body) -if not isinstance(scopes, list): - raise SystemExit(f"Unexpected scopes response: {body!r}") -print(json.dumps(scopes)) -' <<<"$scopes_json")" - - key_id="$(curl -fsS -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ - --get \ - --data-urlencode "label=${API_KEY_LABEL}" \ - --data-urlencode "ownership=mine" \ - --data-urlencode "take=50" \ - "${N8N_BASE}/rest/api-keys" | python3 -c ' -import json,sys -body=json.load(sys.stdin) -payload=body.get("data", body) -items=payload.get("items", payload.get("data", [])) if isinstance(payload, dict) else payload -for item in items or []: - if item.get("label")==sys.argv[1]: - print(item.get("id","")) - break -' "$API_KEY_LABEL")" - - if [[ -n "$key_id" ]]; then - echo "Replacing inaccessible bootstrap API key..." - raw_key="$(curl -fsS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \ - "${N8N_BASE}/rest/api-keys/${key_id}/rotate" | python3 -c ' -import json,sys -body=json.load(sys.stdin) -data=body.get("data", body) -key=data.get("rawApiKey") or data.get("apiKey") or "" -if not key or key.startswith("*"): - raise SystemExit(f"Rotate did not return a raw API key: {body!r}") -print(key) -')" - else - echo "Creating bootstrap API key..." - create_body="$(python3 -c ' -import json,sys -print(json.dumps({"label":sys.argv[2],"expiresAt":None,"scopes":json.loads(sys.argv[1])})) -' "$scopes_json" "$API_KEY_LABEL")" - raw_key="$(curl -fsS -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ - -X POST \ - -H 'Content-Type: application/json' \ - -d "$create_body" \ - "${N8N_BASE}/rest/api-keys" | python3 -c ' -import json,sys -body=json.load(sys.stdin) -data=body.get("data", body) -key=data.get("rawApiKey") or "" -if not key: - raise SystemExit(f"Create did not return a raw API key: {body!r}") -print(key) -')" - fi - - mkdir -p "$(dirname "$API_KEY_FILE")" - umask 077 - printf '%s\n' "$raw_key" >"$API_KEY_FILE" - chmod 600 "$API_KEY_FILE" - N8N_API_KEY="$raw_key" - export N8N_API_KEY -} - -ensure_api_key() { - if [[ -n "${N8N_API_KEY:-}" ]]; then - if ! api_key_valid "$N8N_API_KEY"; then - echo "N8N_API_KEY is set but is not accepted by n8n." >&2 - exit 1 - fi - return - fi - - if [[ -s "$API_KEY_FILE" ]]; then - N8N_API_KEY="$(tr -d '[:space:]' <"$API_KEY_FILE")" - export N8N_API_KEY - if api_key_valid "$N8N_API_KEY"; then - return - fi - unset N8N_API_KEY - fi - - provision_api_key -} - -bootstrap_fingerprint() { - SOURCE_ROOT="$SOURCE_ROOT" python3 - <<'PY' -import glob -import hashlib -import os - -digest = hashlib.sha256() -root = os.environ["SOURCE_ROOT"] -for pattern in ("n8n/credentials/*.template.json", "n8n/workflows/*.json"): - for path in sorted(glob.glob(os.path.join(root, pattern))): - digest.update(os.path.relpath(path, root).encode()) - with open(path, "rb") as handle: - digest.update(handle.read()) -for name in ( - "N8N_ENCRYPTION_KEY", - "OPENAI_API_KEY", - "POSTIZ_API_KEY", - "SFTP_HOST", - "SFTP_USERNAME", - "SFTP_PRIVATE_KEY", -): - digest.update(name.encode()) - digest.update(os.environ[name].encode()) -print(digest.hexdigest()) -PY -} - -workflow_is_current() { - local id="$1" - local source="$2" - local body="$TMP_DIR/workflow-${id}.json" - local code - code="$(curl -sS -o "$body" -w '%{http_code}' \ - -H "X-N8N-API-KEY: ${N8N_API_KEY}" \ - "${N8N_BASE}/api/v1/workflows/${id}" || true)" - [[ "$code" == "200" ]] || return 1 - python3 - "$body" "$source" <<'PY' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - body = json.load(handle) -with open(sys.argv[2], encoding="utf-8") as handle: - desired = json.load(handle) -deployed = body.get("data", body) -keys = ("name", "nodes", "connections", "settings", "staticData") -matches = deployed.get("active") is True and all( - deployed.get(key) == desired.get(key) for key in keys -) -raise SystemExit(0 if matches else 1) -PY -} - -all_workflows_current() { - local file id - for file in "${WORKFLOW_FILES[@]}"; do - id="$(workflow_id "$SOURCE_ROOT/$file")" - workflow_is_current "$id" "$SOURCE_ROOT/$file" || return 1 - done -} - -render_credential() { - local template="$1" - local out="$2" - python3 - "$template" "$out" <<'PY' -import json -import os -import re -import sys - -source, destination = sys.argv[1:] -raw = open(source, encoding="utf-8").read() - -def replace(match: re.Match[str]) -> str: - key = match.group(1) - if key not in os.environ: - raise SystemExit(f"Missing environment value for template: {key}") - return json.dumps(os.environ[key])[1:-1] - -rendered = re.sub(r"\$\{([A-Z0-9_]+)\}", replace, raw) -json.loads(rendered) -open(destination, "w", encoding="utf-8").write(rendered) -PY -} - -copy_into_n8n() { - local source="$1" - local destination="$2" - # shellcheck disable=SC2016 - compose exec -T -u node n8n sh -c 'cat > "$1"' sh "$destination" <"$source" -} - -publish_workflow() { - local id="$1" - local body="$TMP_DIR/publish-${id}.json" - local code - code="$(curl -sS -o "$body" -w '%{http_code}' -X POST \ - -H "X-N8N-API-KEY: ${N8N_API_KEY}" \ - -H 'Content-Type: application/json' \ - "${N8N_BASE}/api/v1/workflows/${id}/publish" || true)" - if [[ "$code" != "200" ]]; then - code="$(curl -sS -o "$body" -w '%{http_code}' -X POST \ - -H "X-N8N-API-KEY: ${N8N_API_KEY}" \ - -H 'Content-Type: application/json' \ - "${N8N_BASE}/api/v1/workflows/${id}/activate" || true)" - fi - if [[ "$code" != "200" ]]; then - echo "Failed to publish workflow $id (HTTP $code): $(<"$body")" >&2 - exit 1 - fi -} - -ensure_api_key -fingerprint="$(bootstrap_fingerprint)" -if [[ -s "$STATE_FILE" ]] && [[ "$(<"$STATE_FILE")" == "$fingerprint" ]] && \ - all_workflows_current; then - echo "n8n bootstrap is already current." - exit 0 -fi - -OWNER_USER_ID="$(owner_user_id)" -echo "Importing credentials for owner $OWNER_USER_ID..." -for template in "$SOURCE_ROOT"/n8n/credentials/*.template.json; do - base="$(basename "$template" .template.json)" - rendered="$TMP_DIR/${base}.json" - render_credential "$template" "$rendered" - copy_into_n8n "$rendered" "/tmp/${base}.json" - compose exec -T -u node n8n \ - n8n import:credentials --input="/tmp/${base}.json" --userId="$OWNER_USER_ID" - compose exec -T -u node n8n rm -f "/tmp/${base}.json" -done - -echo "Importing and publishing workflows..." -for file in "${WORKFLOW_FILES[@]}"; do - id="$(workflow_id "$SOURCE_ROOT/$file")" - copy_into_n8n "$SOURCE_ROOT/$file" /tmp/syndicator-workflow.json - compose exec -T -u node n8n \ - n8n import:workflow --input=/tmp/syndicator-workflow.json --userId="$OWNER_USER_ID" - compose exec -T -u node n8n rm -f /tmp/syndicator-workflow.json - publish_workflow "$id" -done - -if ! all_workflows_current; then - echo "At least one imported workflow differs from source or is inactive." >&2 - exit 1 -fi - -mkdir -p "$(dirname "$STATE_FILE")" -umask 077 -printf '%s\n' "$fingerprint" >"$STATE_FILE" -chmod 600 "$STATE_FILE" -echo "n8n bootstrap complete." diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c733201..9ae72a4 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -102,7 +102,8 @@ if [[ "${SYNDICATOR_TEST_FAIL_AFTER_START:-0}" == "1" ]]; then echo "Deliberate post-start failure requested by integration test." >&2 false fi -"$ROOT/scripts/bootstrap-n8n.sh" +wait_for_n8n +run_reconcile "$ROOT/scripts/verify.sh" write_release_state "$desired_tag" "$desired_revision" diff --git a/scripts/lib.sh b/scripts/lib.sh index 733ce9b..c1ca1cb 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -117,6 +117,10 @@ wait_for_n8n() { return 1 } +run_reconcile() { + compose --profile reconcile run --rm -T n8n-reconcile +} + workflow_id() { python3 - "$1" <<'PY' import json diff --git a/scripts/verify.sh b/scripts/verify.sh index f8f5650..9f1e74a 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -7,41 +7,8 @@ source "$ROOT/scripts/lib.sh" load_env wait_for_n8n 30 2 +run_reconcile -if [[ -z "${N8N_API_KEY:-}" ]]; then - api_key_file="$(resolve_from_root "${N8N_API_KEY_FILE:-secrets/n8n_api_key}")" - if [[ ! -s "$api_key_file" ]]; then - echo "Missing n8n API key: $api_key_file" >&2 - exit 1 - fi - N8N_API_KEY="$(tr -d '[:space:]' <"$api_key_file")" -fi - -tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT -n8n_base="http://127.0.0.1:${N8N_HOST_PORT:-5678}" - -for file in "$SOURCE_ROOT"/n8n/workflows/*.json; do - id="$(workflow_id "$file")" - body="$tmp/workflow-${id}.json" - code="$(curl -sS -o "$body" -w '%{http_code}' \ - -H "X-N8N-API-KEY: ${N8N_API_KEY}" \ - "${n8n_base}/api/v1/workflows/${id}" || true)" - if [[ "$code" != "200" ]]; then - echo "Workflow $id is unavailable through the n8n API (HTTP $code)." >&2 - exit 1 - fi - python3 - "$body" "$file" <<'PY' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - body = json.load(handle) -data = body.get("data", body) -if data.get("active") is not True: - raise SystemExit(f"{sys.argv[2]} is not active") -PY -done echo "n8n health and workflow publication are valid." health="$(compose exec -T n8n wget -qO- http://pyautoflip:8080/health || true)" @@ -51,6 +18,8 @@ if [[ "$health" != *'"status":"ok"'* && "$health" != *'"status": "ok"'* ]]; then fi echo "pyautoflip is reachable from n8n." +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT private_key="$(resolve_from_root "${SFTP_PRIVATE_KEY_FILE:-secrets/sftp_n8n_ed25519}")" sftp_port="${SFTP_PUBLISH_PORT:-2222}" ssh-keyscan -p "$sftp_port" 127.0.0.1 >"$tmp/known_hosts" 2>/dev/null diff --git a/tests/integration/stack.sh b/tests/integration/stack.sh index bb4dd2e..c8e7b97 100755 --- a/tests/integration/stack.sh +++ b/tests/integration/stack.sh @@ -33,8 +33,6 @@ N8N_ENCRYPTION_KEY=integration-only-encryption-key N8N_OWNER_EMAIL=ci@example.invalid N8N_OWNER_PASSWORD=ci-owner-password N8N_OWNER_ENV_FILE=$ROOT/tests/fixtures/n8n_owner.env -N8N_API_KEY_FILE=$tmp/n8n_api_key -N8N_BOOTSTRAP_STATE_FILE=$tmp/bootstrap.sha256 OPENAI_API_KEY=integration-openai-key POSTIZ_API_KEY=integration-postiz-key SFTP_PUBLISH_PORT=$sftp_port @@ -100,7 +98,6 @@ if [[ "${SYNDICATOR_INTEGRATION_FAILURE_ONLY:-0}" == "1" ]]; then exit 0 fi -cp "$tmp/n8n_api_key" "$tmp/n8n_api_key.before" if ! "$ROOT/bin/syndicator" deploy | tee "$tmp/second-deploy.log"; then exit 1 fi @@ -114,29 +111,6 @@ then echo "Second deployment did not skip an unchanged bootstrap." >&2 exit 1 fi -cmp "$tmp/n8n_api_key.before" "$tmp/n8n_api_key" - -api_key="$(tr -d '[:space:]' <"$tmp/n8n_api_key")" -curl -fsS \ - -H "X-N8N-API-KEY: $api_key" \ - "http://127.0.0.1:${n8n_port}/api/v1/workflows?limit=100" | - python3 -c ' -import json,sys -body=json.load(sys.stdin) -items=body.get("data", body) -if isinstance(items, dict): - items=items.get("data", []) -expected={ - "8NOGn9jgOoV0fw0u", - "OGa6Xa8GxkSmA7Cr", - "y9TTx7N8Iygn88ry", - "l7HCCWtO1ALC82n6", - "zh21miLsQC8Jvua6", -} -actual={item["id"] for item in items if item.get("id") in expected} -if actual != expected: - raise SystemExit(f"Expected five Syndicator workflows, got {sorted(actual)}") -' printf '%s\n' "integration payload" >"$tmp/upload.txt" ssh-keyscan -p "$sftp_port" 127.0.0.1 >"$tmp/known_hosts" 2>/dev/null diff --git a/tests/test_repository.py b/tests/test_repository.py index dd39c53..a743e14 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -177,13 +177,11 @@ def test_example_configuration_is_host_neutral(self) -> None: example = (ROOT / ".env.example").read_text(encoding="utf-8") self.assertNotRegex(example, r"\b192\.168\.\d{1,3}\.\d{1,3}\b") - def test_bootstrap_uses_supported_interfaces(self) -> None: - bootstrap = (ROOT / "scripts" / "bootstrap-n8n.sh").read_text( - encoding="utf-8" - ) - self.assertNotIn("docker volume", bootstrap) - self.assertNotIn("sqlite", bootstrap.lower()) - self.assertNotIn("PUBLISH_WORKFLOW_IDS", bootstrap) + def test_reconcile_uses_supported_interfaces(self) -> None: + reconcile = (ROOT / "n8n" / "reconcile.js").read_text(encoding="utf-8") + self.assertNotIn("docker volume", reconcile) + self.assertNotIn("sqlite", reconcile.lower()) + self.assertNotIn("PUBLISH_WORKFLOW_IDS", reconcile) library = (ROOT / "scripts" / "lib.sh").read_text(encoding="utf-8") self.assertIn("/healthz/readiness", library) diff --git a/tests/validate-compose.sh b/tests/validate-compose.sh index 47435ff..8f129d0 100755 --- a/tests/validate-compose.sh +++ b/tests/validate-compose.sh @@ -22,6 +22,9 @@ fi export N8N_ENCRYPTION_KEY="ci-only-encryption-key" export N8N_OWNER_EMAIL="ci@example.invalid" +export N8N_OWNER_PASSWORD="ci-owner-password" +export OPENAI_API_KEY="ci-openai-key" +export POSTIZ_API_KEY="ci-postiz-key" if [[ "$#" -gt 0 ]]; then docker compose --env-file /dev/null "$@" From 3930517dcad1752bb6fb6690d6c7f648285b648f Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Thu, 13 Aug 2026 19:51:18 +0200 Subject: [PATCH 10/16] Hash the n8n owner password inside the container. This removes the host bcrypt sidecar, Compose $$ escaping, and secrets/n8n_owner.env so owner credentials stay in .env only. Co-authored-by: Cursor --- .env.example | 7 ++-- README.md | 2 +- docker-compose.yml | 4 +-- docs/operations.md | 1 - n8n/.dockerignore | 1 + n8n/Dockerfile | 1 + n8n/entrypoint.sh | 10 ++++++ n8n/hash-owner-password.js | 28 ++++++++++++++++ scripts/doctor.sh | 3 +- scripts/ensure-n8n-owner.sh | 63 ------------------------------------ scripts/init.sh | 1 - tests/fixtures/n8n_owner.env | 2 -- tests/integration/stack.sh | 1 - tests/validate-compose.sh | 16 --------- 14 files changed, 45 insertions(+), 95 deletions(-) create mode 100644 n8n/hash-owner-password.js delete mode 100755 scripts/ensure-n8n-owner.sh delete mode 100644 tests/fixtures/n8n_owner.env diff --git a/.env.example b/.env.example index 1956102..7b0b5b3 100644 --- a/.env.example +++ b/.env.example @@ -20,8 +20,8 @@ N8N_PROXY_HOPS=0 N8N_ENCRYPTION_KEY= # Instance owner (provisioned via N8N_INSTANCE_OWNER_* on n8n start). -# `bin/syndicator init` hashes the password into the Compose owner env file. -# The n8n-reconcile service logs in with these values to import workflows. +# The n8n entrypoint hashes N8N_OWNER_PASSWORD; n8n-reconcile logs in with +# the same values to import workflows. N8N_OWNER_EMAIL= N8N_OWNER_PASSWORD= # N8N_OWNER_FIRST_NAME=Syndicator @@ -44,9 +44,6 @@ SFTP_USERNAME=sftp # Created automatically by `bin/syndicator init`. SFTP_PRIVATE_KEY_FILE=./secrets/sftp_n8n_ed25519 -# Generated bcrypt environment file consumed by Compose. -# N8N_OWNER_ENV_FILE=secrets/n8n_owner.env - # --- Build/runtime overrides (normally leave unset) --- # SYNDICATOR_IMAGE_TAG=local # PYAUTOFLIP_WARM_MODELS=1 diff --git a/README.md b/README.md index 87e8244..5cf2e96 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ bin/syndicator deploy Owner account is provisioned from env on n8n start (`N8N_INSTANCE_OWNER_*`). After n8n is healthy, the `n8n-reconcile` service logs in with `N8N_OWNER_EMAIL` / `N8N_OWNER_PASSWORD`, imports credentials and workflows from git, and publishes webhooks. UI login uses the same owner credentials. -`init` writes `secrets/sftp_n8n_ed25519` (private), `sftp/keys/n8n.pub` (public), and `secrets/n8n_owner.env` (bcrypt hash for Compose). Extra client keys: copy any `.pub` into `sftp/keys/` and run `bin/syndicator restart sftp`. Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`. +`init` writes `secrets/sftp_n8n_ed25519` (private) and `sftp/keys/n8n.pub` (public). The n8n owner password is hashed inside the container on start. Extra client keys: copy any `.pub` into `sftp/keys/` and run `bin/syndicator restart sftp`. Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`. Published ports bind to loopback by default. Read the [operations runbook](docs/operations.md) before enabling LAN or internet access. diff --git a/docker-compose.yml b/docker-compose.yml index d910007..1db37e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,9 +52,6 @@ services: restart: unless-stopped ports: - "${N8N_BIND_ADDRESS:-127.0.0.1}:${N8N_HOST_PORT:-5678}:5678" - # Password hash written by ./scripts/ensure-n8n-owner.sh (bcrypt; $ escaped as $$). - env_file: - - ${N8N_OWNER_ENV_FILE:-secrets/n8n_owner.env} environment: GENERIC_TIMEZONE: ${GENERIC_TIMEZONE:-Europe/Zurich} TZ: ${GENERIC_TIMEZONE:-Europe/Zurich} @@ -76,6 +73,7 @@ services: N8N_INSTANCE_OWNER_EMAIL: ${N8N_OWNER_EMAIL:?set N8N_OWNER_EMAIL in .env} N8N_INSTANCE_OWNER_FIRST_NAME: ${N8N_OWNER_FIRST_NAME:-Syndicator} N8N_INSTANCE_OWNER_LAST_NAME: ${N8N_OWNER_LAST_NAME:-Owner} + N8N_OWNER_PASSWORD: ${N8N_OWNER_PASSWORD:?set N8N_OWNER_PASSWORD in .env} volumes: - n8n_data:/home/node/.n8n - n8n_files:/files diff --git a/docs/operations.md b/docs/operations.md index 4e5d46c..17867b2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -118,7 +118,6 @@ metadata are removed. Review the resulting JSON before committing it. If the owner password changes, update `N8N_OWNER_PASSWORD` in `.env`, then run: ```bash -scripts/ensure-n8n-owner.sh --force bin/syndicator deploy ``` diff --git a/n8n/.dockerignore b/n8n/.dockerignore index 0f6285c..226725d 100644 --- a/n8n/.dockerignore +++ b/n8n/.dockerignore @@ -2,6 +2,7 @@ !Dockerfile !entrypoint.sh !reconcile.js +!hash-owner-password.js !package.json !package-lock.json !workflows/*.json diff --git a/n8n/Dockerfile b/n8n/Dockerfile index bdf2ed3..9e1b936 100644 --- a/n8n/Dockerfile +++ b/n8n/Dockerfile @@ -18,6 +18,7 @@ RUN npm ci --omit=dev \ COPY entrypoint.sh /entrypoint-syndicator.sh COPY reconcile.js /opt/syndicator/reconcile.js +COPY hash-owner-password.js /opt/syndicator/hash-owner-password.js COPY workflows /opt/syndicator/workflows COPY credentials /opt/syndicator/credentials RUN chmod +x /entrypoint-syndicator.sh \ diff --git a/n8n/entrypoint.sh b/n8n/entrypoint.sh index 94513b8..a889234 100755 --- a/n8n/entrypoint.sh +++ b/n8n/entrypoint.sh @@ -18,5 +18,15 @@ if [ -d "$SEED_DIR/node_modules" ] && [ -f "$SEED_DIR/package-lock.json" ]; then fi fi +if [ -z "${N8N_INSTANCE_OWNER_PASSWORD_HASH:-}" ]; then + if [ -z "${N8N_OWNER_PASSWORD:-}" ]; then + echo "N8N_OWNER_PASSWORD is required" >&2 + exit 1 + fi + N8N_INSTANCE_OWNER_PASSWORD_HASH="$(node /opt/syndicator/hash-owner-password.js)" + export N8N_INSTANCE_OWNER_PASSWORD_HASH + unset N8N_OWNER_PASSWORD +fi + # Preserve upstream custom-certificate handling + default start. exec /docker-entrypoint.sh "$@" diff --git a/n8n/hash-owner-password.js b/n8n/hash-owner-password.js new file mode 100644 index 0000000..00e380a --- /dev/null +++ b/n8n/hash-owner-password.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +"use strict"; + +const password = process.env.N8N_OWNER_PASSWORD; +if (!password) { + console.error("N8N_OWNER_PASSWORD is required to hash the instance owner password."); + process.exit(1); +} + +function loadBcrypt() { + const candidates = [ + "bcryptjs", + "bcrypt", + "/usr/local/lib/node_modules/n8n/node_modules/bcryptjs", + "/usr/local/lib/node_modules/n8n/node_modules/bcrypt", + ]; + for (const id of candidates) { + try { + return require(id); + } catch { + // try the next candidate + } + } + throw new Error("Unable to load bcrypt from the n8n image"); +} + +const bcrypt = loadBcrypt(); +process.stdout.write(bcrypt.hashSync(password, 10)); diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 292d7ad..4d978dd 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -48,11 +48,10 @@ for name in \ need_env "$name" done -owner_env="$(resolve_from_root "${N8N_OWNER_ENV_FILE:-secrets/n8n_owner.env}")" private_key="$(resolve_from_root "${SFTP_PRIVATE_KEY_FILE:-secrets/sftp_n8n_ed25519}")" keys_dir="$(resolve_from_root "${SFTP_KEYS_DIR:-sftp/keys}")" -for path in "$owner_env" "$private_key" "$keys_dir/n8n.pub"; do +for path in "$private_key" "$keys_dir/n8n.pub"; do if [[ ! -e "$path" ]]; then echo "Missing generated setup artifact: $path" >&2 failed=1 diff --git a/scripts/ensure-n8n-owner.sh b/scripts/ensure-n8n-owner.sh deleted file mode 100755 index 1ed620a..0000000 --- a/scripts/ensure-n8n-owner.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# Idempotently bcrypt-hash N8N_OWNER_PASSWORD into the Compose owner env file. -# Run through `bin/syndicator init` before starting the stack. -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# shellcheck source=scripts/lib.sh -source "$ROOT/scripts/lib.sh" - -force=0 -if [[ "${1:-}" == "--force" ]]; then - force=1 -elif [[ "$#" -gt 0 ]]; then - echo "Usage: $0 [--force]" >&2 - exit 2 -fi - -load_env -need_env N8N_OWNER_EMAIL -need_env N8N_OWNER_PASSWORD - -out="$(resolve_from_root "${N8N_OWNER_ENV_FILE:-secrets/n8n_owner.env}")" -mkdir -p "$(dirname "$out")" -if [[ -s "$out" && "$force" -eq 0 ]]; then - echo "n8n owner hash already present: $out" - exit 0 -fi - -hash_password() { - # Prefer host bcrypt; fall back to a one-shot container (Docker is required anyway). - if python3 -c 'import bcrypt' >/dev/null 2>&1; then - N8N_OWNER_PASSWORD="$N8N_OWNER_PASSWORD" python3 - <<'PY' -import bcrypt, os -password = os.environ["N8N_OWNER_PASSWORD"].encode() -print(bcrypt.hashpw(password, bcrypt.gensalt(rounds=10)).decode()) -PY - return - fi - printf '%s' "$N8N_OWNER_PASSWORD" | docker run --rm -i \ - "python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" \ - sh -c ' - pip install --no-cache-dir -q bcrypt==5.0.0 >/dev/null - python -c "import bcrypt,sys; p=sys.stdin.buffer.read(); print(bcrypt.hashpw(p, bcrypt.gensalt(rounds=10)).decode())" - ' -} - -echo "Hashing n8n owner password…" -raw_hash="$(hash_password)" -if [[ ! "$raw_hash" =~ ^\$2[aby]\$ ]]; then - echo "Expected a bcrypt hash, got: ${raw_hash:0:20}…" >&2 - exit 1 -fi - -# Compose env_file interpolates $VAR; escape each $ as $$ for a literal hash. -escaped_hash="${raw_hash//\$/\$\$}" - -umask 077 -cat >"$out" <"$owner_env" - created_owner_env=1 -fi - export N8N_ENCRYPTION_KEY="ci-only-encryption-key" export N8N_OWNER_EMAIL="ci@example.invalid" export N8N_OWNER_PASSWORD="ci-owner-password" From 7cc48556e1e28f92f55324c802316d2d27d70754 Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Thu, 13 Aug 2026 19:55:17 +0200 Subject: [PATCH 11/16] Give n8n a shared SFTP volume instead of an internal FTP hop. Callers still use SFTP; n8n now reads and writes /syndicator directly, so the n8n client keypair and SFTP credential can go. Co-authored-by: Cursor --- .env.example | 13 ++--- README.md | 12 ++-- docker-compose.yml | 16 +++--- docs/adr/0001-deployment-model.md | 7 +-- n8n/credentials/sftp.template.json | 12 ---- n8n/reconcile.js | 24 +------- n8n/workflows/Adapt Feature Image.json | 38 +++++-------- n8n/workflows/Adapt Hugo Media.json | 74 +++++++++--------------- n8n/workflows/Adapt Reel Media.json | 55 ++++++------------ n8n/workflows/Blog Post Publish.json | 78 +++++++++----------------- n8n/workflows/Reel Publish.json | 76 +++++++++---------------- scripts/doctor.sh | 20 +------ scripts/ensure-sftp-keys.sh | 33 ----------- scripts/init.sh | 6 +- scripts/verify.sh | 28 +++++---- sftp/authorized_keys.example | 2 +- sftp/setup.sh | 4 +- tests/integration/stack.sh | 11 ++-- tests/test_repository.py | 14 +++++ 19 files changed, 176 insertions(+), 347 deletions(-) delete mode 100644 n8n/credentials/sftp.template.json delete mode 100755 scripts/ensure-sftp-keys.sh diff --git a/.env.example b/.env.example index 7b0b5b3..edc6829 100644 --- a/.env.example +++ b/.env.example @@ -27,23 +27,18 @@ N8N_OWNER_PASSWORD= # N8N_OWNER_FIRST_NAME=Syndicator # N8N_OWNER_LAST_NAME=Owner -# --- SFTP (published to host; internal compose hostname is always "sftp") --- +# --- SFTP (published to host) --- SFTP_BIND_ADDRESS=127.0.0.1 SFTP_PUBLISH_PORT=2222 # SFTP_KEYS_DIR=./sftp/keys +# Optional client private key used by `bin/syndicator verify`. +# SFTP_CLIENT_KEY_FILE=./secrets/sftp_client_ed25519 -# --- Credential secrets (rendered into templates, then deleted) --- +# --- Credential secrets --- OPENAI_API_KEY= POSTIZ_API_KEY= -# n8n FTP/SFTP credential (host must be the compose service name) -SFTP_HOST=sftp -SFTP_USERNAME=sftp -# Path to the private key n8n uses to reach the sftp service (PEM/OpenSSH). -# Created automatically by `bin/syndicator init`. -SFTP_PRIVATE_KEY_FILE=./secrets/sftp_n8n_ed25519 - # --- Build/runtime overrides (normally leave unset) --- # SYNDICATOR_IMAGE_TAG=local # PYAUTOFLIP_WARM_MODELS=1 diff --git a/README.md b/README.md index 5cf2e96..a6f110f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Syndicator provides the `syndicate` interface specified in this document. Callers invoke syndicate by: -1. uploading medias to SFTP (port `2222`, key-only; authorize a public key in `sftp/keys/`, or reuse `secrets/sftp_n8n_ed25519` from `./scripts/ensure-sftp-keys.sh`) +1. uploading medias to SFTP (port `2222`, key-only; authorize a public key in `sftp/keys/`) 2. POSTing JSON to the Blog Post Publish and Reel Publish webhook. 3. The webhook responds with HTTP 2xx as soon as the request is accepted and continues asynchronously. @@ -165,11 +165,11 @@ bin/syndicator deploy Owner account is provisioned from env on n8n start (`N8N_INSTANCE_OWNER_*`). After n8n is healthy, the `n8n-reconcile` service logs in with `N8N_OWNER_EMAIL` / `N8N_OWNER_PASSWORD`, imports credentials and workflows from git, and publishes webhooks. UI login uses the same owner credentials. -`init` writes `secrets/sftp_n8n_ed25519` (private) and `sftp/keys/n8n.pub` (public). The n8n owner password is hashed inside the container on start. Extra client keys: copy any `.pub` into `sftp/keys/` and run `bin/syndicator restart sftp`. Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`. +`init` creates `.env` and an encryption key. Authorize callers by copying a `.pub` into `sftp/keys/` and running `bin/syndicator restart sftp`. Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`. Published ports bind to loopback by default. Read the [operations runbook](docs/operations.md) before enabling LAN or internet access. -The `files-init` Compose service chowns the shared `n8n_files` volume to uid/gid `1000` on each `up` so n8n and pyautoflip can write under `/files`. +The `files-init` Compose service chowns the shared `n8n_files` and `sftp_data` volumes to uid/gid `1000` on each `up` so n8n, pyautoflip, and SFTP can write. ## Update workflows @@ -227,7 +227,7 @@ bin/syndicator ### Runtime structure -Once instantiated, three services collaborate: callers reach **sftp** (files) and **n8n** (webhooks); n8n drives SFTP, **pyautoflip**, and external APIs. +Once instantiated, three services collaborate: callers reach **sftp** (files) and **n8n** (webhooks); n8n reads and writes the shared SFTP volume, **pyautoflip**, and external APIs. ```mermaid flowchart LR @@ -239,7 +239,7 @@ flowchart LR end Caller -->|key auth SFTP| SFTP Caller -->|webhooks| N8N - N8N -->|FTP host=sftp| SFTP + N8N -->|shared volume /syndicator| SFTP N8N -->|HTTP /reframe on /files| PyAF N8N --> OpenAI["OpenAI"] N8N --> Postiz["Postiz"] @@ -249,7 +249,7 @@ flowchart LR | Service | Role | |---------|------| | `sftp` | Key-only SFTP on port `2222`; chroot home with `/syndicator/…`; host keys in `sftp_host_keys` | -| `n8n` | Workflow engine; SQLite in `n8n_data`; shares `n8n_files` → `/files` with pyautoflip | +| `n8n` | Workflow engine; SQLite in `n8n_data`; shares `n8n_files` → `/files` with pyautoflip and `sftp_data` → `/syndicator` | | `pyautoflip` | Reel reframing sidecar (`HTTP /reframe` on `/files`) | | Workflow | Role | diff --git a/docker-compose.yml b/docker-compose.yml index 1db37e7..44fa412 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,12 +10,13 @@ services: files-init: image: alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc volumes: - - n8n_files:/data + - n8n_files:/files + - sftp_data:/syndicator command: [ "sh", "-c", - "chown 1000:1000 /data && chmod 775 /data", + "chown 1000:1000 /files /syndicator && chmod 775 /files /syndicator", ] restart: "no" @@ -28,14 +29,14 @@ services: - "${SFTP_BIND_ADDRESS:-127.0.0.1}:${SFTP_PUBLISH_PORT:-2222}:22" volumes: - sftp_data:/home/sftp/syndicator - # Client public keys (ensure-sftp-keys.sh writes n8n.pub here). + # Client public keys (copy any .pub into sftp/keys/). - ${SFTP_KEYS_DIR:-./sftp/keys}:/home/sftp/.ssh/keys:ro # Server host keys generated on first start; survive recreate. - sftp_host_keys:/etc/ssh/host_keys # Supported atmoz startup hook; avoids replacing the sshd binary. - ./sftp/setup.sh:/etc/sftp.d/10-syndicator.sh:ro # user::uid:gid:dirs — empty password → key-only auth - command: sftp::1001:100:syndicator + command: sftp::1000:1000:syndicator healthcheck: test: [ @@ -62,10 +63,11 @@ services: N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-${WEBHOOK_URL:-http://localhost:5678/}} N8N_PROXY_HOPS: ${N8N_PROXY_HOPS:-0} N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true" - N8N_RESTRICT_FILE_ACCESS_TO: /files + N8N_RESTRICT_FILE_ACCESS_TO: /files;/syndicator N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE:-false} N8N_COMMUNITY_PACKAGES_ENABLED: "true" N8N_UNVERIFIED_PACKAGES_ENABLED: "true" + NODE_FUNCTION_ALLOW_BUILTIN: fs,path N8N_RUNNERS_TASK_TIMEOUT: "300" N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES: "268435456" N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES: "1000" @@ -77,6 +79,7 @@ services: volumes: - n8n_data:/home/node/.n8n - n8n_files:/files + - sftp_data:/syndicator depends_on: files-init: condition: service_completed_successfully @@ -110,14 +113,11 @@ services: N8N_OWNER_PASSWORD: ${N8N_OWNER_PASSWORD:?set N8N_OWNER_PASSWORD in .env} OPENAI_API_KEY: ${OPENAI_API_KEY:?set OPENAI_API_KEY in .env} POSTIZ_API_KEY: ${POSTIZ_API_KEY:?set POSTIZ_API_KEY in .env} - SFTP_HOST: ${SFTP_HOST:-sftp} - SFTP_USERNAME: ${SFTP_USERNAME:-sftp} N8N_INTERNAL_URL: http://n8n:5678 volumes: - n8n_data:/home/node/.n8n - ./n8n/workflows:/opt/syndicator/workflows:ro - ./n8n/credentials:/opt/syndicator/credentials:ro - - ${SFTP_PRIVATE_KEY_FILE:-./secrets/sftp_n8n_ed25519}:/run/secrets/sftp_private_key:ro entrypoint: ["node", "/opt/syndicator/reconcile.js"] pyautoflip: diff --git a/docs/adr/0001-deployment-model.md b/docs/adr/0001-deployment-model.md index 0f9ab40..bf8a3f0 100644 --- a/docs/adr/0001-deployment-model.md +++ b/docs/adr/0001-deployment-model.md @@ -91,10 +91,9 @@ add more state and failure modes than it removes. ### Direct n8n access to the SFTP data volume -Deferred. It could remove the internal SFTP credential and key exchange, but -it requires changing 17 workflow nodes and would couple workflows to the -single-host layout. The external SFTP interface stays stable; this optimization -can be reconsidered now that integration tests protect the behavior. +Accepted. n8n mounts `sftp_data` at `/syndicator` and uses Read/Write File +nodes. The external SFTP interface stays stable for callers. Revisit if +services move to separate machines. ## Consequences diff --git a/n8n/credentials/sftp.template.json b/n8n/credentials/sftp.template.json deleted file mode 100644 index ee5afca..0000000 --- a/n8n/credentials/sftp.template.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account", - "type": "sftp", - "data": { - "host": "${SFTP_HOST}", - "username": "${SFTP_USERNAME}", - "privateKey": "${SFTP_PRIVATE_KEY}" - } - } -] diff --git a/n8n/reconcile.js b/n8n/reconcile.js index 9502bff..0069b4f 100644 --- a/n8n/reconcile.js +++ b/n8n/reconcile.js @@ -15,8 +15,6 @@ const N8N_BASE = (process.env.N8N_INTERNAL_URL || "http://n8n:5678").replace( /\/$/, "", ); -const KEY_FILE = - process.env.SFTP_PRIVATE_KEY_FILE || "/run/secrets/sftp_private_key"; function fail(message) { console.error(message); @@ -31,16 +29,6 @@ function runN8n(args) { return result.stdout; } -function loadPrivateKey() { - if (process.env.SFTP_PRIVATE_KEY) { - return process.env.SFTP_PRIVATE_KEY; - } - if (!fs.existsSync(KEY_FILE)) { - fail(`Missing SFTP private key: ${KEY_FILE}`); - } - return fs.readFileSync(KEY_FILE, "utf8"); -} - function listBundle(kind, suffix) { const dir = path.join(BUNDLE, kind); return fs @@ -59,14 +47,7 @@ function fingerprint() { digest.update(path.relative(BUNDLE, filePath)); digest.update(fs.readFileSync(filePath)); } - for (const name of [ - "N8N_ENCRYPTION_KEY", - "OPENAI_API_KEY", - "POSTIZ_API_KEY", - "SFTP_HOST", - "SFTP_USERNAME", - "SFTP_PRIVATE_KEY", - ]) { + for (const name of ["N8N_ENCRYPTION_KEY", "OPENAI_API_KEY", "POSTIZ_API_KEY"]) { digest.update(name); digest.update(process.env[name] || ""); } @@ -269,15 +250,12 @@ async function main() { "N8N_OWNER_PASSWORD", "OPENAI_API_KEY", "POSTIZ_API_KEY", - "SFTP_HOST", - "SFTP_USERNAME", ]) { if (!process.env[name]) { fail(`Missing required environment value: ${name}`); } } - process.env.SFTP_PRIVATE_KEY = loadPrivateKey(); const files = workflowFiles(); if (!files.length) { fail(`No workflow exports found under ${BUNDLE}`); diff --git a/n8n/workflows/Adapt Feature Image.json b/n8n/workflows/Adapt Feature Image.json index 79c51b5..a09c233 100644 --- a/n8n/workflows/Adapt Feature Image.json +++ b/n8n/workflows/Adapt Feature Image.json @@ -64,24 +64,19 @@ }, { "parameters": { - "protocol": "sftp", - "path": "={{ '/syndicator/' + $json.slug + '/source/' + $json.header_source }}", - "options": {} + "fileSelector": "={{ '/syndicator/' + $json.slug + '/source/' + $json.header_source }}", + "options": { + "dataPropertyName": "data" + } }, "id": "8f1a0d03-f964-45fb-b5fb-a2ee8670eff0", "name": "Download Feature Source", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 448, 0 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { @@ -130,7 +125,7 @@ }, { "parameters": { - "jsCode": "function cropBox(width, height, targetRatio, focus) {\n const fx = focus?.x ?? 0.5;\n const fy = focus?.y ?? 0.5;\n const srcRatio = width / height;\n let cropW, cropH;\n if (srcRatio > targetRatio) { cropH = height; cropW = Math.round(height * targetRatio); }\n else { cropW = width; cropH = Math.round(width / targetRatio); }\n let left = Math.round(fx * width - cropW / 2);\n let top = Math.round(fy * height - cropH / 2);\n left = Math.min(Math.max(left, 0), width - cropW);\n top = Math.min(Math.max(top, 0), height - cropH);\n return { left, top, width: cropW, height: cropH };\n}\nfunction even(n) { return n % 2 === 0 ? n : n - 1; }\nfunction fitWithoutUpscale(cropW, cropH, maxW, maxH) {\n if (cropW <= maxW && cropH <= maxH) return { width: cropW, height: cropH };\n const scale = Math.min(maxW / cropW, maxH / cropH);\n return { width: even(Math.floor(cropW * scale)), height: even(Math.floor(cropH * scale)) };\n}\nfunction parseFocus(raw) {\n try {\n let t = raw?.content ?? raw?.text ?? raw?.[0]?.content?.[0]?.text ?? raw?.output?.[0]?.content?.[0]?.text ?? raw;\n if (typeof t !== 'string') t = JSON.stringify(t);\n const m = t.match(/\\{[\\s\\S]*\\}/);\n if (!m) return { x: 0.5, y: 0.5 };\n const j = JSON.parse(m[0]);\n return { x: Number(j.x) || 0.5, y: Number(j.y) || 0.5 };\n } catch (e) { return { x: 0.5, y: 0.5 }; }\n}\nfunction readSize(info) {\n const width = Number(info?.size?.width || info?.width || info?.data?.width || 0);\n const height = Number(info?.size?.height || info?.height || info?.data?.height || 0);\n if (width > 0 && height > 0) return { width, height };\n const geo = String(info?.Geometry || '');\n const m = geo.match(/^(\\d+)x(\\d+)/);\n if (m) return { width: Number(m[1]), height: Number(m[2]) };\n throw new Error('Feature Image Info missing size: ' + JSON.stringify(Object.keys(info || {})));\n}\nconst trigger = $('Adapt Feature Trigger').first().json;\nconst slug = String(trigger.slug || '');\nconst { width, height } = readSize($('Feature Image Info').first().json);\nconst focus = parseFocus($('Feature Crop Focus').first().json);\nconst jobs = [];\n{\n const box = cropBox(width, height, 1080 / 1350, focus);\n const out = fitWithoutUpscale(box.width, box.height, 1080, 1350);\n jobs.push({ platform: 'instagram', crop: box, out, sftp_path: '/syndicator/' + slug + '/header/instagram.jpg' });\n}\nfor (const platform of ['facebook', 'x']) {\n let outW = width, outH = height;\n const maxEdge = Math.max(width, height);\n if (maxEdge > 2048) {\n const scale = 2048 / maxEdge;\n outW = even(Math.floor(width * scale));\n outH = even(Math.floor(height * scale));\n }\n jobs.push({ platform, crop: { left: 0, top: 0, width, height }, out: { width: outW, height: outH }, sftp_path: '/syndicator/' + slug + '/header/' + platform + '.jpg' });\n}\nreturn jobs.map((j) => ({ json: j }));" + "jsCode": "function cropBox(width, height, targetRatio, focus) {\n const fx = focus?.x ?? 0.5;\n const fy = focus?.y ?? 0.5;\n const srcRatio = width / height;\n let cropW, cropH;\n if (srcRatio > targetRatio) { cropH = height; cropW = Math.round(height * targetRatio); }\n else { cropW = width; cropH = Math.round(width / targetRatio); }\n let left = Math.round(fx * width - cropW / 2);\n let top = Math.round(fy * height - cropH / 2);\n left = Math.min(Math.max(left, 0), width - cropW);\n top = Math.min(Math.max(top, 0), height - cropH);\n return { left, top, width: cropW, height: cropH };\n}\nfunction even(n) { return n % 2 === 0 ? n : n - 1; }\nfunction fitWithoutUpscale(cropW, cropH, maxW, maxH) {\n if (cropW <= maxW && cropH <= maxH) return { width: cropW, height: cropH };\n const scale = Math.min(maxW / cropW, maxH / cropH);\n return { width: even(Math.floor(cropW * scale)), height: even(Math.floor(cropH * scale)) };\n}\nfunction parseFocus(raw) {\n try {\n let t = raw?.content ?? raw?.text ?? raw?.[0]?.content?.[0]?.text ?? raw?.output?.[0]?.content?.[0]?.text ?? raw;\n if (typeof t !== 'string') t = JSON.stringify(t);\n const m = t.match(/\\{[\\s\\S]*\\}/);\n if (!m) return { x: 0.5, y: 0.5 };\n const j = JSON.parse(m[0]);\n return { x: Number(j.x) || 0.5, y: Number(j.y) || 0.5 };\n } catch (e) { return { x: 0.5, y: 0.5 }; }\n}\nfunction readSize(info) {\n const width = Number(info?.size?.width || info?.width || info?.data?.width || 0);\n const height = Number(info?.size?.height || info?.height || info?.data?.height || 0);\n if (width > 0 && height > 0) return { width, height };\n const geo = String(info?.Geometry || '');\n const m = geo.match(/^(\\d+)x(\\d+)/);\n if (m) return { width: Number(m[1]), height: Number(m[2]) };\n throw new Error('Feature Image Info missing size: ' + JSON.stringify(Object.keys(info || {})));\n}\nconst trigger = $('Adapt Feature Trigger').first().json;\nconst slug = String(trigger.slug || '');\nconst { width, height } = readSize($('Feature Image Info').first().json);\nconst focus = parseFocus($('Feature Crop Focus').first().json);\nconst jobs = [];\n{\n const box = cropBox(width, height, 1080 / 1350, focus);\n const out = fitWithoutUpscale(box.width, box.height, 1080, 1350);\n jobs.push({ platform: 'instagram', crop: box, out, sftp_path: '/syndicator/' + slug + '/header/instagram.jpg' });\n}\nfor (const platform of ['facebook', 'x']) {\n let outW = width, outH = height;\n const maxEdge = Math.max(width, height);\n if (maxEdge > 2048) {\n const scale = 2048 / maxEdge;\n outW = even(Math.floor(width * scale));\n outH = even(Math.floor(height * scale));\n }\n jobs.push({ platform, crop: { left: 0, top: 0, width, height }, out: { width: outW, height: outH }, sftp_path: '/syndicator/' + slug + '/header/' + platform + '.jpg' });\n}\nconst fs = require('fs');\nconst path = require('path');\nfor (const job of jobs) {\n fs.mkdirSync(path.dirname(job.sftp_path), { recursive: true });\n}\nreturn jobs.map((j) => ({ json: j }));" }, "id": "4df52cea-42cf-475c-b35a-a41df142a831", "name": "Plan Header Jobs", @@ -192,25 +187,18 @@ }, { "parameters": { - "protocol": "sftp", - "operation": "upload", - "path": "={{ $('Plan Header Jobs').item.json.sftp_path }}", + "operation": "write", + "fileName": "={{ $('Plan Header Jobs').item.json.sftp_path }}", "options": {} }, "id": "a520fc28-03b5-498b-bf77-d82046094c37", "name": "Upload Header", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 1792, 0 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { diff --git a/n8n/workflows/Adapt Hugo Media.json b/n8n/workflows/Adapt Hugo Media.json index 6182bda..071db18 100644 --- a/n8n/workflows/Adapt Hugo Media.json +++ b/n8n/workflows/Adapt Hugo Media.json @@ -35,7 +35,7 @@ }, { "parameters": { - "jsCode": "const VIDEO_RE = /\\.(mp4|mov|m4v|webm|avi|mkv|mpeg|mpg)$/i;\nfunction hugoBasename(name) {\n const n = String(name || '');\n if (!n) return n;\n if (VIDEO_RE.test(n)) return n.replace(/\\.[^.]+$/, '') + '.mp4';\n return n;\n}\nfunction asBlocks(value) {\n if (Array.isArray(value)) return value;\n if (typeof value === 'string' && value.trim()) {\n try {\n const parsed = JSON.parse(value);\n return Array.isArray(parsed) ? parsed : [];\n } catch (e) {\n return [];\n }\n }\n return [];\n}\n\n// Prefer $input (what the previous node actually passed). Typed workflowInputs\n// on Execute Workflow Trigger often ignore pinData and arrive as nulls.\nconst raw = $input.first().json || {};\nconst slug = String(raw.slug || '').trim();\nconst blocks = asBlocks(raw.blocks);\nconst header = String(raw.header_source || '').trim();\n\nif (!slug) {\n throw new Error(\n 'Adapt Hugo Media: missing slug. Trigger received empty workflowInputs ' +\n '(slug/blocks/header_source). When testing manually, fill the trigger input ' +\n 'fields — pinData on Execute Workflow Trigger is not applied to typed inputs.'\n );\n}\n\nconst base = '/syndicator';\nconst siteRoot = `${base}/hugo-site/content/posts/${slug}`;\nconst sourceRoot = `${base}/${slug}/source`;\nconst jobs = [];\n\nfor (const b of blocks) {\n if (b?.kind !== 'media' || !b.media) continue;\n const m = b.media;\n const source_filename = String(m.source_filename || '').trim();\n if (!source_filename) continue;\n const bundle_filename = hugoBasename(source_filename);\n const kind = m.kind || (VIDEO_RE.test(source_filename) ? 'video' : 'image');\n jobs.push({\n kind,\n source_sftp_path: `${sourceRoot}/${source_filename}`,\n sftp_path: `${siteRoot}/${bundle_filename}`,\n bundle_filename,\n role: '',\n slug,\n });\n}\n\nif (header) {\n const ext = header.includes('.') ? header.slice(header.lastIndexOf('.')) : '.jpg';\n jobs.push({\n kind: 'image',\n source_sftp_path: `${sourceRoot}/${header}`,\n sftp_path: `${siteRoot}/featured${ext}`,\n bundle_filename: `featured${ext}`,\n role: 'featured',\n slug,\n });\n}\n\nif (!jobs.length) return [{ json: { kind: 'noop', skip: true } }];\nreturn jobs.map((j) => ({ json: j }));" + "jsCode": "const VIDEO_RE = /\\.(mp4|mov|m4v|webm|avi|mkv|mpeg|mpg)$/i;\nfunction hugoBasename(name) {\n const n = String(name || '');\n if (!n) return n;\n if (VIDEO_RE.test(n)) return n.replace(/\\.[^.]+$/, '') + '.mp4';\n return n;\n}\nfunction asBlocks(value) {\n if (Array.isArray(value)) return value;\n if (typeof value === 'string' && value.trim()) {\n try {\n const parsed = JSON.parse(value);\n return Array.isArray(parsed) ? parsed : [];\n } catch (e) {\n return [];\n }\n }\n return [];\n}\n\n// Prefer $input (what the previous node actually passed). Typed workflowInputs\n// on Execute Workflow Trigger often ignore pinData and arrive as nulls.\nconst raw = $input.first().json || {};\nconst slug = String(raw.slug || '').trim();\nconst blocks = asBlocks(raw.blocks);\nconst header = String(raw.header_source || '').trim();\n\nif (!slug) {\n throw new Error(\n 'Adapt Hugo Media: missing slug. Trigger received empty workflowInputs ' +\n '(slug/blocks/header_source). When testing manually, fill the trigger input ' +\n 'fields — pinData on Execute Workflow Trigger is not applied to typed inputs.'\n );\n}\n\nconst base = '/syndicator';\nconst siteRoot = `${base}/hugo-site/content/posts/${slug}`;\nconst sourceRoot = `${base}/${slug}/source`;\nconst jobs = [];\n\nfor (const b of blocks) {\n if (b?.kind !== 'media' || !b.media) continue;\n const m = b.media;\n const source_filename = String(m.source_filename || '').trim();\n if (!source_filename) continue;\n const bundle_filename = hugoBasename(source_filename);\n const kind = m.kind || (VIDEO_RE.test(source_filename) ? 'video' : 'image');\n jobs.push({\n kind,\n source_sftp_path: `${sourceRoot}/${source_filename}`,\n sftp_path: `${siteRoot}/${bundle_filename}`,\n bundle_filename,\n role: '',\n slug,\n });\n}\n\nif (header) {\n const ext = header.includes('.') ? header.slice(header.lastIndexOf('.')) : '.jpg';\n jobs.push({\n kind: 'image',\n source_sftp_path: `${sourceRoot}/${header}`,\n sftp_path: `${siteRoot}/featured${ext}`,\n bundle_filename: `featured${ext}`,\n role: 'featured',\n slug,\n });\n}\n\nif (!jobs.length) return [{ json: { kind: 'noop', skip: true } }];\nconst fs = require('fs');\nconst path = require('path');\nfor (const job of jobs) {\n fs.mkdirSync(path.dirname(job.sftp_path), { recursive: true });\n}\nreturn jobs.map((j) => ({ json: j }));" }, "id": "3c3b635c-616c-4685-bfc6-8ef7ad434874", "name": "Plan Site Media Jobs", @@ -95,24 +95,19 @@ }, { "parameters": { - "protocol": "sftp", - "path": "={{ $json.source_sftp_path }}", - "options": {} + "fileSelector": "={{ $json.source_sftp_path }}", + "options": { + "dataPropertyName": "data" + } }, "id": "5e0fe57f-c744-4812-a558-6b67707b4161", "name": "Download Site Video", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 896, 352 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { @@ -162,25 +157,18 @@ }, { "parameters": { - "protocol": "sftp", - "operation": "upload", - "path": "={{ $('Plan Hugo Resize').item.json.sftp_path }}", + "operation": "write", + "fileName": "={{ $('Plan Hugo Resize').item.json.sftp_path }}", "options": {} }, "id": "c3c9fec5-d2d5-4985-9c27-fb8c12bafa43", "name": "Upload Site Video", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 1792, 352 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { @@ -198,46 +186,34 @@ }, { "parameters": { - "protocol": "sftp", - "path": "={{ $json.source_sftp_path }}", - "options": {} + "fileSelector": "={{ $json.source_sftp_path }}", + "options": { + "dataPropertyName": "data" + } }, "id": "769a3d75-50d8-4514-b102-b16fdaebd864", "name": "Download Site Image", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 1568, 32 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { - "protocol": "sftp", - "operation": "upload", - "path": "={{ $json.sftp_path }}", + "operation": "write", + "fileName": "={{ $json.sftp_path }}", "options": {} }, "id": "02114a24-8540-46cd-a549-b9005598f935", "name": "Upload Site Image", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 1792, 32 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { diff --git a/n8n/workflows/Adapt Reel Media.json b/n8n/workflows/Adapt Reel Media.json index 714b9e3..f4b94d8 100644 --- a/n8n/workflows/Adapt Reel Media.json +++ b/n8n/workflows/Adapt Reel Media.json @@ -35,7 +35,7 @@ }, { "parameters": { - "jsCode": "const j = $input.first().json;\nconst slug = String(j.slug || 'unknown');\nconst index = Number(j.index || 1);\nconst source_filename = String(j.source_filename || '').trim();\nconst source_sftp_path = '/syndicator/' + slug + '/source/' + source_filename;\nconst base = '/files';\nconst safeSlug = slug.split('').map((c) => /[a-zA-Z0-9_]/.test(c) ? c : '_').join('').slice(0, 60);\nconst stamp = safeSlug + '-' + index + '-' + Date.now();\nconst source_local = base + '/syndicator-source-' + stamp + '.mp4';\nreturn [{ json: {\n slug: slug,\n index: index,\n source_filename: source_filename,\n source_sftp_path: source_sftp_path,\n source_local: source_local,\n video_4x5_local: base + '/syndicator-video-' + stamp + '-4x5.mp4',\n video_9x16_local: base + '/syndicator-video-' + stamp + '-9x16.mp4',\n video_4x5_sftp: '/syndicator/' + slug + '/reels/4x5/' + index + '.mp4',\n video_9x16_sftp: '/syndicator/' + slug + '/reels/9x16/' + index + '.mp4',\n} }];" + "jsCode": "const j = $input.first().json;\nconst slug = String(j.slug || 'unknown');\nconst index = Number(j.index || 1);\nconst source_filename = String(j.source_filename || '').trim();\nconst source_sftp_path = '/syndicator/' + slug + '/source/' + source_filename;\nconst base = '/files';\nconst safeSlug = slug.split('').map((c) => /[a-zA-Z0-9_]/.test(c) ? c : '_').join('').slice(0, 60);\nconst stamp = safeSlug + '-' + index + '-' + Date.now();\nconst source_local = base + '/syndicator-source-' + stamp + '.mp4';\nconst video_4x5_sftp = '/syndicator/' + slug + '/reels/4x5/' + index + '.mp4';\nconst video_9x16_sftp = '/syndicator/' + slug + '/reels/9x16/' + index + '.mp4';\nconst fs = require('fs');\nconst path = require('path');\nfs.mkdirSync(path.dirname(video_4x5_sftp), { recursive: true });\nfs.mkdirSync(path.dirname(video_9x16_sftp), { recursive: true });\nreturn [{ json: {\n slug: slug,\n index: index,\n source_filename: source_filename,\n source_sftp_path: source_sftp_path,\n source_local: source_local,\n video_4x5_local: base + '/syndicator-video-' + stamp + '-4x5.mp4',\n video_9x16_local: base + '/syndicator-video-' + stamp + '-9x16.mp4',\n video_4x5_sftp: video_4x5_sftp,\n video_9x16_sftp: video_9x16_sftp,\n} }];" }, "id": "46f8dd7d-9ead-45cf-ad9c-a6e0be30a879", "name": "Resolve Paths", @@ -48,24 +48,19 @@ }, { "parameters": { - "protocol": "sftp", - "path": "={{ $json.source_sftp_path }}", - "options": {} + "fileSelector": "={{ $json.source_sftp_path }}", + "options": { + "dataPropertyName": "data" + } }, "id": "1467dfaf-5f3d-453b-bb1d-d906c42bcb5a", "name": "Download Source", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 448, -16 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { @@ -84,25 +79,18 @@ }, { "parameters": { - "protocol": "sftp", - "operation": "upload", - "path": "={{ $('Plan Reframe').item.json.video_4x5_sftp }}", + "operation": "write", + "fileName": "={{ $('Plan Reframe').item.json.video_4x5_sftp }}", "options": {} }, "id": "f199b77d-8e6d-4ba8-b837-28b255e24536", "name": "Upload Reel", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 1568, -112 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { @@ -119,25 +107,18 @@ }, { "parameters": { - "protocol": "sftp", - "operation": "upload", - "path": "={{ $('Plan Reframe').item.json.video_9x16_sftp }}", + "operation": "write", + "fileName": "={{ $('Plan Reframe').item.json.video_9x16_sftp }}", "options": {} }, "id": "0ed402a8-830f-420e-bb82-3183564bb162", "name": "Upload Reel 9:16", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 1568, 80 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { diff --git a/n8n/workflows/Blog Post Publish.json b/n8n/workflows/Blog Post Publish.json index 726e2d3..eda28e6 100644 --- a/n8n/workflows/Blog Post Publish.json +++ b/n8n/workflows/Blog Post Publish.json @@ -117,24 +117,19 @@ }, { "parameters": { - "protocol": "sftp", - "path": "={{ $('Adapt Feature Image').item.json.header.facebook }}", - "options": {} + "fileSelector": "={{ $('Adapt Feature Image').item.json.header.facebook }}", + "options": { + "dataPropertyName": "data" + } }, "id": "7ca330c5-d1f5-4bc2-a93f-8385682fbf6f", "name": "Download Header FB", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 704, 192 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { @@ -213,24 +208,19 @@ }, { "parameters": { - "protocol": "sftp", - "path": "={{ $('Adapt Feature Image').item.json.header.instagram }}", - "options": {} + "fileSelector": "={{ $('Adapt Feature Image').item.json.header.instagram }}", + "options": { + "dataPropertyName": "data" + } }, "id": "6c6bd7a8-85ac-4642-a0ed-b7651e667aa3", "name": "Download Header IG", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 704, 384 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { @@ -309,24 +299,19 @@ }, { "parameters": { - "protocol": "sftp", - "path": "={{ $('Adapt Feature Image').item.json.header.x }}", - "options": {} + "fileSelector": "={{ $('Adapt Feature Image').item.json.header.x }}", + "options": { + "dataPropertyName": "data" + } }, "id": "654e05e6-b06e-462c-9f82-e2f9e0428fa7", "name": "Download Header X", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 704, 576 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { @@ -602,7 +587,7 @@ }, { "parameters": { - "jsCode": "const root = '/syndicator/hugo-site';\nreturn $('Generate Hugo Index MDs').first().json.indexFiles.map((f) => ({\n json: {\n sftp_path: `${root}/${f.repo_path}`,\n content: f.content,\n },\n}));" + "jsCode": "const fs = require('fs');\nconst path = require('path');\nconst root = '/syndicator/hugo-site';\nreturn $('Generate Hugo Index MDs').first().json.indexFiles.map((f) => {\n const sftp_path = `${root}/${f.repo_path}`;\n fs.mkdirSync(path.dirname(sftp_path), { recursive: true });\n const content = String(f.content ?? '');\n return {\n json: { sftp_path, content },\n binary: {\n data: {\n data: Buffer.from(content, 'utf8').toString('base64'),\n mimeType: 'text/markdown',\n fileName: path.basename(sftp_path),\n },\n },\n };\n});" }, "id": "1301589f-ac65-4019-985e-ebe2768c5c53", "name": "Stage Site Files", @@ -615,27 +600,18 @@ }, { "parameters": { - "protocol": "sftp", - "operation": "upload", - "path": "={{ $json.sftp_path }}", - "binaryData": false, - "fileContent": "={{ $json.content }}", + "operation": "write", + "fileName": "={{ $json.sftp_path }}", "options": {} }, "id": "9429c516-3ce2-4936-8879-3bd6d59746f7", "name": "Upload Site Files", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 704, 0 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { diff --git a/n8n/workflows/Reel Publish.json b/n8n/workflows/Reel Publish.json index ca42e44..0f01d1e 100644 --- a/n8n/workflows/Reel Publish.json +++ b/n8n/workflows/Reel Publish.json @@ -71,24 +71,19 @@ }, { "parameters": { - "protocol": "sftp", - "path": "={{ $('Merge Adapt Into Payload').item.json.video_4x5_sftp }}", - "options": {} + "fileSelector": "={{ $('Merge Adapt Into Payload').item.json.video_4x5_sftp }}", + "options": { + "dataPropertyName": "data" + } }, "id": "ea4dc89d-cdd9-4830-8dfe-e726a1e99c83", "name": "Download Reel FB", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 672, 160 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { @@ -201,24 +196,19 @@ }, { "parameters": { - "protocol": "sftp", - "path": "={{ $('Merge Adapt Into Payload').item.json.video_4x5_sftp }}", - "options": {} + "fileSelector": "={{ $('Merge Adapt Into Payload').item.json.video_4x5_sftp }}", + "options": { + "dataPropertyName": "data" + } }, "id": "6a71a2d9-aa28-46ad-afa9-44e0071172c5", "name": "Download Reel IG", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 672, -32 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { @@ -332,24 +322,19 @@ }, { "parameters": { - "protocol": "sftp", - "path": "={{ $('Merge Adapt Into Payload').item.json.video_4x5_sftp }}", - "options": {} + "fileSelector": "={{ $('Merge Adapt Into Payload').item.json.video_4x5_sftp }}", + "options": { + "dataPropertyName": "data" + } }, "id": "7f8e4be0-8c5b-4e1e-93e2-784c7f02aedb", "name": "Download Reel X", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 0, 576 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { @@ -661,24 +646,19 @@ }, { "parameters": { - "protocol": "sftp", - "path": "={{ $('Merge Adapt Into Payload').item.json.video_9x16_sftp }}", - "options": {} + "fileSelector": "={{ $('Merge Adapt Into Payload').item.json.video_9x16_sftp }}", + "options": { + "dataPropertyName": "data" + } }, "id": "552b087a-94f1-40c8-858b-076f00db6a17", "name": "Download Reel YT", - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1.1, "position": [ 1568, -224 - ], - "credentials": { - "sftp": { - "id": "FBrOT9WTXTtyPdxi", - "name": "FTP account" - } - } + ] }, { "parameters": { diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 4d978dd..6f597f4 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -42,26 +42,10 @@ for name in \ N8N_OWNER_EMAIL \ N8N_OWNER_PASSWORD \ OPENAI_API_KEY \ - POSTIZ_API_KEY \ - SFTP_HOST \ - SFTP_USERNAME; do + POSTIZ_API_KEY; do need_env "$name" done -private_key="$(resolve_from_root "${SFTP_PRIVATE_KEY_FILE:-secrets/sftp_n8n_ed25519}")" -keys_dir="$(resolve_from_root "${SFTP_KEYS_DIR:-sftp/keys}")" - -for path in "$private_key" "$keys_dir/n8n.pub"; do - if [[ ! -e "$path" ]]; then - echo "Missing generated setup artifact: $path" >&2 - failed=1 - fi -done - -if [[ "$failed" -ne 0 ]]; then - exit 1 -fi - compose config --quiet if [[ "${N8N_HOST:-localhost}" != "localhost" && \ @@ -71,4 +55,4 @@ if [[ "${N8N_HOST:-localhost}" != "localhost" && \ echo "Warning: n8n is configured for non-local HTTP without TLS." >&2 fi -echo "Host, configuration, and generated artifacts are valid." +echo "Host and configuration are valid." diff --git a/scripts/ensure-sftp-keys.sh b/scripts/ensure-sftp-keys.sh deleted file mode 100755 index bc3fa04..0000000 --- a/scripts/ensure-sftp-keys.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -# Idempotently create the n8n↔sftp client keypair and authorized public key. -# Safe to run before every `docker compose up`. -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# shellcheck source=scripts/lib.sh -source "$ROOT/scripts/lib.sh" - -if [[ -f "$ENV_FILE" ]]; then - load_env -fi - -key_file="${SFTP_PRIVATE_KEY_FILE:-./secrets/sftp_n8n_ed25519}" -key_file="$(resolve_from_root "$key_file")" -keys_dir="$(resolve_from_root "${SFTP_KEYS_DIR:-sftp/keys}")" -pub_file="$keys_dir/n8n.pub" - -mkdir -p "$(dirname "$key_file")" "$keys_dir" - -if [[ ! -f "$key_file" ]]; then - echo "Generating SFTP client key: $key_file" - ssh-keygen -t ed25519 -f "$key_file" -N '' -C 'syndicator-n8n' "$pub_file" -chmod 644 "$pub_file" -# ssh-keygen also writes key_file.pub on create; keep a single canonical pubkey path. -rm -f "${key_file}.pub" -echo "Authorized public key: $pub_file" diff --git a/scripts/init.sh b/scripts/init.sh index d424d30..49c00e3 100755 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -40,9 +40,7 @@ for name in \ N8N_OWNER_EMAIL \ N8N_OWNER_PASSWORD \ OPENAI_API_KEY \ - POSTIZ_API_KEY \ - SFTP_HOST \ - SFTP_USERNAME; do + POSTIZ_API_KEY; do if ! need_env "$name"; then missing=1 fi @@ -55,6 +53,4 @@ if [[ "$missing" -ne 0 ]]; then exit 2 fi -"$ROOT/scripts/ensure-sftp-keys.sh" - echo "Initialization is complete." diff --git a/scripts/verify.sh b/scripts/verify.sh index 9f1e74a..becd7a5 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -20,17 +20,21 @@ echo "pyautoflip is reachable from n8n." tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT -private_key="$(resolve_from_root "${SFTP_PRIVATE_KEY_FILE:-secrets/sftp_n8n_ed25519}")" -sftp_port="${SFTP_PUBLISH_PORT:-2222}" -ssh-keyscan -p "$sftp_port" 127.0.0.1 >"$tmp/known_hosts" 2>/dev/null -printf 'pwd\nquit\n' | sftp -q -b - \ - -P "$sftp_port" \ - -i "$private_key" \ - -o BatchMode=yes \ - -o IdentitiesOnly=yes \ - -o StrictHostKeyChecking=yes \ - -o "UserKnownHostsFile=$tmp/known_hosts" \ - "${SFTP_USERNAME}@127.0.0.1" >/dev/null -echo "SFTP key authentication is valid." +client_key="$(resolve_from_root "${SFTP_CLIENT_KEY_FILE:-secrets/sftp_client_ed25519}")" +if [[ ! -f "$client_key" ]]; then + echo "Skipping SFTP check; no client key at $client_key." +else + sftp_port="${SFTP_PUBLISH_PORT:-2222}" + ssh-keyscan -p "$sftp_port" 127.0.0.1 >"$tmp/known_hosts" 2>/dev/null + printf 'pwd\nquit\n' | sftp -q -b - \ + -P "$sftp_port" \ + -i "$client_key" \ + -o BatchMode=yes \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + -o "UserKnownHostsFile=$tmp/known_hosts" \ + "sftp@127.0.0.1" >/dev/null + echo "SFTP key authentication is valid." +fi echo "Syndicator verification complete." diff --git a/sftp/authorized_keys.example b/sftp/authorized_keys.example index 08e4055..34ce5e3 100644 --- a/sftp/authorized_keys.example +++ b/sftp/authorized_keys.example @@ -1,3 +1,3 @@ # Example OpenSSH public key (replace with a real key before compose up). -# Copy to this directory as e.g. mac.pub or n8n.pub — filenames are arbitrary. +# Copy to this directory as e.g. mac.pub or laptop.pub — filenames are arbitrary. ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleReplaceWithRealPublicKey syndicator-example diff --git a/sftp/setup.sh b/sftp/setup.sh index 8d53b27..d6c9375 100755 --- a/sftp/setup.sh +++ b/sftp/setup.sh @@ -4,8 +4,8 @@ set -Eeo pipefail HOST_KEY_DIR="${SFTP_HOST_KEY_DIR:-/etc/ssh/host_keys}" -SFTP_UID="${SFTP_UID:-1001}" -SFTP_GID="${SFTP_GID:-100}" +SFTP_UID="${SFTP_UID:-1000}" +SFTP_GID="${SFTP_GID:-1000}" DATA_DIR="${SFTP_DATA_DIR:-/home/sftp/syndicator}" mkdir -p "$HOST_KEY_DIR" diff --git a/tests/integration/stack.sh b/tests/integration/stack.sh index 7effde3..30eef59 100755 --- a/tests/integration/stack.sh +++ b/tests/integration/stack.sh @@ -21,6 +21,11 @@ PY n8n_port="$(free_port)" sftp_port="$(free_port)" +mkdir -p "$tmp/keys" +ssh-keygen -t ed25519 -f "$tmp/sftp_client" -N '' -C 'syndicator-it' "$env_file" < None: }, ) + def test_workflows_use_local_files_instead_of_ftp(self) -> None: + for path, workflow in self.workflows.items(): + for node in workflow.get("nodes", []): + self.assertNotEqual( + node.get("type"), + "n8n-nodes-base.ftp", + f"{path.name}: {node.get('name')}", + ) + self.assertNotIn( + "sftp", + node.get("credentials") or {}, + f"{path.name}: {node.get('name')}", + ) + def test_workflow_names_and_ids_are_unique(self) -> None: ids: list[str] = [] for path, workflow in self.workflows.items(): From cc37b1d6c23cb6653e07656932adaab9f3689943 Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Thu, 13 Aug 2026 19:58:16 +0200 Subject: [PATCH 12/16] Drop release-state files and host dotenv parsing from deploy. Compose now owns env interpolation and image tags; a failed deploy still stops unverified services without a pending-state sidecar. Co-authored-by: Cursor --- .env.example | 3 -- bin/syndicator | 4 -- docs/operations.md | 25 ++++-------- scripts/deploy.sh | 62 +++-------------------------- scripts/doctor.sh | 21 ++-------- scripts/dotenv.py | 78 ------------------------------------- scripts/export-workflows.sh | 1 - scripts/init.sh | 67 ++++++++++++++++++------------- scripts/lib.sh | 76 +----------------------------------- scripts/verify.sh | 4 +- tests/integration/stack.sh | 16 +------- tests/test_repository.py | 33 ---------------- 12 files changed, 63 insertions(+), 327 deletions(-) delete mode 100755 scripts/dotenv.py diff --git a/.env.example b/.env.example index edc6829..455707d 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,3 @@ POSTIZ_API_KEY= # SYNDICATOR_IMAGE_TAG=local # PYAUTOFLIP_WARM_MODELS=1 # SFTP_PLATFORM=linux/amd64 - -# --- Operations --- -# SYNDICATOR_RELEASE_STATE_FILE=secrets/release.env diff --git a/bin/syndicator b/bin/syndicator index 71d4f7f..957ff30 100755 --- a/bin/syndicator +++ b/bin/syndicator @@ -36,7 +36,6 @@ case "$command" in exec "$ROOT/scripts/deploy.sh" "$@" ;; bootstrap) - load_env wait_for_n8n run_reconcile ;; @@ -51,16 +50,13 @@ case "$command" in echo "Usage: bin/syndicator restart SERVICE..." >&2 exit 2 fi - load_env compose restart "$@" "$ROOT/scripts/verify.sh" ;; status) - load_env compose ps "$@" ;; logs) - load_env compose logs -f "$@" ;; help | --help | -h) diff --git a/docs/operations.md b/docs/operations.md index 17867b2..bac1966 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -35,9 +35,9 @@ with a list of values that still need input. Fill in: - Postiz API key - the public URL and bind addresses appropriate for the host -The lifecycle parses `.env` as data, never as shell code. Quote values that -contain spaces or `#`; single quotes preserve `$`, backticks, and other -characters literally. +The lifecycle passes `.env` to Compose as an env file, never as shell code. Quote +values that contain spaces or `#`; single quotes preserve `$`, backticks, and +other characters literally. Then deploy: @@ -50,9 +50,8 @@ inputs, starts the stack, waits for the in-container n8n reconcile, and runs end-to-end health checks. Running it again is safe. If source configuration is unchanged and all workflows remain published, n8n import is skipped. -The first controlled deployment writes `secrets/release.env`. Unless -`SYNDICATOR_IMAGE_TAG` is explicitly set, images use the current 12-character -Git revision as their tag. +Unless `SYNDICATOR_IMAGE_TAG` is set, images use the current 12-character Git +revision as their tag. ## Local and network configuration @@ -150,16 +149,8 @@ stack, reconciles n8n, and verifies. Instance volumes are not snapshotted; callers keep working when `.env`, SFTP host keys, and authorized client keys stay in place. -An explicit tag is available for release testing: - -```bash -bin/syndicator deploy --tag release-candidate-1 -``` - -If bootstrap or verification fails, the new services are stopped and pending -details remain in `secrets/release.env.pending`. The last healthy release state -is not overwritten. Do not simply restart the failed containers; fix the -checkout and deploy again. +If reconcile or verification fails, the new services are stopped. Do not simply +restart the failed containers; fix the checkout and deploy again. ## Disaster recovery @@ -202,7 +193,7 @@ The stack integration test uses random loopback ports and a unique Compose project. It deploys twice, checks that an unchanged reconcile is skipped, uploads over SFTP, and removes all test containers and volumes. A deliberately failed release also verifies that unverified containers are -stopped and pending recovery state is recorded. A separate Buildx job verifies +stopped. A separate Buildx job verifies n8n and pyautoflip for Linux arm64. ## Troubleshooting diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 9ae72a4..c0b1ff2 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -6,22 +6,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" source "$ROOT/scripts/lib.sh" pull=0 -requested_tag="" while [[ "$#" -gt 0 ]]; do case "$1" in --pull) pull=1 ;; - --tag) - if [[ -z "${2:-}" ]]; then - echo "--tag requires a value." >&2 - exit 2 - fi - requested_tag="$2" - shift - ;; *) - echo "Usage: $0 [--pull] [--tag TAG]" >&2 + echo "Usage: $0 [--pull]" >&2 exit 2 ;; esac @@ -30,53 +21,18 @@ done "$ROOT/scripts/init.sh" "$ROOT/scripts/doctor.sh" --require-config -load_env - -load_release_state -old_tag="${CURRENT_TAG:-}" -old_revision="${CURRENT_GIT_REVISION:-}" -desired_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" -if [[ "${SYNDICATOR_ALLOW_DIRTY:-0}" != "1" ]] && \ - [[ -n "$(git status --porcelain 2>/dev/null || true)" ]]; then - echo "Refusing to build a release from a dirty working tree." >&2 - exit 1 +if [[ -z "${SYNDICATOR_IMAGE_TAG:-}" ]]; then + SYNDICATOR_IMAGE_TAG="$(git rev-parse --short=12 HEAD 2>/dev/null || printf 'local')" + export SYNDICATOR_IMAGE_TAG fi -if [[ -n "$requested_tag" ]]; then - desired_tag="$requested_tag" -elif [[ -n "${SYNDICATOR_IMAGE_TAG:-}" ]]; then - desired_tag="$SYNDICATOR_IMAGE_TAG" -else - desired_tag="$(git rev-parse --short=12 HEAD 2>/dev/null || printf 'local')" -fi -if [[ ! "$desired_tag" =~ ^[a-zA-Z0-9_.-]+$ ]]; then - echo "Invalid image tag: $desired_tag" >&2 - exit 1 -fi -if [[ -n "$old_tag" && "$old_tag" == "$desired_tag" && \ - -n "$old_revision" && "$old_revision" != "$desired_revision" ]]; then - echo "Image tag $desired_tag already belongs to Git revision $old_revision." >&2 - echo "Use a new --tag value for revision $desired_revision." >&2 - exit 1 -fi - -export SYNDICATOR_IMAGE_TAG="$desired_tag" if [[ "$pull" -eq 1 ]]; then compose build --pull else compose build fi -pending_state="$(pending_release_file)" -mkdir -p "$(dirname "$pending_state")" -umask 077 -{ - printf 'PENDING_TAG=%q\n' "$desired_tag" - printf 'PENDING_GIT_REVISION=%q\n' "$desired_revision" -} >"$pending_state" -chmod 600 "$pending_state" - runtime_mutated=0 deployment_cleanup() { status=$? @@ -86,11 +42,6 @@ deployment_cleanup() { else echo "Deployment failed and automatic service shutdown also failed." >&2 fi - for service in n8n pyautoflip sftp; do - if [[ -n "$(compose ps --status running -q "$service" 2>/dev/null || true)" ]]; then - echo "Unverified service is still running: $service" >&2 - fi - done fi exit "$status" } @@ -106,8 +57,5 @@ wait_for_n8n run_reconcile "$ROOT/scripts/verify.sh" -write_release_state "$desired_tag" "$desired_revision" -rm -f "$pending_state" trap - EXIT - -echo "Deployment $desired_tag is healthy." +echo "Deployment ${SYNDICATOR_IMAGE_TAG} is healthy." diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 6f597f4..85482e8 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -36,23 +36,10 @@ if [[ "$require_config" -eq 0 ]]; then exit 0 fi -load_env -for name in \ - N8N_ENCRYPTION_KEY \ - N8N_OWNER_EMAIL \ - N8N_OWNER_PASSWORD \ - OPENAI_API_KEY \ - POSTIZ_API_KEY; do - need_env "$name" -done - -compose config --quiet - -if [[ "${N8N_HOST:-localhost}" != "localhost" && \ - "${N8N_HOST:-localhost}" != "127.0.0.1" && \ - "${N8N_HOST:-localhost}" != "::1" && \ - "${N8N_PROTOCOL:-http}" != "https" ]]; then - echo "Warning: n8n is configured for non-local HTTP without TLS." >&2 +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing environment file: $ENV_FILE" >&2 + exit 1 fi +compose config --quiet echo "Host and configuration are valid." diff --git a/scripts/dotenv.py b/scripts/dotenv.py deleted file mode 100755 index 402ea87..0000000 --- a/scripts/dotenv.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -"""Parse the supported Compose dotenv subset without evaluating shell code.""" - -from __future__ import annotations - -import json -import re -import sys -from pathlib import Path - - -NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") - - -def parse_value(raw: str, line_number: int) -> str: - value = raw.strip() - if not value: - return "" - - if value.startswith("'"): - end = value.find("'", 1) - if end < 0 or value[end + 1 :].strip().lstrip("#").strip(): - raise ValueError(f"line {line_number}: invalid single-quoted value") - return value[1:end] - - if value.startswith('"'): - decoder = json.JSONDecoder() - try: - parsed, end = decoder.raw_decode(value) - except json.JSONDecodeError as exc: - raise ValueError(f"line {line_number}: invalid double-quoted value") from exc - if not isinstance(parsed, str): - raise ValueError(f"line {line_number}: expected a string value") - if value[end:].strip().lstrip("#").strip(): - raise ValueError(f"line {line_number}: content after quoted value") - return parsed - - value = re.split(r"\s+#", value, maxsplit=1)[0].rstrip() - return value - - -def parse(path: Path) -> list[tuple[str, str]]: - values: dict[str, str] = {} - for line_number, raw_line in enumerate( - path.read_text(encoding="utf-8").splitlines(), start=1 - ): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - if line.startswith("export "): - line = line[7:].lstrip() - if "=" not in line: - raise ValueError(f"line {line_number}: expected NAME=VALUE") - name, raw_value = line.split("=", 1) - name = name.strip() - if not NAME.fullmatch(name): - raise ValueError(f"line {line_number}: invalid variable name {name!r}") - values[name] = parse_value(raw_value, line_number) - return list(values.items()) - - -def main() -> int: - if len(sys.argv) != 2: - print(f"Usage: {sys.argv[0]} FILE", file=sys.stderr) - return 2 - try: - values = parse(Path(sys.argv[1])) - except (OSError, UnicodeError, ValueError) as exc: - print(f"{sys.argv[1]}: {exc}", file=sys.stderr) - return 1 - output = sys.stdout.buffer - for name, value in values: - output.write(name.encode() + b"\0" + value.encode() + b"\0") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/export-workflows.sh b/scripts/export-workflows.sh index e409d15..ad4e5eb 100755 --- a/scripts/export-workflows.sh +++ b/scripts/export-workflows.sh @@ -6,7 +6,6 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" # shellcheck source=scripts/lib.sh source "$ROOT/scripts/lib.sh" -load_env OUT_DIR="$ROOT/n8n/workflows" mkdir -p "$OUT_DIR" diff --git a/scripts/init.sh b/scripts/init.sh index 49c00e3..d4c7cad 100755 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -13,44 +13,57 @@ if [[ ! -f "$ENV_FILE" ]]; then fi chmod 600 "$ENV_FILE" -load_env -if [[ -z "${N8N_ENCRYPTION_KEY:-}" ]]; then - encryption_key="$(openssl rand -hex 32)" - python3 - "$ENV_FILE" "$encryption_key" <<'PY' +status=0 +python3 - "$ENV_FILE" <<'PY' || status=$? from pathlib import Path +import secrets import sys path = Path(sys.argv[1]) -replacement = f"N8N_ENCRYPTION_KEY={sys.argv[2]}" +required = ( + "N8N_ENCRYPTION_KEY", + "N8N_OWNER_EMAIL", + "N8N_OWNER_PASSWORD", + "OPENAI_API_KEY", + "POSTIZ_API_KEY", +) +values: dict[str, str] = {} lines = path.read_text(encoding="utf-8").splitlines() +key_index = None for index, line in enumerate(lines): - if line.startswith("N8N_ENCRYPTION_KEY="): - lines[index] = replacement - break -else: - lines.append(replacement) -path.write_text("\n".join(lines) + "\n", encoding="utf-8") -PY - load_env - echo "Generated N8N_ENCRYPTION_KEY in $ENV_FILE" -fi + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in line: + continue + name, value = line.split("=", 1) + name = name.strip() + values[name] = value + if name == "N8N_ENCRYPTION_KEY": + key_index = index -missing=0 -for name in \ - N8N_OWNER_EMAIL \ - N8N_OWNER_PASSWORD \ - OPENAI_API_KEY \ - POSTIZ_API_KEY; do - if ! need_env "$name"; then - missing=1 - fi -done +if not values.get("N8N_ENCRYPTION_KEY"): + generated = secrets.token_hex(32) + replacement = f"N8N_ENCRYPTION_KEY={generated}" + if key_index is None: + lines.append(replacement) + else: + lines[key_index] = replacement + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + values["N8N_ENCRYPTION_KEY"] = generated + print(f"Generated N8N_ENCRYPTION_KEY in {path}") -if [[ "$missing" -ne 0 ]]; then +missing = [name for name in required if not values.get(name)] +if missing: + for name in missing: + print(f"Missing required environment value: {name}", file=sys.stderr) + raise SystemExit(2) +PY +if [[ "$status" -eq 2 ]]; then if [[ "$created" -eq 1 ]]; then - echo "Created $ENV_FILE. Fill the values above, then run init again." >&2 + echo "Created $ENV_FILE. Fill the remaining values, then run init again." >&2 fi exit 2 +elif [[ "$status" -ne 0 ]]; then + exit "$status" fi echo "Initialization is complete." diff --git a/scripts/lib.sh b/scripts/lib.sh index c1ca1cb..0bf2081 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -3,40 +3,9 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SOURCE_ROOT="${SYNDICATOR_SOURCE_ROOT:-$ROOT}" ENV_FILE="${SYNDICATOR_ENV_FILE:-$ROOT/.env}" -SYNDICATOR_LOADED_ENV_KEYS=() cd "$ROOT" || exit 1 -load_env() { - local parsed key value - if [[ ! -f "$ENV_FILE" ]]; then - echo "Missing environment file: $ENV_FILE" >&2 - return 1 - fi - for key in "${SYNDICATOR_LOADED_ENV_KEYS[@]+"${SYNDICATOR_LOADED_ENV_KEYS[@]}"}"; do - unset "$key" - done - SYNDICATOR_LOADED_ENV_KEYS=() - parsed="$(mktemp)" - if ! python3 "$ROOT/scripts/dotenv.py" "$ENV_FILE" >"$parsed"; then - rm -f "$parsed" - return 1 - fi - while IFS= read -r -d '' key && IFS= read -r -d '' value; do - export "$key=$value" - SYNDICATOR_LOADED_ENV_KEYS+=("$key") - done <"$parsed" - rm -f "$parsed" -} - -need_env() { - local name="$1" - if [[ -z "${!name:-}" ]]; then - echo "Missing required environment value: $name" >&2 - return 1 - fi -} - resolve_from_root() { local path="$1" if [[ "$path" = /* ]]; then @@ -46,54 +15,13 @@ resolve_from_root() { fi } -release_state_file() { - resolve_from_root "${SYNDICATOR_RELEASE_STATE_FILE:-secrets/release.env}" -} - -pending_release_file() { - printf '%s.pending\n' "$(release_state_file)" -} - -load_release_state() { - local state - state="$(release_state_file)" - if [[ -s "$state" ]]; then - # shellcheck source=/dev/null - source "$state" - fi -} - -write_release_state() { - local current="$1" - local current_revision="${2:-}" - local state temporary_state - if [[ -z "$current_revision" ]]; then - current_revision="$(git rev-parse HEAD 2>/dev/null || printf 'unknown')" - fi - state="$(release_state_file)" - mkdir -p "$(dirname "$state")" - umask 077 - temporary_state="${state}.tmp.$$" - { - printf 'CURRENT_TAG=%q\n' "$current" - printf 'CURRENT_GIT_REVISION=%q\n' "$current_revision" - printf 'DEPLOYED_AT=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" - } >"$temporary_state" - chmod 600 "$temporary_state" - mv "$temporary_state" "$state" -} - compose() { local args=( --project-directory "$SOURCE_ROOT" -f "$SOURCE_ROOT/docker-compose.yml" - --env-file "$ENV_FILE" ) - if [[ -z "${SYNDICATOR_IMAGE_TAG:-}" ]]; then - load_release_state - if [[ -n "${CURRENT_TAG:-}" ]]; then - export SYNDICATOR_IMAGE_TAG="$CURRENT_TAG" - fi + if [[ -f "$ENV_FILE" ]]; then + args+=(--env-file "$ENV_FILE") fi if [[ -n "${SYNDICATOR_PROJECT:-}" ]]; then args+=(-p "$SYNDICATOR_PROJECT") diff --git a/scripts/verify.sh b/scripts/verify.sh index becd7a5..7aa9edd 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -5,7 +5,6 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" # shellcheck source=scripts/lib.sh source "$ROOT/scripts/lib.sh" -load_env wait_for_n8n 30 2 run_reconcile @@ -24,7 +23,8 @@ client_key="$(resolve_from_root "${SFTP_CLIENT_KEY_FILE:-secrets/sftp_client_ed2 if [[ ! -f "$client_key" ]]; then echo "Skipping SFTP check; no client key at $client_key." else - sftp_port="${SFTP_PUBLISH_PORT:-2222}" + published="$(compose port sftp 22)" + sftp_port="${published##*:}" ssh-keyscan -p "$sftp_port" 127.0.0.1 >"$tmp/known_hosts" 2>/dev/null printf 'pwd\nquit\n' | sftp -q -b - \ -P "$sftp_port" \ diff --git a/tests/integration/stack.sh b/tests/integration/stack.sh index 30eef59..74de799 100755 --- a/tests/integration/stack.sh +++ b/tests/integration/stack.sh @@ -25,6 +25,7 @@ mkdir -p "$tmp/keys" ssh-keygen -t ed25519 -f "$tmp/sftp_client" -N '' -C 'syndicator-it' "$env_file" <>"$env_file" set +e - "$ROOT/bin/syndicator" deploy --tag integration-failure \ + "$ROOT/bin/syndicator" deploy \ >"$tmp/failed-deploy.log" 2>&1 failed_status=$? set -e @@ -76,16 +74,6 @@ test_failed_deployment() { echo "Deliberately invalid deployment unexpectedly succeeded." >&2 exit 1 fi - if [[ ! -s "$tmp/release.env.pending" ]]; then - echo "Failed deployment did not record pending recovery state." >&2 - python3 - "$tmp/failed-deploy.log" <<'PY' >&2 -from pathlib import Path -import sys - -print(Path(sys.argv[1]).read_text(encoding="utf-8")) -PY - exit 1 - fi if [[ -n "$(docker compose --env-file "$env_file" -p "$project" \ ps --status running -q n8n)" ]]; then echo "Failed deployment left unverified n8n running." >&2 diff --git a/tests/test_repository.py b/tests/test_repository.py index 910e9ed..474a15a 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -3,7 +3,6 @@ import json import re import subprocess -import tempfile import unittest from pathlib import Path from typing import Any @@ -198,8 +197,6 @@ def test_reconcile_uses_supported_interfaces(self) -> None: self.assertNotIn("PUBLISH_WORKFLOW_IDS", reconcile) library = (ROOT / "scripts" / "lib.sh").read_text(encoding="utf-8") self.assertIn("/healthz/readiness", library) - - def test_local_markdown_links_resolve(self) -> None: link_pattern = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") for document in ROOT.rglob("*.md"): if {".git", ".venv", "node_modules"}.intersection(document.parts): @@ -214,36 +211,6 @@ def test_local_markdown_links_resolve(self) -> None: path = (document.parent / unquote(parsed.path)).resolve() self.assertTrue(path.exists(), f"{document}: broken link {target}") - def test_dotenv_parser_does_not_evaluate_values(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - marker = root / "executed" - dotenv = root / ".env" - dotenv.write_text( - "\n".join( - [ - f"LITERAL='$(touch {marker})'", - "DOLLAR=prefix$HOME", - 'SPACED="hello world"', - "COMMENTED=value # ignored", - ] - ) - + "\n", - encoding="utf-8", - ) - result = subprocess.run( - ["python3", str(ROOT / "scripts" / "dotenv.py"), str(dotenv)], - check=True, - capture_output=True, - ) - fields = result.stdout.split(b"\0") - values = dict(zip(fields[0::2], fields[1::2])) - self.assertEqual(values[b"LITERAL"], f"$(touch {marker})".encode()) - self.assertEqual(values[b"DOLLAR"], b"prefix$HOME") - self.assertEqual(values[b"SPACED"], b"hello world") - self.assertEqual(values[b"COMMENTED"], b"value") - self.assertFalse(marker.exists()) - if __name__ == "__main__": unittest.main() From c15f9436d8308c637e578d9311bebf7bda45d73b Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Thu, 13 Aug 2026 20:01:40 +0200 Subject: [PATCH 13/16] Shrink the operator CLI to verify, export, and logs. Instantiate with Compose instead of a deploy wrapper, and check webhook readiness rather than API-key files. Co-authored-by: Cursor --- .env.example | 2 +- README.md | 21 ++++---- bin/syndicator | 33 +------------ docker-compose.yml | 5 +- docs/adr/0001-deployment-model.md | 15 +++--- docs/adr/0002-disposable-instances.md | 20 ++++---- docs/operations.md | 71 +++++++++++++-------------- pyautoflip/README.md | 3 +- scripts/deploy.sh | 61 ----------------------- scripts/verify.sh | 18 ++++++- tests/integration/stack.sh | 46 +++++------------ tests/test-init.sh | 2 +- 12 files changed, 97 insertions(+), 200 deletions(-) delete mode 100755 scripts/deploy.sh diff --git a/.env.example b/.env.example index 455707d..a8baf2d 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ N8N_PROXY_HOPS=0 # Encryption key for credentials at rest. # Reusing the existing n8n_data volume: copy encryptionKey from # docker compose exec -u node n8n cat /home/node/.n8n/config -# Fresh volume: `bin/syndicator init` generates a 256-bit key. +# Fresh volume: `scripts/init.sh` generates a 256-bit key. N8N_ENCRYPTION_KEY= # Instance owner (provisioned via N8N_INSTANCE_OWNER_* on n8n start). diff --git a/README.md b/README.md index a6f110f..41812a8 100644 --- a/README.md +++ b/README.md @@ -156,16 +156,17 @@ Once Syndicator has finished processing Blog Post Publish the static Hugo post c ## Setup ```bash -bin/syndicator init +scripts/init.sh # Fill the values requested in .env, then: -bin/syndicator deploy +docker compose up -d --build +bin/syndicator verify ``` -`deploy` checks prerequisites, generates local-only keys, builds and starts the stack, reconciles n8n credentials/workflows inside Compose, and verifies n8n, pyautoflip, and SFTP. It is safe to run repeatedly; an unchanged bootstrap is skipped. +`init.sh` creates `.env` and an encryption key. Compose builds and starts the stack. `verify` reconciles n8n credentials and workflows inside Compose, then checks n8n, webhook registration, pyautoflip, and SFTP. It is safe to run repeatedly; an unchanged reconcile is skipped. Owner account is provisioned from env on n8n start (`N8N_INSTANCE_OWNER_*`). After n8n is healthy, the `n8n-reconcile` service logs in with `N8N_OWNER_EMAIL` / `N8N_OWNER_PASSWORD`, imports credentials and workflows from git, and publishes webhooks. UI login uses the same owner credentials. -`init` creates `.env` and an encryption key. Authorize callers by copying a `.pub` into `sftp/keys/` and running `bin/syndicator restart sftp`. Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`. +Authorize callers by copying a `.pub` into `sftp/keys/` and recreating the SFTP service (`docker compose up -d --force-recreate sftp`). Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`. Published ports bind to loopback by default. Read the [operations runbook](docs/operations.md) before enabling LAN or internet access. @@ -179,15 +180,15 @@ The `files-init` Compose service chowns the shared `n8n_files` and `sftp_data` v Instances are disposable. `.env`, SFTP host keys, and authorized client keys are identity; everything else can be rebuilt from git. -Pull a reviewed revision and run `bin/syndicator deploy` (add `--pull` to refresh base images). A failed deploy stops the unverified services; fix the checkout and deploy again. +Pull a reviewed revision and run `docker compose up -d --build --pull always`, then `bin/syndicator verify`. If verify fails, bring the stack down, fix the checkout, and start again. -Disaster recovery is a new instance: reprovide `.env`, run `init` and `deploy`, and regenerate SFTP keys unless you kept them outside Syndicator. Callers may need to accept a new SSH host key and re-upload files. +Disaster recovery is a new instance: reprovide `.env`, run `scripts/init.sh` and `docker compose up -d --build`, then `bin/syndicator verify`. Regenerate SFTP keys unless you kept them outside Syndicator. Callers may need to accept a new SSH host key and re-upload files. ## Architecture The workflow engine, n8n, orchestrates all blog post processing via modular workflows. The most important non-functional requirements are repeatability, testability, automation, and maintainability. The initial custom pipeline became difficult to change, which motivated decomposing processing into visible workflow nodes. -Compose remains the application boundary because it isolates three different runtimes and provides the same topology on macOS and Linux. The operator lifecycle is intentionally separate and tested through `bin/syndicator`. The rationale and rejected alternatives are recorded in [ADR 0001](docs/adr/0001-deployment-model.md); disposable instances are [ADR 0002](docs/adr/0002-disposable-instances.md). +Compose remains the application boundary because it isolates three different runtimes and provides the same topology on macOS and Linux. Instantiate with Compose; `bin/syndicator` covers verify, export, and logs. The rationale and rejected alternatives are recorded in [ADR 0001](docs/adr/0001-deployment-model.md); disposable instances are [ADR 0002](docs/adr/0002-disposable-instances.md). ## Software Design @@ -204,12 +205,12 @@ The repo is the blueprint for a containerized instance: Compose defines the stac | `n8n/Dockerfile` | Custom n8n image (`ffmpeg` + community node seed + reconcile) | | `n8n/reconcile.js` | In-container credential/workflow import and webhook publish | | `sftp/setup.sh` | Supported atmoz startup hook for durable host keys, key sync, and ownership | -| `scripts/` | Focused lifecycle implementations behind `bin/syndicator` | +| `scripts/` | Init, doctor, verify, and export helpers | | `n8n/workflows/` | Importable workflow exports (source of truth) | | `n8n/credentials/` | Credential templates (stable IDs; secrets from `.env`) | | `pyautoflip/` | Image/build context for the reframe sidecar | | `sftp/keys/` | Authorized client public keys (refreshed into `authorized_keys` on each sftp start) | -| `bin/syndicator` | Checked lifecycle: init, deploy, bootstrap, verify, export | +| `bin/syndicator` | Operator CLI: verify, export, logs | ``` docker-compose.yml @@ -220,7 +221,7 @@ n8n/workflows/ n8n/credentials/*.template.json pyautoflip/ sftp/ -scripts/{init,deploy,verify,export}.sh +scripts/{init,doctor,verify,export}.sh docs/{operations.md,adr/} bin/syndicator ``` diff --git a/bin/syndicator b/bin/syndicator index 957ff30..863cd95 100755 --- a/bin/syndicator +++ b/bin/syndicator @@ -9,15 +9,8 @@ usage() { cat <<'EOF' Usage: bin/syndicator -Lifecycle: - doctor Check host prerequisites - init Create and validate local configuration and keys - deploy Build, start, reconcile, and verify the stack - bootstrap Re-run in-container n8n credential and workflow reconcile - verify Verify health, reconcile, pyautoflip, and SFTP + verify Reconcile n8n, then check health, webhooks, pyautoflip, and SFTP export Export sanitized workflows from n8n - restart Restart one or more services, then verify - status Show Compose service status logs Follow Compose service logs EOF } @@ -26,36 +19,12 @@ command="${1:-help}" shift || true case "$command" in - doctor) - exec "$ROOT/scripts/doctor.sh" "$@" - ;; - init) - exec "$ROOT/scripts/init.sh" "$@" - ;; - deploy) - exec "$ROOT/scripts/deploy.sh" "$@" - ;; - bootstrap) - wait_for_n8n - run_reconcile - ;; verify) exec "$ROOT/scripts/verify.sh" "$@" ;; export) exec "$ROOT/scripts/export-workflows.sh" "$@" ;; - restart) - if [[ "$#" -eq 0 ]]; then - echo "Usage: bin/syndicator restart SERVICE..." >&2 - exit 2 - fi - compose restart "$@" - "$ROOT/scripts/verify.sh" - ;; - status) - compose ps "$@" - ;; logs) compose logs -f "$@" ;; diff --git a/docker-compose.yml b/docker-compose.yml index 44fa412..8f441a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,8 @@ # Phase 1 syndicator stack: SFTP staging + n8n (regular/SQLite) + pyautoflip. # Usage (from this directory): -# bin/syndicator init # fill the requested values in .env -# bin/syndicator deploy +# scripts/init.sh # fill the requested values in .env +# docker compose up -d --build +# bin/syndicator verify name: syndicator diff --git a/docs/adr/0001-deployment-model.md b/docs/adr/0001-deployment-model.md index bf8a3f0..606e471 100644 --- a/docs/adr/0001-deployment-model.md +++ b/docs/adr/0001-deployment-model.md @@ -29,19 +29,18 @@ Keep Docker Compose as the application packaging and runtime boundary. packages, and model preparation. - Compose owns service networking, health, startup dependencies, ports, and persistent volumes. -- `bin/syndicator` is the only operator-facing lifecycle. It delegates to - focused scripts for initialization, deployment, reconciliation, and - verification. +- Operators instantiate with Compose. `bin/syndicator` covers verify, export, + and logs; `scripts/init.sh` creates `.env`. - Runtime inputs are pinned. Dependency changes arrive as reviewable pull requests and must pass an isolated full-stack test before deployment. -- Each changed release is tagged by Git revision. Application volumes are - disposable; identity (`.env` and SFTP keys) is supplied at instantiate time. - Volume backup and rollback are out of scope; see - [ADR 0002](0002-disposable-instances.md). +- Application volumes are disposable; identity (`.env` and SFTP keys) is + supplied at instantiate time. Volume backup and rollback are out of scope; + see [ADR 0002](0002-disposable-instances.md). Ansible may be added outside this boundary to prepare a Linux host: install Docker, configure a firewall or reverse proxy, place the repository and -encrypted secrets, and invoke `bin/syndicator deploy`. It must not reproduce +encrypted secrets, and run `docker compose up -d --build` plus +`bin/syndicator verify`. It must not reproduce the application installation, workflow import, or update logic. Terraform is reserved for infrastructure resources such as a VM, DNS records, diff --git a/docs/adr/0002-disposable-instances.md b/docs/adr/0002-disposable-instances.md index acb4e8e..f74a4a4 100644 --- a/docs/adr/0002-disposable-instances.md +++ b/docs/adr/0002-disposable-instances.md @@ -11,7 +11,7 @@ That assumed n8n SQLite, SFTP uploads, and host keys were unique state that had to survive a host loss or a bad update. Workflows, credentials, and webhook paths are already reconstructed from git -and `.env` by `init` / `deploy` / in-container reconcile. Uploaded SFTP files can be +and `.env` by `scripts/init.sh`, Compose, and in-container reconcile. Uploaded SFTP files can be re-provided by callers. The remaining identity is `.env`, SFTP host keys, and authorized client keys — which belong next to other host secrets, not inside the application lifecycle. @@ -24,22 +24,22 @@ Instances are disposable. Create them at will from the current checkout. or restored by Syndicator. They may be lost on disaster and on update. - `.env` and SFTP keys are ingested when an instance is created. Keeping callers unaware of an update means leaving that identity in place. -- Disaster recovery is a new instance: reprovide `.env`, run `init` and - `deploy`, and regenerate SFTP keys unless they were saved outside - Syndicator. Callers re-upload files and may need to accept a new SSH host - key. +- Disaster recovery is a new instance: reprovide `.env`, run `scripts/init.sh` + and `docker compose up -d --build`, then `bin/syndicator verify`. Regenerate + SFTP keys unless they were saved outside Syndicator. Callers re-upload files + and may need to accept a new SSH host key. - If `.env` and SFTP keys should survive a host loss, back them up outside this repository. Syndicator does not choose a storage provider or encryption key lifecycle. -`bin/syndicator` therefore has no `backup`, `restore`, `update`, or `rollback` -commands. A software update is `bin/syndicator deploy` (optionally `--pull`) -on the reviewed revision. +`bin/syndicator` therefore has no `backup`, `restore`, `update`, `rollback`, +`init`, or `deploy` commands. A software update is `docker compose up -d --build` +on the reviewed revision, followed by `bin/syndicator verify`. ## Consequences -- Failed deploys still stop unverified services; recovery is another deploy, - not a volume restore. +- If verification fails, bring the services down; recovery is another + `compose up` plus `verify`, not a volume restore. - SFTP host keys remain in the `sftp_host_keys` volume so a normal container recreate does not change the SSH identity. Wiping that volume is visible to callers. diff --git a/docs/operations.md b/docs/operations.md index bac1966..3b1146e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -16,7 +16,7 @@ The host needs: Check the host without changing anything: ```bash -bin/syndicator doctor +scripts/doctor.sh ``` ## First installation @@ -24,7 +24,7 @@ bin/syndicator doctor Create the local configuration: ```bash -bin/syndicator init +scripts/init.sh ``` On the first run this creates `.env`, generates `N8N_ENCRYPTION_KEY`, and stops @@ -39,19 +39,19 @@ The lifecycle passes `.env` to Compose as an env file, never as shell code. Quot values that contain spaces or `#`; single quotes preserve `$`, backticks, and other characters literally. -Then deploy: +Then start the stack and verify: ```bash -bin/syndicator deploy +docker compose up -d --build +bin/syndicator verify ``` -`deploy` performs initialization and diagnostics again, builds immutable -inputs, starts the stack, waits for the in-container n8n reconcile, and runs -end-to-end health checks. Running it again is safe. If source configuration is -unchanged and all workflows remain published, n8n import is skipped. +Compose builds and starts the services. `verify` waits for n8n, runs the +in-container reconcile, and checks health, webhook registration, pyautoflip, +and SFTP. Running it again is safe. If source configuration is unchanged and +all workflows remain published, n8n import is skipped. -Unless `SYNDICATOR_IMAGE_TAG` is set, images use the current 12-character Git -revision as their tag. +Unless `SYNDICATOR_IMAGE_TAG` is set, images are tagged `local`. ## Local and network configuration @@ -85,25 +85,18 @@ application contract change and must be coordinated with callers. ## Routine commands -Inspect status: +Inspect logs: ```bash -bin/syndicator status bin/syndicator logs ``` -Run non-mutating service and contract checks: +Run reconcile and non-mutating service checks: ```bash bin/syndicator verify ``` -Reconcile n8n after a workflow or credential-template change: - -```bash -bin/syndicator bootstrap -``` - Export workflows after editing them in n8n: ```bash @@ -114,17 +107,19 @@ python3 -m unittest discover -s tests -p 'test_*.py' Exports are normalized: pin data, instance IDs, project ownership, and version metadata are removed. Review the resulting JSON before committing it. -If the owner password changes, update `N8N_OWNER_PASSWORD` in `.env`, then run: +If the owner password changes, update `N8N_OWNER_PASSWORD` in `.env`, then +recreate n8n and verify: ```bash -bin/syndicator deploy +docker compose up -d --force-recreate n8n +bin/syndicator verify ``` After adding or removing a public key under `sftp/keys/`, apply it through the supported hook: ```bash -bin/syndicator restart sftp +docker compose up -d --force-recreate sftp ``` ## Dependency updates @@ -135,22 +130,22 @@ Dependabot. Do not edit a floating `latest` or `stable` tag on the server. For an update: 1. Review the release notes and dependency diff. -2. Let CI validate manifests, audit npm dependencies, build both images, deploy +2. Let CI validate manifests, audit npm dependencies, build both images, start an isolated stack twice, and test SFTP I/O. 3. Pull the reviewed Git revision on the server. 4. Run: ```bash -bin/syndicator deploy --pull +docker compose up -d --build --pull always +bin/syndicator verify ``` -Deploy rebuilds commit-tagged images from the current checkout, starts the -stack, reconciles n8n, and verifies. Instance volumes are not snapshotted; -callers keep working when `.env`, SFTP host keys, and authorized client keys -stay in place. +This rebuilds images from the current checkout, starts the stack, reconciles +n8n, and verifies. Instance volumes are not snapshotted; callers keep working +when `.env`, SFTP host keys, and authorized client keys stay in place. -If reconcile or verification fails, the new services are stopped. Do not simply -restart the failed containers; fix the checkout and deploy again. +If reconcile or verification fails, bring the new services down. Do not simply +restart the failed containers; fix the checkout and start again. ## Disaster recovery @@ -160,7 +155,8 @@ Syndicator does not back up application volumes. A lost host is a new instance: 2. Check out the desired Git revision. 3. Restore `.env` from wherever you keep secrets, or recreate it and fill the required values. -4. Run `bin/syndicator init` and `bin/syndicator deploy`. +4. Run `scripts/init.sh`, `docker compose up -d --build`, and + `bin/syndicator verify`. 5. Restore authorized client public keys under `sftp/keys/` if you kept them. 6. Verify firewall, DNS, and reverse proxy. @@ -190,18 +186,16 @@ bash tests/integration/stack.sh CI first builds the production model-warmed image and performs a real reframe. The stack integration test uses random loopback ports and a unique Compose -project. It deploys twice, checks that an unchanged reconcile is skipped, -uploads over SFTP, and removes all test containers and volumes. A -deliberately failed release also verifies that unverified containers are -stopped. A separate Buildx job verifies -n8n and pyautoflip for Linux arm64. +project. It starts the stack, verifies twice, checks that an unchanged +reconcile is skipped, uploads over SFTP, and removes all test containers and +volumes. A separate Buildx job verifies n8n and pyautoflip for Linux arm64. ## Troubleshooting If n8n is unavailable during reconcile, inspect readiness and logs: ```bash -bin/syndicator status +docker compose ps bin/syndicator logs n8n ``` @@ -235,7 +229,8 @@ should stop at: - creating the deployment user and directory - configuring firewall, TLS proxy, and optional off-host secret backup - placing `.env` and other bootstrap secrets from a vault -- checking out a reviewed Git revision and invoking `bin/syndicator deploy` +- checking out a reviewed Git revision and running `docker compose up -d --build` + plus `bin/syndicator verify` Do not duplicate Compose services, Dockerfile package installation, or n8n bootstrap in Ansible. Terraform belongs one level further out: VM, DNS, diff --git a/pyautoflip/README.md b/pyautoflip/README.md index 5728889..0f69213 100644 --- a/pyautoflip/README.md +++ b/pyautoflip/README.md @@ -8,7 +8,8 @@ Defined as the `pyautoflip` service in [`../docker-compose.yml`](../docker-compo ```bash # From the repository root: -bin/syndicator deploy +docker compose up -d --build +bin/syndicator verify ``` ## API diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100755 index c0b1ff2..0000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# shellcheck source=scripts/lib.sh -source "$ROOT/scripts/lib.sh" - -pull=0 -while [[ "$#" -gt 0 ]]; do - case "$1" in - --pull) - pull=1 - ;; - *) - echo "Usage: $0 [--pull]" >&2 - exit 2 - ;; - esac - shift -done - -"$ROOT/scripts/init.sh" -"$ROOT/scripts/doctor.sh" --require-config - -if [[ -z "${SYNDICATOR_IMAGE_TAG:-}" ]]; then - SYNDICATOR_IMAGE_TAG="$(git rev-parse --short=12 HEAD 2>/dev/null || printf 'local')" - export SYNDICATOR_IMAGE_TAG -fi - -if [[ "$pull" -eq 1 ]]; then - compose build --pull -else - compose build -fi - -runtime_mutated=0 -deployment_cleanup() { - status=$? - if [[ "$status" -ne 0 && "$runtime_mutated" -eq 1 ]]; then - if compose stop n8n pyautoflip sftp >/dev/null 2>&1; then - echo "Deployment failed; the unverified services were stopped." >&2 - else - echo "Deployment failed and automatic service shutdown also failed." >&2 - fi - fi - exit "$status" -} -trap deployment_cleanup EXIT - -runtime_mutated=1 -compose up -d --remove-orphans -if [[ "${SYNDICATOR_TEST_FAIL_AFTER_START:-0}" == "1" ]]; then - echo "Deliberate post-start failure requested by integration test." >&2 - false -fi -wait_for_n8n -run_reconcile -"$ROOT/scripts/verify.sh" - -trap - EXIT -echo "Deployment ${SYNDICATOR_IMAGE_TAG} is healthy." diff --git a/scripts/verify.sh b/scripts/verify.sh index 7aa9edd..6463d00 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -10,6 +10,22 @@ run_reconcile echo "n8n health and workflow publication are valid." +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +n8n_published="$(compose port n8n 5678 | head -n1)" +n8n_url="http://${n8n_published}" +for path in publish reel; do + code="$(curl -sS -o "$tmp/webhook-${path}.txt" -w '%{http_code}' \ + "${n8n_url}/webhook/${path}" || true)" + body="$(<"$tmp/webhook-${path}.txt")" + if [[ "$code" != "404" ]] || [[ "$body" != *[Ww]ebhook* ]]; then + echo "Webhook /webhook/${path} is not registered (HTTP ${code}): ${body}" >&2 + exit 1 + fi +done +echo "Publish and reel webhooks are registered." + health="$(compose exec -T n8n wget -qO- http://pyautoflip:8080/health || true)" if [[ "$health" != *'"status":"ok"'* && "$health" != *'"status": "ok"'* ]]; then echo "pyautoflip health check failed: $health" >&2 @@ -17,8 +33,6 @@ if [[ "$health" != *'"status":"ok"'* && "$health" != *'"status": "ok"'* ]]; then fi echo "pyautoflip is reachable from n8n." -tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT client_key="$(resolve_from_root "${SFTP_CLIENT_KEY_FILE:-secrets/sftp_client_ed25519}")" if [[ ! -f "$client_key" ]]; then echo "Skipping SFTP check; no client key at $client_key." diff --git a/tests/integration/stack.sh b/tests/integration/stack.sh index 74de799..ada9918 100755 --- a/tests/integration/stack.sh +++ b/tests/integration/stack.sh @@ -3,6 +3,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" tmp="$(mktemp -d)" project="syndicator-it-${RANDOM}" @@ -49,56 +51,34 @@ chmod 600 "$env_file" export SYNDICATOR_ENV_FILE="$env_file" export SYNDICATOR_PROJECT="$project" +export SYNDICATOR_IMAGE_TAG="integration" cleanup() { status=$? if [[ "$status" -ne 0 ]]; then - docker compose --env-file "$env_file" -p "$project" \ - logs --no-color >&2 || true + compose logs --no-color >&2 || true fi - docker compose --env-file "$env_file" -p "$project" \ - down -v --remove-orphans >/dev/null 2>&1 || true + compose down -v --remove-orphans >/dev/null 2>&1 || true rm -rf "$tmp" exit "$status" } trap cleanup EXIT -test_failed_deployment() { - printf '%s\n' 'SYNDICATOR_TEST_FAIL_AFTER_START=1' >>"$env_file" - set +e - "$ROOT/bin/syndicator" deploy \ - >"$tmp/failed-deploy.log" 2>&1 - failed_status=$? - set -e - if [[ "$failed_status" -eq 0 ]]; then - echo "Deliberately invalid deployment unexpectedly succeeded." >&2 - exit 1 - fi - if [[ -n "$(docker compose --env-file "$env_file" -p "$project" \ - ps --status running -q n8n)" ]]; then - echo "Failed deployment left unverified n8n running." >&2 - exit 1 - fi -} - -"$ROOT/bin/syndicator" deploy -if [[ "${SYNDICATOR_INTEGRATION_FAILURE_ONLY:-0}" == "1" ]]; then - test_failed_deployment - echo "Failed deployment containment test passed." - exit 0 -fi - -if ! "$ROOT/bin/syndicator" deploy | tee "$tmp/second-deploy.log"; then +"$ROOT/scripts/init.sh" +compose build +compose up -d --remove-orphans +"$ROOT/bin/syndicator" verify +if ! "$ROOT/bin/syndicator" verify | tee "$tmp/second-verify.log"; then exit 1 fi -if ! python3 - "$tmp/second-deploy.log" <<'PY' +if ! python3 - "$tmp/second-verify.log" <<'PY' from pathlib import Path import sys raise SystemExit(0 if "already current" in Path(sys.argv[1]).read_text() else 1) PY then - echo "Second deployment did not skip an unchanged bootstrap." >&2 + echo "Second verify did not skip an unchanged reconcile." >&2 exit 1 fi @@ -120,6 +100,4 @@ sftp -q -b "$tmp/sftp.batch" \ sftp@127.0.0.1 cmp "$tmp/upload.txt" "$tmp/download.txt" -test_failed_deployment - echo "Isolated stack integration test passed." diff --git a/tests/test-init.sh b/tests/test-init.sh index 9e9d5d3..7aa75c9 100755 --- a/tests/test-init.sh +++ b/tests/test-init.sh @@ -6,7 +6,7 @@ tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT set +e -SYNDICATOR_ENV_FILE="$tmp/.env" "$ROOT/bin/syndicator" init >/dev/null 2>&1 +SYNDICATOR_ENV_FILE="$tmp/.env" "$ROOT/scripts/init.sh" >/dev/null 2>&1 status=$? set -e From 4a5cf1cbf2dcf3b6bac5c0755a371e3cbe04ca8f Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Thu, 13 Aug 2026 22:45:14 +0200 Subject: [PATCH 14/16] Process media in place on the shared SFTP volume. pyautoflip and ffmpeg write under /syndicator instead of copying files through n8n binary. Reconcile can re-import already-owned workflows, and Compose honors SYNDICATOR_ENV_FILE at call time. Co-authored-by: Cursor --- README.md | 66 +++++---- docker-compose.yml | 2 + n8n/reconcile.js | 17 ++- n8n/workflows/Adapt Hugo Media.json | 186 ++++-------------------- n8n/workflows/Adapt Reel Media.json | 213 ++++------------------------ pyautoflip/README.md | 9 +- pyautoflip/app.py | 26 ++-- scripts/lib.sh | 5 +- tests/integration/stack.sh | 4 +- 9 files changed, 135 insertions(+), 393 deletions(-) diff --git a/README.md b/README.md index 41812a8..db69b3d 100644 --- a/README.md +++ b/README.md @@ -196,35 +196,43 @@ Software design is split into **instantiation** (how an instance is built and st ### Instantiation -The repo is the blueprint for a containerized instance: Compose defines the stack, an in-container reconcile step imports credentials and workflows, and the rest is source material those steps consume. - -| Piece | Role | -|-------|------| -| `docker-compose.yml` | Compose stack: files-init + SFTP + n8n + n8n-reconcile + pyautoflip | -| `.env.example` | Env template for secrets and host paths | -| `n8n/Dockerfile` | Custom n8n image (`ffmpeg` + community node seed + reconcile) | -| `n8n/reconcile.js` | In-container credential/workflow import and webhook publish | -| `sftp/setup.sh` | Supported atmoz startup hook for durable host keys, key sync, and ownership | -| `scripts/` | Init, doctor, verify, and export helpers | -| `n8n/workflows/` | Importable workflow exports (source of truth) | -| `n8n/credentials/` | Credential templates (stable IDs; secrets from `.env`) | -| `pyautoflip/` | Image/build context for the reframe sidecar | -| `sftp/keys/` | Authorized client public keys (refreshed into `authorized_keys` on each sftp start) | -| `bin/syndicator` | Operator CLI: verify, export, logs | +To start an instance you need this repository and a filled-in `.env`. `docker compose up --build` builds the images and starts sftp, n8n, and pyautoflip. n8n comes up with an owner account from `.env`, but not yet with Syndicator's workflows. `n8n-reconcile` then logs into that n8n, creates credentials from `.env`, imports the workflow JSON from git, and publishes the webhooks. After that the instance matches this checkout. +```mermaid +flowchart LR + Git["Git checkout"] + Env[".env"] + subgraph instantiate ["instantiate"] + Init["files-init"] + SFTP["sftp"] + N8N["n8n"] + PyAF["pyautoflip"] + Recon["n8n-reconcile"] + end + Git --> instantiate + Git -->|workflows, credential templates| Recon + Env -->|secrets, owner| N8N + Env --> Recon + Init -->|chown volumes| SFTP + Init --> N8N + Init --> PyAF + N8N -->|healthy| Recon + Recon -->|import + publish webhooks| N8N ``` -docker-compose.yml -.env.example -n8n/Dockerfile -n8n/reconcile.js -n8n/workflows/ -n8n/credentials/*.template.json -pyautoflip/ -sftp/ -scripts/{init,doctor,verify,export}.sh -docs/{operations.md,adr/} -bin/syndicator -``` + +| Component | Role | +|-----------|------| +| Git checkout | Blueprint: Compose file, Dockerfiles, workflow JSON, credential templates, SFTP startup hook, authorized `.pub` keys | +| `.env` | Instance identity: encryption key, owner login, API keys, bind addresses. Created from `.env.example` by `scripts/init.sh` | +| `files-init` | One-shot: chowns shared `n8n_files` and `sftp_data` to uid/gid `1000` so n8n, pyautoflip, and SFTP can write | +| `sftp` | Starts with `sftp/setup.sh`: durable host keys in `sftp_host_keys`, client keys from `sftp/keys/` | +| `n8n` | Custom image (`ffmpeg`, community nodes). On start, hashes `N8N_OWNER_PASSWORD` and provisions the owner from env. SQLite lives in `n8n_data` | +| `pyautoflip` | Custom image; shares `sftp_data` at `/syndicator` with n8n | +| `n8n-reconcile` | Compose profile `reconcile`, not a long-running service. Logs in as owner, renders credential templates from `.env`, imports workflows from git, publishes webhooks | + +`bin/syndicator verify` is the operator gate after `docker compose up`: it waits for n8n, runs reconcile, then checks health, webhook registration, pyautoflip, and SFTP. Export and logs are the other CLI commands; they are not part of instantiate. + +Git remains source of truth for workflows. n8n's volume is disposable; a new instance re-imports from git. See [ADR 0001](docs/adr/0001-deployment-model.md) and [ADR 0002](docs/adr/0002-disposable-instances.md). ### Runtime structure @@ -241,7 +249,7 @@ flowchart LR Caller -->|key auth SFTP| SFTP Caller -->|webhooks| N8N N8N -->|shared volume /syndicator| SFTP - N8N -->|HTTP /reframe on /files| PyAF + N8N -->|HTTP /reframe on /syndicator| PyAF N8N --> OpenAI["OpenAI"] N8N --> Postiz["Postiz"] N8N --> Hugo["Hugo site tree"] @@ -251,7 +259,7 @@ flowchart LR |---------|------| | `sftp` | Key-only SFTP on port `2222`; chroot home with `/syndicator/…`; host keys in `sftp_host_keys` | | `n8n` | Workflow engine; SQLite in `n8n_data`; shares `n8n_files` → `/files` with pyautoflip and `sftp_data` → `/syndicator` | -| `pyautoflip` | Reel reframing sidecar (`HTTP /reframe` on `/files`) | +| `pyautoflip` | Reel reframing sidecar (`HTTP /reframe` on `/syndicator`) | | Workflow | Role | |----------|------| diff --git a/docker-compose.yml b/docker-compose.yml index 8f441a4..4362053 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -132,9 +132,11 @@ services: user: "1000:1000" environment: HOME: /home/pyautoflip + PYAUTOFLIP_ALLOWED_ROOTS: /files;/syndicator restart: unless-stopped volumes: - n8n_files:/files + - sftp_data:/syndicator depends_on: files-init: condition: service_completed_successfully diff --git a/n8n/reconcile.js b/n8n/reconcile.js index 0069b4f..aa28c13 100644 --- a/n8n/reconcile.js +++ b/n8n/reconcile.js @@ -29,6 +29,19 @@ function runN8n(args) { return result.stdout; } +function importOwned(kind, inputPath, userId) { + const withOwner = [`import:${kind}`, `--input=${inputPath}`, `--userId=${userId}`]; + const result = spawnSync("n8n", withOwner, { encoding: "utf8" }); + if (result.status === 0) { + return; + } + const msg = `${result.stderr || ""}${result.stdout || ""}`; + if (!msg.includes("already owned")) { + fail(`n8n ${withOwner.join(" ")} failed:\n${msg}`); + } + runN8n([`import:${kind}`, `--input=${inputPath}`]); +} + function listBundle(kind, suffix) { const dir = path.join(BUNDLE, kind); return fs @@ -281,13 +294,13 @@ async function main() { path.basename(templatePath, ".template.json") + ".json", ); fs.writeFileSync(out, renderCredential(templatePath)); - runN8n(["import:credentials", `--input=${out}`, `--userId=${userId}`]); + importOwned("credentials", out, userId); } console.log("Importing and publishing workflows..."); for (const filePath of files) { const id = JSON.parse(fs.readFileSync(filePath, "utf8")).id; - runN8n(["import:workflow", `--input=${filePath}`, `--userId=${userId}`]); + importOwned("workflow", filePath, userId); await publish(cookie, id); } diff --git a/n8n/workflows/Adapt Hugo Media.json b/n8n/workflows/Adapt Hugo Media.json index 071db18..492995c 100644 --- a/n8n/workflows/Adapt Hugo Media.json +++ b/n8n/workflows/Adapt Hugo Media.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-08-08T13:50:29.558Z", + "updatedAt": "2026-08-13T20:12:00.000Z", "createdAt": "2026-08-05T15:02:11.968Z", "id": "OGa6Xa8GxkSmA7Cr", "name": "Adapt Hugo Media", @@ -82,59 +82,14 @@ { "parameters": { "mode": "runOnceForEachItem", - "jsCode": "const j = $input.item.json;\nconst root = '/files';\nconst raw = String(j.bundle_filename || 'video');\nconst stem = raw.includes('.') ? raw.slice(0, raw.lastIndexOf('.')) : raw;\nconst safe = stem.split('').map((c) => /[a-zA-Z0-9_]/.test(c) ? c : '_').join('').slice(0, 80) || 'video';\nconst local_path = root + '/syndicator-site-' + safe + '-' + Date.now() + '.mp4';\nreturn { json: Object.assign({}, j, { local_path: local_path }) };" - }, - "id": "a9a3d9c7-c671-49c4-a4bb-b00832c1714a", - "name": "Resolve Video Local", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 672, - 352 - ] - }, - { - "parameters": { - "fileSelector": "={{ $json.source_sftp_path }}", - "options": { - "dataPropertyName": "data" - } - }, - "id": "5e0fe57f-c744-4812-a558-6b67707b4161", - "name": "Download Site Video", - "type": "n8n-nodes-base.readWriteFile", - "typeVersion": 1.1, - "position": [ - 896, - 352 - ] - }, - { - "parameters": { - "operation": "write", - "fileName": "={{ $('Resolve Video Local').item.json.local_path }}", - "options": {} - }, - "id": "5a46c465-2538-4a04-bc1c-8264e3778df5", - "name": "Write Site Video Local", - "type": "n8n-nodes-base.readWriteFile", - "typeVersion": 1.1, - "position": [ - 1120, - 352 - ] - }, - { - "parameters": { - "mode": "runOnceForEachItem", - "jsCode": "const job = $input.item.json;\nconst local = $('Resolve Video Local').item.json.local_path;\nconst outLocal = local.replace(/\\.mp4$/i, '') + '-hugo.mp4';\n// Resize to fit inside 700×394, preserve aspect, no crop.\nconst customArgs = `-y -i ${local} -vf \"scale=700:394:force_original_aspect_ratio=decrease\" -c:v libx264 -preset veryfast -crf 28 -pix_fmt yuv420p -c:a aac -movflags +faststart ${outLocal}`;\nreturn { json: { ...job, local_path: local, out_local: outLocal, customArgs } };" + "jsCode": "const job = $input.item.json;\nfunction quote(p) {\n return '\"' + String(p).replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"') + '\"';\n}\nconst customArgs = `-y -i ${quote(job.source_sftp_path)} -vf \"scale=700:394:force_original_aspect_ratio=decrease\" -c:v libx264 -preset veryfast -crf 28 -pix_fmt yuv420p -c:a aac -movflags +faststart ${quote(job.sftp_path)}`;\nreturn { json: { ...job, customArgs } };" }, "id": "6596da44-d212-41bd-b3b9-9eefdad0f7e5", "name": "Plan Hugo Resize", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1344, + 672, 352 ] }, @@ -143,30 +98,14 @@ "operation": "custom", "timeoutSeconds": 900, "customArgs": "={{ $json.customArgs }}", - "customReturnFile": true, - "customOutputPath": "={{ $json.out_local }}" + "customReturnFile": false }, "id": "9a0781d5-b7e9-4bc2-8677-d0477dc6813e", "name": "Adapt Hugo Video", "type": "n8n-nodes-ffmpeg-studio.ffmpegVideo", "typeVersion": 1, "position": [ - 1568, - 352 - ] - }, - { - "parameters": { - "operation": "write", - "fileName": "={{ $('Plan Hugo Resize').item.json.sftp_path }}", - "options": {} - }, - "id": "c3c9fec5-d2d5-4985-9c27-fb8c12bafa43", - "name": "Upload Site Video", - "type": "n8n-nodes-base.readWriteFile", - "typeVersion": 1.1, - "position": [ - 1792, + 896, 352 ] }, @@ -179,39 +118,21 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 2016, + 1120, 176 ], "executeOnce": true }, { "parameters": { - "fileSelector": "={{ $json.source_sftp_path }}", - "options": { - "dataPropertyName": "data" - } + "jsCode": "const fs = require('fs');\nreturn $input.all().map((item) => {\n const job = item.json;\n fs.copyFileSync(job.source_sftp_path, job.sftp_path);\n return { json: job };\n});" }, "id": "769a3d75-50d8-4514-b102-b16fdaebd864", - "name": "Download Site Image", - "type": "n8n-nodes-base.readWriteFile", - "typeVersion": 1.1, - "position": [ - 1568, - 32 - ] - }, - { - "parameters": { - "operation": "write", - "fileName": "={{ $json.sftp_path }}", - "options": {} - }, - "id": "02114a24-8540-46cd-a549-b9005598f935", - "name": "Upload Site Image", - "type": "n8n-nodes-base.readWriteFile", - "typeVersion": 1.1, + "name": "Copy Site Image", + "type": "n8n-nodes-base.code", + "typeVersion": 2, "position": [ - 1792, + 896, 32 ] }, @@ -244,75 +165,64 @@ "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ - 1344, + 672, 112 ] } ], "connections": { - "Plan Site Media Jobs": { + "Adapt Hugo Trigger": { "main": [ [ { - "node": "Is Video?", + "node": "Plan Site Media Jobs", "type": "main", "index": 0 } ] ] }, - "Is Video?": { + "Plan Site Media Jobs": { "main": [ [ { - "node": "Resolve Video Local", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Is Noop?", + "node": "Is Video?", "type": "main", "index": 0 } ] ] }, - "Resolve Video Local": { + "Is Video?": { "main": [ [ { - "node": "Download Site Video", + "node": "Plan Hugo Resize", "type": "main", "index": 0 } - ] - ] - }, - "Download Site Video": { - "main": [ + ], [ { - "node": "Write Site Video Local", + "node": "Is Noop?", "type": "main", "index": 0 } ] ] }, - "Adapt Hugo Video": { + "Plan Hugo Resize": { "main": [ [ { - "node": "Upload Site Video", + "node": "Adapt Hugo Video", "type": "main", "index": 0 } ] ] }, - "Upload Site Video": { + "Adapt Hugo Video": { "main": [ [ { @@ -323,18 +233,7 @@ ] ] }, - "Download Site Image": { - "main": [ - [ - { - "node": "Upload Site Image", - "type": "main", - "index": 0 - } - ] - ] - }, - "Upload Site Image": { + "Copy Site Image": { "main": [ [ { @@ -345,39 +244,6 @@ ] ] }, - "Write Site Video Local": { - "main": [ - [ - { - "node": "Plan Hugo Resize", - "type": "main", - "index": 0 - } - ] - ] - }, - "Plan Hugo Resize": { - "main": [ - [ - { - "node": "Adapt Hugo Video", - "type": "main", - "index": 0 - } - ] - ] - }, - "Adapt Hugo Trigger": { - "main": [ - [ - { - "node": "Plan Site Media Jobs", - "type": "main", - "index": 0 - } - ] - ] - }, "Is Noop?": { "main": [ [ @@ -389,7 +255,7 @@ ], [ { - "node": "Download Site Image", + "node": "Copy Site Image", "type": "main", "index": 0 } @@ -413,7 +279,7 @@ "pinData": {}, "versionId": "9898ecb7-79c3-4a67-900b-9cc601e01b96", "activeVersionId": "9898ecb7-79c3-4a67-900b-9cc601e01b96", - "versionCounter": 2, + "versionCounter": 3, "triggerCount": 0, "sourceWorkflowId": null, "tags": [] diff --git a/n8n/workflows/Adapt Reel Media.json b/n8n/workflows/Adapt Reel Media.json index f4b94d8..5e859ef 100644 --- a/n8n/workflows/Adapt Reel Media.json +++ b/n8n/workflows/Adapt Reel Media.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-08-09T17:15:22.511Z", + "updatedAt": "2026-08-13T20:05:00.000Z", "createdAt": "2026-08-05T15:02:21.158Z", "id": "y9TTx7N8Iygn88ry", "name": "Adapt Reel Media", @@ -35,7 +35,7 @@ }, { "parameters": { - "jsCode": "const j = $input.first().json;\nconst slug = String(j.slug || 'unknown');\nconst index = Number(j.index || 1);\nconst source_filename = String(j.source_filename || '').trim();\nconst source_sftp_path = '/syndicator/' + slug + '/source/' + source_filename;\nconst base = '/files';\nconst safeSlug = slug.split('').map((c) => /[a-zA-Z0-9_]/.test(c) ? c : '_').join('').slice(0, 60);\nconst stamp = safeSlug + '-' + index + '-' + Date.now();\nconst source_local = base + '/syndicator-source-' + stamp + '.mp4';\nconst video_4x5_sftp = '/syndicator/' + slug + '/reels/4x5/' + index + '.mp4';\nconst video_9x16_sftp = '/syndicator/' + slug + '/reels/9x16/' + index + '.mp4';\nconst fs = require('fs');\nconst path = require('path');\nfs.mkdirSync(path.dirname(video_4x5_sftp), { recursive: true });\nfs.mkdirSync(path.dirname(video_9x16_sftp), { recursive: true });\nreturn [{ json: {\n slug: slug,\n index: index,\n source_filename: source_filename,\n source_sftp_path: source_sftp_path,\n source_local: source_local,\n video_4x5_local: base + '/syndicator-video-' + stamp + '-4x5.mp4',\n video_9x16_local: base + '/syndicator-video-' + stamp + '-9x16.mp4',\n video_4x5_sftp: video_4x5_sftp,\n video_9x16_sftp: video_9x16_sftp,\n} }];" + "jsCode": "const j = $input.first().json;\nconst slug = String(j.slug || 'unknown');\nconst index = Number(j.index || 1);\nconst source_filename = String(j.source_filename || '').trim();\nconst source_path = '/syndicator/' + slug + '/source/' + source_filename;\nconst video_4x5 = '/syndicator/' + slug + '/reels/4x5/' + index + '.mp4';\nconst video_9x16 = '/syndicator/' + slug + '/reels/9x16/' + index + '.mp4';\nconst fs = require('fs');\nconst path = require('path');\nfs.mkdirSync(path.dirname(video_4x5), { recursive: true });\nfs.mkdirSync(path.dirname(video_9x16), { recursive: true });\nreturn [{ json: {\n slug: slug,\n index: index,\n source_filename: source_filename,\n source_local: source_path,\n video_4x5_local: video_4x5,\n video_9x16_local: video_9x16,\n video_4x5_sftp: video_4x5,\n video_9x16_sftp: video_9x16,\n} }];" }, "id": "46f8dd7d-9ead-45cf-ad9c-a6e0be30a879", "name": "Resolve Paths", @@ -46,95 +46,6 @@ -16 ] }, - { - "parameters": { - "fileSelector": "={{ $json.source_sftp_path }}", - "options": { - "dataPropertyName": "data" - } - }, - "id": "1467dfaf-5f3d-453b-bb1d-d906c42bcb5a", - "name": "Download Source", - "type": "n8n-nodes-base.readWriteFile", - "typeVersion": 1.1, - "position": [ - 448, - -16 - ] - }, - { - "parameters": { - "operation": "write", - "fileName": "={{ $('Resolve Paths').item.json.source_local }}", - "options": {} - }, - "id": "499b8c19-3f2a-4705-adc8-bf470e9b02ce", - "name": "Write Source Local", - "type": "n8n-nodes-base.readWriteFile", - "typeVersion": 1.1, - "position": [ - 672, - -16 - ] - }, - { - "parameters": { - "operation": "write", - "fileName": "={{ $('Plan Reframe').item.json.video_4x5_sftp }}", - "options": {} - }, - "id": "f199b77d-8e6d-4ba8-b837-28b255e24536", - "name": "Upload Reel", - "type": "n8n-nodes-base.readWriteFile", - "typeVersion": 1.1, - "position": [ - 1568, - -112 - ] - }, - { - "parameters": { - "jsCode": "const trigger = $('Adapt Reel Trigger').first().json;\nconst plan = $('Plan Reframe').first().json;\nreturn [{\n json: {\n slug: trigger.slug,\n index: trigger.index,\n source_filename: trigger.source_filename,\n video_4x5_sftp: plan.video_4x5_sftp,\n video_4x5_local: plan.video_4x5_local,\n video_9x16_sftp: plan.video_9x16_sftp,\n video_9x16_local: plan.video_9x16_local,\n },\n}];" - }, - "id": "1f70fba6-eebb-40ea-ba42-813c951b3bf4", - "name": "Build Result", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 2016, - -16 - ] - }, - { - "parameters": { - "operation": "write", - "fileName": "={{ $('Plan Reframe').item.json.video_9x16_sftp }}", - "options": {} - }, - "id": "0ed402a8-830f-420e-bb82-3183564bb162", - "name": "Upload Reel 9:16", - "type": "n8n-nodes-base.readWriteFile", - "typeVersion": 1.1, - "position": [ - 1568, - 80 - ] - }, - { - "parameters": { - "mode": "combine", - "combineBy": "combineByPosition", - "options": {} - }, - "id": "90ae4bda-f534-4b1a-a73b-6ea1a3c0d80e", - "name": "Merge Variants", - "type": "n8n-nodes-base.merge", - "typeVersion": 3, - "position": [ - 1792, - -16 - ] - }, { "parameters": { "jsCode": "const p = $('Resolve Paths').first().json;\nreturn [{ json: {\n ...p,\n method: 'saliency',\n motion_threshold: 0.5,\n padding_method: 'blur',\n} }];" @@ -144,7 +55,7 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 896, + 448, -16 ] }, @@ -164,7 +75,7 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 1120, + 672, -112 ] }, @@ -184,40 +95,36 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 1120, + 672, 80 ] }, { "parameters": { - "fileSelector": "={{ $json.output_path }}", - "options": { - "dataPropertyName": "data" - } + "mode": "combine", + "combineBy": "combineByPosition", + "options": {} }, - "id": "db04f570-f012-44d9-aa13-6c3484e55a9f", - "name": "Read Reel 4:5", - "type": "n8n-nodes-base.readWriteFile", - "typeVersion": 1.1, + "id": "90ae4bda-f534-4b1a-a73b-6ea1a3c0d80e", + "name": "Merge Variants", + "type": "n8n-nodes-base.merge", + "typeVersion": 3, "position": [ - 1344, - -112 + 896, + -16 ] }, { "parameters": { - "fileSelector": "={{ $json.output_path }}", - "options": { - "dataPropertyName": "data" - } + "jsCode": "const trigger = $('Adapt Reel Trigger').first().json;\nconst plan = $('Plan Reframe').first().json;\nreturn [{\n json: {\n slug: trigger.slug,\n index: trigger.index,\n source_filename: trigger.source_filename,\n video_4x5_sftp: plan.video_4x5_sftp,\n video_4x5_local: plan.video_4x5_local,\n video_9x16_sftp: plan.video_9x16_sftp,\n video_9x16_local: plan.video_9x16_local,\n },\n}];" }, - "id": "8e5b2a15-8a7a-408d-8c67-3e22367afc79", - "name": "Read Reel 9:16", - "type": "n8n-nodes-base.readWriteFile", - "typeVersion": 1.1, + "id": "1f70fba6-eebb-40ea-ba42-813c951b3bf4", + "name": "Build Result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, "position": [ - 1344, - 80 + 1120, + -16 ] } ], @@ -234,61 +141,6 @@ ] }, "Resolve Paths": { - "main": [ - [ - { - "node": "Download Source", - "type": "main", - "index": 0 - } - ] - ] - }, - "Download Source": { - "main": [ - [ - { - "node": "Write Source Local", - "type": "main", - "index": 0 - } - ] - ] - }, - "Upload Reel": { - "main": [ - [ - { - "node": "Merge Variants", - "type": "main", - "index": 0 - } - ] - ] - }, - "Upload Reel 9:16": { - "main": [ - [ - { - "node": "Merge Variants", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge Variants": { - "main": [ - [ - { - "node": "Build Result", - "type": "main", - "index": 0 - } - ] - ] - }, - "Write Source Local": { "main": [ [ { @@ -319,7 +171,7 @@ "main": [ [ { - "node": "Read Reel 4:5", + "node": "Merge Variants", "type": "main", "index": 0 } @@ -330,29 +182,18 @@ "main": [ [ { - "node": "Read Reel 9:16", - "type": "main", - "index": 0 - } - ] - ] - }, - "Read Reel 4:5": { - "main": [ - [ - { - "node": "Upload Reel", + "node": "Merge Variants", "type": "main", - "index": 0 + "index": 1 } ] ] }, - "Read Reel 9:16": { + "Merge Variants": { "main": [ [ { - "node": "Upload Reel 9:16", + "node": "Build Result", "type": "main", "index": 0 } @@ -376,7 +217,7 @@ "pinData": {}, "versionId": "3fa76090-065a-4a39-858d-14bdcfb278bf", "activeVersionId": "3fa76090-065a-4a39-858d-14bdcfb278bf", - "versionCounter": 4, + "versionCounter": 5, "triggerCount": 0, "sourceWorkflowId": null, "tags": [] diff --git a/pyautoflip/README.md b/pyautoflip/README.md index 0f69213..fff476c 100644 --- a/pyautoflip/README.md +++ b/pyautoflip/README.md @@ -2,7 +2,8 @@ HTTP wrapper around [pyautoflip](https://github.com/AhmedHisham1/pyautoflip) (saliency mode) for the n8n **Adapt Reel Media** workflow. Runs in its own container next to n8n and shares -the `/files` volume so videos are not uploaded through HTTP bodies. +`/files` plus the SFTP tree at `/syndicator`, so reframes read and write on disk +instead of going through n8n binary. Defined as the `pyautoflip` service in [`../docker-compose.yml`](../docker-compose.yml). @@ -22,8 +23,8 @@ bin/syndicator verify ```json POST /reframe { - "input_path": "/files/syndicator-source-….mp4", - "output_path": "/files/syndicator-video-…-9x16.mp4", + "input_path": "/syndicator//source/clip.mp4", + "output_path": "/syndicator//reels/9x16/1.mp4", "aspect_ratio": "9:16", "method": "saliency", "motion_threshold": 0.5, @@ -31,7 +32,7 @@ POST /reframe } ``` -Paths must stay under `/files` (or `$PYAUTOFLIP_FILES_ROOT`). Response includes +Paths must stay under `/files` or `/syndicator` (`$PYAUTOFLIP_ALLOWED_ROOTS`). Response includes `output_path`, `width`, `height`, and `duration_ms`. **Upstream workarounds** (applied in `app.py` before `reframe_video`): diff --git a/pyautoflip/app.py b/pyautoflip/app.py index cdac7b2..6fd2d6e 100644 --- a/pyautoflip/app.py +++ b/pyautoflip/app.py @@ -17,6 +17,13 @@ from pydantic import BaseModel, Field FILES_ROOT = Path(os.environ.get("PYAUTOFLIP_FILES_ROOT", "/files")).resolve() +ALLOWED_ROOTS = tuple( + Path(part.strip()).resolve() + for part in os.environ.get( + "PYAUTOFLIP_ALLOWED_ROOTS", str(FILES_ROOT) + ).split(";") + if part.strip() +) # Final encode quality (libx264). Lower = better quality / larger files. ENCODE_CRF = int(os.environ.get("PYAUTOFLIP_CRF", "18")) ENCODE_PRESET = os.environ.get("PYAUTOFLIP_PRESET", "medium") @@ -44,14 +51,17 @@ class ReframeResponse(BaseModel): def _resolve_under_files(raw: str) -> Path: path = Path(raw).resolve() - try: - path.relative_to(FILES_ROOT) - except ValueError as exc: - raise HTTPException( - status_code=400, - detail=f"path must be under {FILES_ROOT}: {raw}", - ) from exc - return path + for root in ALLOWED_ROOTS: + try: + path.relative_to(root) + return path + except ValueError: + continue + allowed = ";".join(str(root) for root in ALLOWED_ROOTS) + raise HTTPException( + status_code=400, + detail=f"path must be under {allowed}: {raw}", + ) def _make_even(n: int) -> int: diff --git a/scripts/lib.sh b/scripts/lib.sh index 0bf2081..0765a03 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -16,12 +16,13 @@ resolve_from_root() { } compose() { + local env_file="${SYNDICATOR_ENV_FILE:-$ENV_FILE}" local args=( --project-directory "$SOURCE_ROOT" -f "$SOURCE_ROOT/docker-compose.yml" ) - if [[ -f "$ENV_FILE" ]]; then - args+=(--env-file "$ENV_FILE") + if [[ -f "$env_file" ]]; then + args+=(--env-file "$env_file") fi if [[ -n "${SYNDICATOR_PROJECT:-}" ]]; then args+=(-p "$SYNDICATOR_PROJECT") diff --git a/tests/integration/stack.sh b/tests/integration/stack.sh index ada9918..77cb8fc 100755 --- a/tests/integration/stack.sh +++ b/tests/integration/stack.sh @@ -3,8 +3,6 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" -# shellcheck source=scripts/lib.sh -source "$ROOT/scripts/lib.sh" tmp="$(mktemp -d)" project="syndicator-it-${RANDOM}" @@ -52,6 +50,8 @@ chmod 600 "$env_file" export SYNDICATOR_ENV_FILE="$env_file" export SYNDICATOR_PROJECT="$project" export SYNDICATOR_IMAGE_TAG="integration" +# shellcheck source=scripts/lib.sh +source "$ROOT/scripts/lib.sh" cleanup() { status=$? From 8a90b032552b66dd6d398835b1eae230b97ca4bf Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Fri, 14 Aug 2026 10:35:16 +0200 Subject: [PATCH 15/16] Move GitHub Actions to Node 24 runtimes. checkout@v4 still declares Node 20, which runners now warn about; v7 and the Docker setup v4 actions run on Node 24. Co-authored-by: Cursor --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d89d105..8a25a92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Validate repository manifests run: | @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Build application images run: | @@ -60,9 +60,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + - uses: actions/checkout@v7 + - uses: docker/setup-qemu-action@v4 + - uses: docker/setup-buildx-action@v4 - name: Build application images for Linux arm64 run: | From 758ea0c3faed0faff4db1c0b87b1a7beb9e88def Mon Sep 17 00:00:00 2001 From: Benno Baumgartner Date: Fri, 14 Aug 2026 16:21:01 +0200 Subject: [PATCH 16/16] Register production webhooks through the running n8n process. CLI publish from the reconcile sidecar only updated SQLite, so verify treated an unregistered GET 404 as success. Co-authored-by: Cursor --- docker-compose.yml | 1 + n8n/reconcile.js | 173 +++++++++++++++++++++++++++++++++++---- scripts/verify.sh | 34 +++++++- tests/test_repository.py | 22 +++++ 4 files changed, 208 insertions(+), 22 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4362053..0f265d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,6 +117,7 @@ services: N8N_INTERNAL_URL: http://n8n:5678 volumes: - n8n_data:/home/node/.n8n + - ./n8n/reconcile.js:/opt/syndicator/reconcile.js:ro - ./n8n/workflows:/opt/syndicator/workflows:ro - ./n8n/credentials:/opt/syndicator/credentials:ro entrypoint: ["node", "/opt/syndicator/reconcile.js"] diff --git a/n8n/reconcile.js b/n8n/reconcile.js index aa28c13..b2af06e 100644 --- a/n8n/reconcile.js +++ b/n8n/reconcile.js @@ -150,10 +150,13 @@ function cookieHeader(res) { return list.map((item) => item.split(";")[0]).join("; "); } +const BROWSER_ID = crypto.randomUUID(); + async function request(url, { method = "GET", headers = {}, body, cookie } = {}) { const res = await fetch(url, { method, headers: { + "browser-id": BROWSER_ID, ...headers, ...(cookie ? { Cookie: cookie } : {}), }, @@ -197,27 +200,149 @@ async function rest(cookie, method, urlPath, body) { }); } +function payloadOf(result) { + return (result.json && result.json.data) || result.json || {}; +} + +async function getWorkflow(cookie, id) { + for (const urlPath of [`/rest/workflows/${id}`, `/api/v1/workflows/${id}`]) { + const result = await rest(cookie, "GET", urlPath); + if (result.res.ok) { + return payloadOf(result); + } + } + return null; +} + +async function postFirstOk(cookie, paths, body) { + let last = null; + for (const urlPath of paths) { + const result = await rest(cookie, "POST", urlPath, body); + last = result; + if (result.res.ok) { + return result; + } + } + return last; +} + +function isInactive(result) { + return payloadOf(result).active === false; +} + +async function unpublish(cookie, id) { + const result = await postFirstOk( + cookie, + [ + `/rest/workflows/${id}/deactivate`, + `/rest/workflows/${id}/unpublish`, + `/api/v1/workflows/${id}/deactivate`, + `/api/v1/workflows/${id}/unpublish`, + ], + {}, + ); + if (result && (result.res.ok || isInactive(result))) { + return; + } + const current = await getWorkflow(cookie, id); + if (current && current.active !== true) { + return; + } + fail( + `Failed to unpublish workflow ${id} (HTTP ${result ? result.res.status : "none"}): ${result ? result.text : ""}`, + ); +} + async function publish(cookie, id) { - const cli = spawnSync("n8n", ["publish:workflow", `--id=${id}`], { - encoding: "utf8", - }); - if (cli.status === 0) { + // Publish through the running n8n HTTP API so production webhooks register + // in its live router. CLI publish from this sidecar only writes the DB. + await unpublish(cookie, id); + const current = await getWorkflow(cookie, id); + if (!current || !current.versionId) { + fail(`Workflow ${id} has no versionId after import`); + } + const result = await postFirstOk( + cookie, + [ + `/rest/workflows/${id}/activate`, + `/rest/workflows/${id}/publish`, + `/api/v1/workflows/${id}/activate`, + `/api/v1/workflows/${id}/publish`, + ], + { versionId: current.versionId }, + ); + if (result && result.res.ok && payloadOf(result).active === true) { return; } + fail( + `Failed to publish workflow ${id} (HTTP ${result ? result.res.status : "none"}): ${result ? result.text : ""}`, + ); +} - for (const urlPath of [ - `/rest/workflows/${id}/publish`, - `/rest/workflows/${id}/activate`, - `/api/v1/workflows/${id}/publish`, - `/api/v1/workflows/${id}/activate`, - ]) { - const result = await rest(cookie, "POST", urlPath); - if (result.res.status === 200) { - return; +async function publishAll(cookie, files) { + for (const filePath of files) { + await publish(cookie, JSON.parse(fs.readFileSync(filePath, "utf8")).id); + } +} + +function importWorkflow(filePath, userId, tmpDir) { + const workflow = JSON.parse(fs.readFileSync(filePath, "utf8")); + workflow.active = false; + delete workflow.activeVersionId; + const out = path.join(tmpDir, path.basename(filePath)); + fs.writeFileSync(out, `${JSON.stringify(workflow)}\n`); + importOwned("workflow", out, userId); +} + +function webhookPaths() { + const paths = []; + for (const filePath of listBundle("workflows", ".json")) { + const workflow = JSON.parse(fs.readFileSync(filePath, "utf8")); + for (const node of workflow.nodes || []) { + if (node.type !== "n8n-nodes-base.webhook") { + continue; + } + const hook = String((node.parameters && node.parameters.path) || "") + .trim() + .replace(/^\/+/, ""); + if (hook) { + paths.push(hook); + } } } + return paths.sort(); +} + +function webhookIsLive(status, message) { + if (status === 405) { + return true; + } + return ( + /not registered for GET/i.test(message) || + /Did you mean to make a POST/i.test(message) + ); +} + +async function webhooksLive() { + for (const hook of webhookPaths()) { + const { res, text, json } = await request(`${N8N_BASE}/webhook/${hook}`, { + method: "GET", + }); + const message = (json && json.message) || text || ""; + if (!webhookIsLive(res.status, message)) { + return { ok: false, hook, status: res.status, message }; + } + } + return { ok: true }; +} + +async function assertWebhooksLive() { + const result = await webhooksLive(); + if (result.ok) { + return; + } fail( - `Failed to publish workflow ${id} (CLI: ${cli.stderr || cli.stdout})`, + `Production webhook /webhook/${result.hook} is not registered (HTTP ${result.status}): ${result.message}`, ); } @@ -281,7 +406,19 @@ async function main() { fs.readFileSync(STATE_FILE, "utf8").trim() === digest && (await allWorkflowsCurrent(cookie, files)) ) { - console.log("n8n bootstrap is already current."); + if ((await webhooksLive()).ok) { + console.log("n8n bootstrap is already current."); + return; + } + console.log( + "Workflows are current but production webhooks are not registered; republishing...", + ); + await publishAll(cookie, files); + if (!(await allWorkflowsCurrent(cookie, files))) { + fail("Republished workflows differ from source or are inactive."); + } + await assertWebhooksLive(); + console.log("n8n bootstrap complete."); return; } @@ -299,14 +436,14 @@ async function main() { console.log("Importing and publishing workflows..."); for (const filePath of files) { - const id = JSON.parse(fs.readFileSync(filePath, "utf8")).id; - importOwned("workflow", filePath, userId); - await publish(cookie, id); + importWorkflow(filePath, userId, tmp); + await publish(cookie, JSON.parse(fs.readFileSync(filePath, "utf8")).id); } if (!(await allWorkflowsCurrent(cookie, files))) { fail("At least one imported workflow differs from source or is inactive."); } + await assertWebhooksLive(); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/scripts/verify.sh b/scripts/verify.sh index 6463d00..5cac0da 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -15,14 +15,40 @@ trap 'rm -rf "$tmp"' EXIT n8n_published="$(compose port n8n 5678 | head -n1)" n8n_url="http://${n8n_published}" -for path in publish reel; do +mapfile -t webhook_paths < <( + python3 - "$ROOT/n8n/workflows" <<'PY' +import json +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]) +for path in sorted(root.glob("*.json")): + workflow = json.loads(path.read_text(encoding="utf-8")) + for node in workflow.get("nodes") or []: + if node.get("type") != "n8n-nodes-base.webhook": + continue + hook = str((node.get("parameters") or {}).get("path") or "").strip().strip("/") + if hook: + print(hook) +PY +) +if [[ "${#webhook_paths[@]}" -eq 0 ]]; then + echo "No webhook paths found in n8n/workflows." >&2 + exit 1 +fi +for path in "${webhook_paths[@]}"; do + # GET a POST-only production webhook. Live: "Did you mean to make a POST request?" + # Missing from the live router: "The requested webhook ... is not registered." code="$(curl -sS -o "$tmp/webhook-${path}.txt" -w '%{http_code}' \ "${n8n_url}/webhook/${path}" || true)" body="$(<"$tmp/webhook-${path}.txt")" - if [[ "$code" != "404" ]] || [[ "$body" != *[Ww]ebhook* ]]; then - echo "Webhook /webhook/${path} is not registered (HTTP ${code}): ${body}" >&2 - exit 1 + if [[ "$code" == "405" ]] || + [[ "$body" == *"Did you mean to make a POST request"* ]] || + [[ "$body" == *"not registered for GET"* ]]; then + continue fi + echo "Webhook /webhook/${path} is not registered (HTTP ${code}): ${body}" >&2 + exit 1 done echo "Publish and reel webhooks are registered." diff --git a/tests/test_repository.py b/tests/test_repository.py index 474a15a..9a27b4e 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -79,6 +79,20 @@ def test_workflow_names_and_ids_are_unique(self) -> None: self.assertEqual(len(ids), len(set(ids)), "workflow IDs must be unique") + def test_webhook_nodes_have_stable_production_paths(self) -> None: + found: dict[str, str] = {} + for path, workflow in self.workflows.items(): + for node in workflow.get("nodes") or []: + if node.get("type") != "n8n-nodes-base.webhook": + continue + parameters = node.get("parameters") or {} + hook = str(parameters.get("path") or "").strip().strip("/") + self.assertTrue(node.get("webhookId"), f"{path.name}: missing webhookId") + self.assertEqual(parameters.get("httpMethod"), "POST", path.name) + self.assertTrue(hook, path.name) + found[hook] = path.name + self.assertEqual(set(found), {"publish", "reel"}) + def test_subworkflow_references_resolve(self) -> None: known_ids = {workflow["id"] for workflow in self.workflows.values()} for path, workflow in self.workflows.items(): @@ -195,6 +209,14 @@ def test_reconcile_uses_supported_interfaces(self) -> None: self.assertNotIn("docker volume", reconcile) self.assertNotIn("sqlite", reconcile.lower()) self.assertNotIn("PUBLISH_WORKFLOW_IDS", reconcile) + self.assertNotIn("publish:workflow", reconcile) + self.assertIn("/rest/workflows/", reconcile) + self.assertIn("/deactivate", reconcile) + self.assertIn("/activate", reconcile) + self.assertIn("active = false", reconcile) + verify = (ROOT / "scripts" / "verify.sh").read_text(encoding="utf-8") + self.assertIn("Did you mean to make a POST request", verify) + self.assertNotIn("*[Ww]ebhook*", verify) library = (ROOT / "scripts" / "lib.sh").read_text(encoding="utf-8") self.assertIn("/healthz/readiness", library) link_pattern = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")