From 6ad64b499b0a31552fac9b64e15768ded46693a5 Mon Sep 17 00:00:00 2001 From: crypt0rr <57799908+crypt0rr@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:02:21 +0200 Subject: [PATCH 1/2] Add service contract enforcement workflow --- .github/ISSUE_TEMPLATE/bug.yml | 45 +- .github/ISSUE_TEMPLATE/feature.yml | 9 + .github/ISSUE_TEMPLATE/new_service.yml | 43 +- .github/PULL_REQUEST_TEMPLATE.md | 2 + .github/workflows/service-contract.yml | 105 +++ .gitignore | 6 +- CONTRIBUTING.md | 44 ++ documentation/service-contract-baseline.md | 37 ++ templates/service-template/.env | 4 +- templates/service-template/README.md | 13 + templates/service-template/compose.yaml | 10 +- tools/requirements.txt | 1 + tools/service-profiles.yml | 116 ++++ tools/tests/__init__.py | 0 tools/tests/test_validate_services.py | 222 +++++++ tools/validate_services.py | 705 +++++++++++++++++++++ 16 files changed, 1350 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/service-contract.yml create mode 100644 documentation/service-contract-baseline.md create mode 100644 tools/requirements.txt create mode 100644 tools/service-profiles.yml create mode 100644 tools/tests/__init__.py create mode 100644 tools/tests/test_validate_services.py create mode 100644 tools/validate_services.py diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index dcb54209..c7079bbb 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -18,6 +18,33 @@ body: validations: required: true + - type: input + id: service + attributes: + label: ScaleTail Service + description: Which service directory is affected (for example, services/homebox)? + placeholder: services/ + validations: + required: true + + - type: input + id: image + attributes: + label: Image and Tag + description: Which application and Tailscale image tags are running? + placeholder: ghcr.io/example/service:1.2.3; tailscale/tailscale:latest + validations: + required: true + + - type: input + id: architecture + attributes: + label: Host Architecture + description: For example amd64, arm64, or armv7. + placeholder: amd64 + validations: + required: true + - type: textarea id: expected attributes: @@ -36,6 +63,18 @@ body: validations: required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction Steps + description: Include the exact commands used, such as docker compose config, up, or logs. + placeholder: | + 1. cd services/ + 2. docker compose config --quiet + 3. docker compose up -d + validations: + required: true + - type: textarea id: screenshots attributes: @@ -87,7 +126,7 @@ body: To get Docker logs: docker logs render: shell validations: - required: false + required: true - type: textarea id: compose @@ -103,7 +142,7 @@ body: render: yaml validations: - required: false + required: true - type: textarea id: env @@ -117,6 +156,7 @@ body: - TS_KEY - ENCRYPTION_KEY - JWT_SECRET + - Passwords and API tokens render: shell validations: @@ -130,4 +170,3 @@ body: placeholder: Any other relevant information... validations: required: false - \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index 84331d9e..f6b18bb4 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -38,6 +38,15 @@ body: validations: required: true + - type: textarea + id: compatibility + attributes: + label: Compatibility and Validation + description: Explain how this would affect existing services and how the change could be tested without breaking the sidecar contract. + placeholder: Existing services remain compatible because... I would validate this with... + validations: + required: true + - type: checkboxes id: contribute attributes: diff --git a/.github/ISSUE_TEMPLATE/new_service.yml b/.github/ISSUE_TEMPLATE/new_service.yml index 0f1d2a7b..273dba79 100644 --- a/.github/ISSUE_TEMPLATE/new_service.yml +++ b/.github/ISSUE_TEMPLATE/new_service.yml @@ -22,6 +22,24 @@ body: validations: required: true + - type: input + id: service-name + attributes: + label: Proposed Service Name + description: The lowercase directory name to use under services/. + placeholder: service-name + validations: + required: true + + - type: input + id: image + attributes: + label: Maintained Container Image + description: Provide the official registry image and a tag or release reference. + placeholder: ghcr.io/owner/service:1.2.3 + validations: + required: true + - type: input id: link-to-compose attributes: @@ -50,7 +68,30 @@ body: description: Please provide a link to the official website of the service. placeholder: https://servicename.com/service validations: - required: false + required: true + + - type: textarea + id: runtime-details + attributes: + label: Runtime Details + description: Document the internal HTTP/HTTPS/UDP ports, persistent paths, dependencies, required permissions/devices, and supported architectures. + placeholder: | + Internal port(s): + Persistent paths: + Dependencies: + Required capabilities/devices: + Architectures: + validations: + required: true + + - type: textarea + id: official-docs + attributes: + label: Official Install and Compose Documentation + description: Link the upstream installation, Docker image, and configuration documentation used to verify the proposal. + placeholder: https://docs.example.com/self-hosting + validations: + required: true - type: checkboxes id: contribute diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 91a9787a..44de5ed2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -18,6 +18,8 @@ - [ ] I have added verification that the stack works as expected. - [ ] I have updated necessary documentation (e.g. frontpage [README.md](https://github.com/tailscale-dev/ScaleTail/blob/main/README.md) ). - [ ] I have selected the correct label(s) for this PR. +- [ ] I preserved the template comments and ran `python tools/validate_services.py` plus `docker compose config --quiet` for each changed service. +- [ ] For a new service, I checked official upstream documentation for the image, internal port, healthcheck, volumes, permissions, dependencies, and architecture support. ## Additional Context diff --git a/.github/workflows/service-contract.yml b/.github/workflows/service-contract.yml new file mode 100644 index 00000000..c9ec2211 --- /dev/null +++ b/.github/workflows/service-contract.yml @@ -0,0 +1,105 @@ +name: Validate ScaleTail service contract + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - "services/**" + - "templates/service-template/**" + - "tools/**" + - "README.md" + - "CONTRIBUTING.md" + - ".github/PULL_REQUEST_TEMPLATE.md" + - ".github/ISSUE_TEMPLATE/**" + - ".github/workflows/service-contract.yml" + pull_request: + branches: + - main + paths: + - "services/**" + - "templates/service-template/**" + - "tools/**" + - "README.md" + - "CONTRIBUTING.md" + - ".github/PULL_REQUEST_TEMPLATE.md" + - ".github/ISSUE_TEMPLATE/**" + - ".github/workflows/service-contract.yml" + +permissions: + contents: read + +jobs: + service-contract: + name: Service contract + runs-on: ubuntu-latest + steps: + - name: Clone this repo + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install validator dependencies + run: python -m pip install --requirement tools/requirements.txt + + - name: Run validator tests + run: python -m unittest discover -s tools/tests -v + + - name: Validate changed services + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + BEFORE_SHA: ${{ github.event.before }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "pull_request" ]; then + python tools/validate_services.py --changed-from "$BASE_SHA" --format github + elif [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then + python tools/validate_services.py --changed-from "$BEFORE_SHA" --format github + else + python tools/validate_services.py --all --baseline --format github + fi + + - name: Run Compose config checks + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + BEFORE_SHA: ${{ github.event.before }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "pull_request" ]; then + reference="$BASE_SHA" + elif [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then + reference="$BEFORE_SHA" + else + reference="" + fi + if [ -n "$reference" ]; then + service_dirs=$(git diff --name-only "$reference" HEAD -- services/ | awk -F/ 'NF >= 2 {print $1 "/" $2}' | sort -u) + else + service_dirs=$(find services -mindepth 1 -maxdepth 1 -type d | sort) + fi + while IFS= read -r service_dir; do + [ -n "$service_dir" ] || continue + if [ -f "$service_dir/compose.yaml" ]; then + compose_file=compose.yaml + elif [ -f "$service_dir/compose.yml" ]; then + compose_file=compose.yml + else + continue + fi + (cd "$service_dir" && docker compose -f "$compose_file" config --quiet) + done <<< "$service_dirs" + + - name: Lint service documentation + uses: rvben/rumdl@v0.2.41 + with: + path: "services/" + config: ".markdownlint.yml" + report-type: annotations diff --git a/.gitignore b/.gitignore index 197c6574..a96c4bfa 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,8 @@ Temporary Items .rumdl_cache/ **/.rumdl_cache/ -# End of https://www.toptal.com/developers/gitignore/api/macos \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/macos + +# Python validator/test artifacts +__pycache__/ +*.py[cod] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f942de7c..8c4d585e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,51 @@ Thanks for helping expand these Tailscale sidecar examples. Keeping services ali - Link to upstream service docs and any official setup videos. 5. Sanity-check the stack with `docker compose config` from the service directory to catch typos and missing variables. +### Service contract and validation + +The files in `templates/service-template` are the canonical structure for a +new service. Keep the explanatory comments in the Tailscale and application +blocks; add service-specific comments beside the template comments instead of +deleting them. New services must use `compose.yaml`, include a complete `.env` +template, and add a categorized link to the root `README.md`. + +Run the repository validator before opening a pull request: + +```console +python -m pip install -r tools/requirements.txt +python tools/validate_services.py services/ +docker compose config --quiet +``` + +The `service-contract` GitHub check runs these deterministic checks for changed +services. It does not pull images or start third-party containers. Multi-container +and Tailscale-node layouts must be listed in `tools/service-profiles.yml` with +an ingress service and a reason for the exception. + +The validator cannot prove that an upstream image's internal port, healthcheck, +volume path, UID/GID, or device requirements are correct. Verify those details +against the service's official documentation and record the links and gotchas in +the service README before requesting review. + ## Updating an existing service - Keep the sidecar pattern intact (`network_mode: service:tailscale`, health checks, `depends_on`). - Avoid removing existing volumes or changing container names unless the change is clearly documented in the README. +- Preserve the template comments and run the validator for the service after any + Compose or `.env` change. + +## Issue and pull request review + +Use the personal `scaletail-maintainer` Codex skill for research-heavy reviews, +new-service validation, and issue triage. It reports findings by default and +only edits the local checkout when explicitly asked to fix something. It does +not push branches, post GitHub comments, resolve review threads, apply labels, +or close issues unless those actions are separately requested. + +Issue triage uses the existing GitHub labels plus these small cross-cutting +labels when they are useful: `needs-info`, `template`, `service`, `upstream`, +`security`, and `blocked`. Start by checking for duplicates and whether the +form contains enough reproduction or upstream information. Runtime reports +such as sidecar healthcheck and database-DNS failures need evidence from the +service, image, Docker/Compose, and Tailscale layers; a formatting-only change +is not proof that they are resolved. diff --git a/documentation/service-contract-baseline.md b/documentation/service-contract-baseline.md new file mode 100644 index 00000000..db28513d --- /dev/null +++ b/documentation/service-contract-baseline.md @@ -0,0 +1,37 @@ +# Service contract baseline + +This snapshot records the initial audit used to introduce the service contract +validator. The validator is intentionally stricter for new or modified +services; legacy findings remain visible in baseline mode until they are fixed. + +## Current inventory + +- 120 service directories contain a README, `.env`, and Compose entrypoint. +- 114 services use `compose.yaml`; 6 retain legacy `compose.yml` names. +- Three services use the `tailscale-node` profile because they advertise Tailscale routing roles instead of hosting an application. +- Multi-container services are listed explicitly in `tools/service-profiles.yml` with their ingress service and rationale. + +## Remediation backlog + +- `services/dockge`: `docker compose config --quiet` rejects the empty `STACKS_DIR` bind mount. +- `services/netbox`: `env_file: /.env` is an invalid absolute path from the service directory, and the ingress service lacks the Tailscale health dependency. +- `services/affine`, `services/flaresolverr`, `services/mattermost`, `services/next-explorer`, and `services/seafile`: ingress dependency chains need runtime-aware review and repair. +- `services/recyclarr`, `services/beszel-agent`, and `services/configarr`: the Tailscale sidecar does not persist its `/config` mount and needs an explicit decision or repair. +- `affine`, `filebrowser`, `minecraft`, and `next-explorer` are missing from the root README service index. +- Existing `.env` files contain secret-like defaults and mutable image tags; these are warnings in the baseline and must not be copied into new services. + +Run the audit with: + +```console +python tools/validate_services.py --all --baseline --format json +``` + +The initial audit reports eight structural/configuration errors (the two +Compose failures above plus six ingress health-dependency findings). The +remaining findings are tracked as warnings during rollout so the required PR +check can enforce changed services without hiding the repository-wide cleanup +work. + +Runtime issues such as database DNS failures and Tailscale healthcheck regressions +require service-specific research and should be handled through the maintainer +workflow rather than by mechanical template rewrites. diff --git a/templates/service-template/.env b/templates/service-template/.env index ad96b14c..bd72f50b 100644 --- a/templates/service-template/.env +++ b/templates/service-template/.env @@ -16,9 +16,9 @@ TS_AUTHKEY= # Auth key from https://tailscale.com/admin/authkeys. See: https://t # Optional Service variables # PUID=1000 -#Time Zone setting for containers +# Time Zone setting for containers TZ=Europe/Amsterdam # See: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones # Any Container environment variables are declared below. See https://docs.docker.com/compose/how-tos/environment-variables/ -#EXAMPLE_VAR="Environment varibale" +# EXAMPLE_VAR="Environment variable" diff --git a/templates/service-template/README.md b/templates/service-template/README.md index 58af12c9..84bd5db4 100644 --- a/templates/service-template/README.md +++ b/templates/service-template/README.md @@ -24,3 +24,16 @@ In this setup, the `tailscale-SERVICE` service runs Tailscale, which manages sec Please check the following contents for validity as some variables need to be defined upfront. - `.env` // Main variable `TS_AUTHKEY` + +## Validation checklist + +- Keep the comments and section order from the template Compose and `.env` + files. Add service-specific comments beside them; do not remove the template + guidance to make a file shorter. +- Confirm the user-facing container uses `network_mode: service:tailscale` and + waits for the Tailscale healthcheck. +- Verify the Serve proxy target against the app's actual internal listening + port. Serve configuration does not consume `.env` port variables. +- Run `python tools/validate_services.py services/` and + `docker compose config --quiet` from this service directory before opening a + pull request. diff --git a/templates/service-template/compose.yaml b/templates/service-template/compose.yaml index 03fd6370..77e390d7 100644 --- a/templates/service-template/compose.yaml +++ b/templates/service-template/compose.yaml @@ -8,8 +8,8 @@ configs: "AllowFunnel":{"$${TS_CERT_DOMAIN}:443":false}} services: -# Make sure you have updated/checked the .env file with the correct variables. -# All the ${ xx } need to be defined there. + # Make sure you have updated/checked the .env file with the correct variables. + # All the ${ xx } need to be defined there. # Tailscale Sidecar Configuration tailscale: image: tailscale/tailscale:latest # Image to be used @@ -22,7 +22,7 @@ services: - TS_USERSPACE=false - TS_ENABLE_HEALTH_CHECK=true # Enable healthcheck endpoint: "/healthz" - TS_LOCAL_ADDR_PORT=127.0.0.1:41234 # The : for the healthz endpoint - #- TS_ACCEPT_DNS=true # Uncomment when using MagicDNS + # - TS_ACCEPT_DNS=true # Uncomment when using MagicDNS - TS_AUTH_ONCE=true configs: - source: ts-serve @@ -52,11 +52,11 @@ services: image: ${IMAGE_URL} # Image to be used network_mode: service:tailscale # Sidecar configuration to route ${SERVICE} through Tailscale container_name: app-${SERVICE} # Name for local container management - environment: # Varibles are delared in .env file. + environment: # Variables are declared in .env file. - PUID=1000 - PGID=1000 - TZ=${TZ} - #- EXAMPLE_VAR=${EXAMPLE_VAR} + # - EXAMPLE_VAR=${EXAMPLE_VAR} volumes: - ./${SERVICE}-data/app/config:/config depends_on: diff --git a/tools/requirements.txt b/tools/requirements.txt new file mode 100644 index 00000000..8392d541 --- /dev/null +++ b/tools/requirements.txt @@ -0,0 +1 @@ +PyYAML==6.0.2 diff --git a/tools/service-profiles.yml b/tools/service-profiles.yml new file mode 100644 index 00000000..6d7d75e1 --- /dev/null +++ b/tools/service-profiles.yml @@ -0,0 +1,116 @@ +version: 1 + +profiles: + sidecar-web: + description: A single user-facing application routed through the Tailscale sidecar. + multi-container: + description: A service with multiple application or dependency containers and an explicit ingress service. + tailscale-node: + description: A Tailscale routing/service deployment without an application sidecar. + +services: + affine: + profile: multi-container + ingress: application + reason: Affine starts a migration job and has Redis and PostgreSQL dependencies. + booklore: + profile: multi-container + ingress: application + reason: BookLore includes a MariaDB dependency. + caddy: + profile: multi-container + ingress: application + reason: Caddy includes an auxiliary whoami service. + coder: + profile: multi-container + ingress: application + reason: Coder includes a database service. + docmost: + profile: multi-container + ingress: application + reason: Docmost includes database and Redis dependencies. + espocrm: + profile: multi-container + ingress: application + reason: EspoCRM includes a database service. + formbricks: + profile: multi-container + ingress: formbricks + reason: Formbricks uses PostgreSQL and Redis services. + flaresolverr: + profile: multi-container + ingress: application + reason: FlareSolverr uses an API-server suffix in its container name even though it has one application container. + ghost: + profile: multi-container + ingress: application + reason: Ghost includes a database service. + grampsweb: + profile: multi-container + ingress: application + reason: Gramps Web includes worker and Redis services. + immich: + profile: multi-container + ingress: application + reason: Immich includes machine-learning, Redis, and database services. + kaneo: + profile: multi-container + ingress: frontend + reason: Kaneo has separate frontend, backend, and PostgreSQL services sharing the sidecar namespace. + karakeep: + profile: multi-container + ingress: web + reason: Karakeep includes Chrome and Meilisearch dependencies. + mattermost: + profile: multi-container + ingress: application + reason: Mattermost includes a database service. + miniflux: + profile: multi-container + ingress: application + reason: Miniflux includes a database service. + netbox: + profile: multi-container + ingress: netbox + reason: NetBox includes worker, PostgreSQL, Redis, and Redis-cache services. + paperless: + profile: multi-container + ingress: application + reason: Paperless includes database and broker services. + rustdesk-server: + profile: multi-container + ingress: application + reason: RustDesk uses separate hbbs and hbbr services. + seafile: + profile: multi-container + ingress: seafile + reason: Seafile includes database and memcached dependencies. + searxng: + profile: multi-container + ingress: application + reason: SearXNG includes a Valkey dependency. + sure: + profile: multi-container + ingress: web + reason: Sure includes web, worker, database, backup, and Redis services. + tandoor: + profile: multi-container + ingress: application + reason: Tandoor includes a database service. + traefik: + profile: multi-container + ingress: traefik_proxy + reason: Traefik includes a separately routed simpleweb example service. + xwiki: + profile: multi-container + ingress: application + reason: XWiki includes a database service. + tailscale-app-connector-node: + profile: tailscale-node + reason: This service advertises a Tailscale app connector rather than hosting an application. + tailscale-exit-node: + profile: tailscale-node + reason: This service advertises a Tailscale exit node rather than hosting an application. + tailscale-subnet-router-node: + profile: tailscale-node + reason: This service advertises Tailscale subnet routes rather than hosting an application. diff --git a/tools/tests/__init__.py b/tools/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/tests/test_validate_services.py b/tools/tests/test_validate_services.py new file mode 100644 index 00000000..b548db35 --- /dev/null +++ b/tools/tests/test_validate_services.py @@ -0,0 +1,222 @@ +import sys +import tempfile +import unittest +from pathlib import Path +from subprocess import CompletedProcess +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parents[1])) +import validate_services # noqa: E402 + + +TEMPLATE_ENV = """# Service Configuration +SERVICE=demo +IMAGE_URL=example/demo:1.0 +# Network Configuration +SERVICEPORT=8080 +DNS_SERVER=9.9.9.9 +# Tailscale Configuration +TS_AUTHKEY= +# Time Zone setting for containers +TZ=Europe/Amsterdam +# Any Container environment variables are declared below +""" + +TEMPLATE_README = """# Demo with Tailscale Sidecar Configuration + +## Configuration Overview + +Demo uses Tailscale Serve over port 8080. Prerequisites include Docker access. +Persistent data is stored in the documented volume. See the official +documentation at https://example.com/docs. +""" + +BASE_COMPOSE = """configs: + ts-serve: + content: | + {\"TCP\":{\"443\":{\"HTTPS\":true}},\"Web\":{\"$${TS_CERT_DOMAIN}:443\":{\"Handlers\":{\"/\":{\"Proxy\":\"http://127.0.0.1:8080\"}}}}} +services: + # Tailscale Sidecar Configuration + tailscale: + image: tailscale/tailscale:1.0 # Image to be used + container_name: tailscale-${SERVICE} # Name for local container management + hostname: ${SERVICE} # Name used within your Tailscale environment + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json # Tailscale Serve configuration + - TS_USERSPACE=false + - TS_ENABLE_HEALTH_CHECK=true + - TS_LOCAL_ADDR_PORT=127.0.0.1:41234 + volumes: + - ./config:/config # Config folder used to store Tailscale files + - ./ts/state:/var/lib/tailscale # Tailscale requirement + devices: + - /dev/net/tun:/dev/net/tun # Network configuration for Tailscale to work + cap_add: + - net_admin # Tailscale requirement + healthcheck: + test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"http://127.0.0.1:41234/healthz\"] # Check Tailscale has a Tailnet IP and is operational + restart: always + # ${SERVICE} + application: + image: ${IMAGE_URL} # Image to be used + network_mode: service:tailscale # Sidecar configuration to route ${SERVICE} through Tailscale + container_name: app-${SERVICE} # Name for local container management + environment: # Variables are declared in .env file. + - TZ=${TZ} + volumes: + - ./${SERVICE}-data:/config + depends_on: + database: + condition: service_started + healthcheck: + test: [\"CMD\", \"pgrep\", \"-f\", \"demo\"] + restart: always + database: + image: postgres:16 +""" + + +class ValidateServicesTests(unittest.TestCase): + def test_duplicate_yaml_keys_are_rejected(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "compose.yaml" + path.write_text("services:\n app:\n image: one\n image: two\n", encoding="utf-8") + with self.assertRaises(validate_services.DuplicateKeyError): + validate_services.load_yaml(path) + + def test_malformed_yaml_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "compose.yaml" + path.write_text("services:\n app: [\n", encoding="utf-8") + with self.assertRaises(validate_services.yaml.YAMLError): + validate_services.load_yaml(path) + + def test_new_service_requires_tailscale_health_dependency(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + service = root / "services" / "demo" + (root / "tools").mkdir(parents=True) + service.mkdir(parents=True) + (root / "README.md").write_text("[Demo](services/demo)\n", encoding="utf-8") + (service / ".env").write_text(TEMPLATE_ENV, encoding="utf-8") + (service / "README.md").write_text(TEMPLATE_README, encoding="utf-8") + (service / "compose.yaml").write_text(BASE_COMPOSE, encoding="utf-8") + validator = validate_services.Validator( + root, + {"profiles": {"sidecar-web": {}}, "services": {}}, + ) + with patch.object( + validate_services.subprocess, + "run", + return_value=CompletedProcess([], 0, "", ""), + ): + validator.validate(service, is_new=True) + codes = {finding.code for finding in validator.findings} + self.assertIn("TAILSCALE_HEALTH_DEPENDENCY", codes) + + def test_tailscale_node_does_not_require_serve_config(self): + validator = validate_services.Validator( + validate_services.ROOT, + validate_services.load_yaml(validate_services.PROFILE_FILE), + baseline=True, + ) + with patch.object( + validate_services.subprocess, + "run", + return_value=CompletedProcess([], 0, "", ""), + ): + validator.validate(validate_services.ROOT / "services" / "tailscale-exit-node") + codes = {finding.code for finding in validator.findings} + self.assertNotIn("TAILSCALE_CONFIG_VOLUME", codes) + self.assertNotIn("INGRESS_SERVICE_MISSING", codes) + + def test_extra_application_requires_explicit_profile(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + service = root / "services" / "demo" + (root / "tools").mkdir(parents=True) + service.mkdir(parents=True) + (root / "README.md").write_text("| Demo | A service | (services/demo) |\n", encoding="utf-8") + (service / ".env").write_text(TEMPLATE_ENV, encoding="utf-8") + (service / "README.md").write_text(TEMPLATE_README, encoding="utf-8") + (service / "compose.yaml").write_text(BASE_COMPOSE + " worker:\n image: example/worker:1.0\n", encoding="utf-8") + validator = validate_services.Validator( + root, + {"profiles": {"sidecar-web": {}}, "services": {}}, + ) + with patch.object( + validate_services.subprocess, + "run", + return_value=CompletedProcess([], 0, "", ""), + ): + validator.validate(service, is_new=True) + self.assertIn("PROFILE_REQUIRED", {finding.code for finding in validator.findings}) + + def test_published_ports_need_readme_explanation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + service = root / "services" / "demo" + (root / "tools").mkdir(parents=True) + service.mkdir(parents=True) + (root / "README.md").write_text("[Demo](services/demo)\n", encoding="utf-8") + (service / ".env").write_text(TEMPLATE_ENV, encoding="utf-8") + (service / "README.md").write_text(TEMPLATE_README.replace("port 8080", "the app"), encoding="utf-8") + (service / "compose.yaml").write_text(BASE_COMPOSE.replace(" healthcheck:\n", " ports:\n - 0.0.0.0:8080:8080\n healthcheck:\n"), encoding="utf-8") + validator = validate_services.Validator( + root, + {"profiles": {"sidecar-web": {}}, "services": {}}, + ) + with patch.object( + validate_services.subprocess, + "run", + return_value=CompletedProcess([], 0, "", ""), + ): + validator.validate(service, is_new=True) + self.assertIn("PORTS_UNDOCUMENTED", {finding.code for finding in validator.findings}) + + def test_secret_like_values_are_reported(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / ".env" + path.write_text(TEMPLATE_ENV + "DATABASE_PASSWORD=real-value\n", encoding="utf-8") + validator = validate_services.Validator(validate_services.ROOT, {"services": {}}) + validator.validate_env("demo", path, is_new=True, profile="sidecar-web") + self.assertIn("ENV_SECRET_LIKE_VALUE", {finding.code for finding in validator.findings}) + + def test_missing_template_comments_are_reported(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / ".env" + path.write_text("SERVICE=demo\nIMAGE_URL=example/demo:1.0\n", encoding="utf-8") + validator = validate_services.Validator(validate_services.ROOT, {"services": {}}) + validator.validate_env("demo", path, is_new=True, profile="sidecar-web") + self.assertIn("ENV_COMMENT_MISSING", {finding.code for finding in validator.findings}) + + def test_invalid_serve_proxy_is_reported(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + service = root / "services" / "demo" + (root / "tools").mkdir(parents=True) + service.mkdir(parents=True) + (root / "README.md").write_text("[Demo](services/demo)\n", encoding="utf-8") + (service / ".env").write_text(TEMPLATE_ENV, encoding="utf-8") + (service / "README.md").write_text(TEMPLATE_README, encoding="utf-8") + (service / "compose.yaml").write_text( + BASE_COMPOSE.replace("http://127.0.0.1:8080", "http://127.0.0.1:${SERVICEPORT}"), + encoding="utf-8", + ) + validator = validate_services.Validator( + root, + {"profiles": {"sidecar-web": {}}, "services": {}}, + ) + with patch.object( + validate_services.subprocess, + "run", + return_value=CompletedProcess([], 0, "", ""), + ): + validator.validate(service, is_new=True) + self.assertIn("SERVE_PROXY_INTERPOLATION", {finding.code for finding in validator.findings}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate_services.py b/tools/validate_services.py new file mode 100644 index 00000000..f614a937 --- /dev/null +++ b/tools/validate_services.py @@ -0,0 +1,705 @@ +#!/usr/bin/env python3 +"""Validate ScaleTail service directories against the sidecar contract. + +The validator intentionally checks deterministic repository invariants only. It +does not pull images or start containers; upstream service behavior is reviewed +by the ScaleTail maintainer skill. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Iterable + +try: + import yaml +except ImportError: # pragma: no cover - exercised by CI setup failures + print("PyYAML is required; install tools/requirements.txt", file=sys.stderr) + raise SystemExit(2) + + +@dataclass +class Finding: + severity: str + code: str + path: str + line: int | None + message: str + remediation: str + service: str + + +class DuplicateKeyError(yaml.YAMLError): + def __init__(self, key: Any, line: int): + super().__init__(f"duplicate YAML key {key!r} at line {line}") + self.key = key + self.line = line + + +class UniqueKeyLoader(yaml.SafeLoader): + """SafeLoader variant that rejects duplicate mapping keys.""" + + +def _construct_unique_mapping(loader: UniqueKeyLoader, node: yaml.MappingNode, deep: bool = False): + mapping: dict[Any, Any] = {} + merged_keys: set[Any] = set() + for key_node, value_node in node.value: + # YAML merge keys intentionally provide defaults that an explicit key + # in the current mapping may override (for example netbox-worker). + # They are not duplicate keys in the source mapping and must therefore + # be handled separately from repeated explicit keys. + if key_node.tag == "tag:yaml.org,2002:merge": + merged = loader.construct_object(value_node, deep=deep) + merged_items = merged.items() if isinstance(merged, dict) else [] + for merged_key, merged_value in merged_items: + if merged_key not in mapping: + mapping[merged_key] = merged_value + merged_keys.add(merged_key) + continue + key = loader.construct_object(key_node, deep=deep) + if key in mapping and key not in merged_keys: + raise DuplicateKeyError(key, key_node.start_mark.line + 1) + mapping[key] = loader.construct_object(value_node, deep=deep) + return mapping + + +UniqueKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping +) + + +ROOT = Path(__file__).resolve().parents[1] +PROFILE_FILE = ROOT / "tools" / "service-profiles.yml" +ENV_COMMENT_ANCHORS = ( + "# Service Configuration", + "# Network Configuration", + "# Tailscale Configuration", + "# Time Zone setting for containers", + "# Any Container environment variables are declared below", +) +COMPOSE_COMMENT_ANCHORS = ( + "# Tailscale Sidecar Configuration", + "# Image to be used", + "# Name for local container management", + "# Name used within your Tailscale environment", + "# Tailscale Serve configuration", + "# Tailscale requirement", + "# Network configuration for Tailscale to work", + "# Check Tailscale has a Tailnet IP and is operational", + "# ${SERVICE}", + "# Sidecar configuration to route", +) +PLACEHOLDER_PATTERNS = ( + "LINK TO PAGE", + "information about the service", + "Explain what the app does", + "SERVICE with Tailscale Sidecar", +) +SECRET_KEY_RE = re.compile(r"(PASSWORD|SECRET|TOKEN|PRIVATE_KEY|API_KEY|ENCRYPTION_KEY)", re.I) +ENV_KEY_RE = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$") + + +def load_yaml(path: Path) -> Any: + with path.open(encoding="utf-8") as handle: + return yaml.load(handle, Loader=UniqueKeyLoader) + + +def rel(path: Path, root: Path = ROOT) -> str: + try: + return str(path.resolve().relative_to(root.resolve())) + except ValueError: + return str(path) + + +def line_for(text: str, needle: str) -> int | None: + for number, line in enumerate(text.splitlines(), 1): + if needle in line: + return number + return None + + +def clean_env_value(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"": + value = value[1:-1] + if " #" in value: + value = value.split(" #", 1)[0].rstrip() + return value + + +def parse_env(path: Path) -> tuple[dict[str, str], dict[str, int]]: + values: dict[str, str] = {} + lines: dict[str, int] = {} + for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + match = ENV_KEY_RE.match(raw.strip()) + if not match: + continue + key, value = match.groups() + values[key] = clean_env_value(value) + lines[key] = number + return values, lines + + +def is_placeholder(value: str) -> bool: + lowered = value.strip().lower() + return ( + not lowered + or lowered.startswith("${") + or lowered.startswith("<") + or lowered.startswith("// your") + or lowered.startswith("your ") + or lowered.startswith("replace") + or lowered.startswith("change") + or lowered.startswith("strongpassword") + or lowered.startswith("some_random") + or lowered in {"password", "changeme", "****", "..."} + or "auth key from" in lowered + or "generate a random" in lowered + ) + + +def env_items(environment: Any) -> dict[str, str]: + if isinstance(environment, dict): + return {str(key): "" if value is None else str(value) for key, value in environment.items()} + result: dict[str, str] = {} + if isinstance(environment, list): + for item in environment: + if not isinstance(item, str) or "=" not in item: + continue + key, value = item.split("=", 1) + result[key] = value + return result + + +def contains_mount(value: Any, target: str) -> bool: + if isinstance(value, str): + return target in value.split(":", 2)[-1] + if isinstance(value, dict): + return value.get("target") == target + return False + + +def contains_device(value: Any, target: str) -> bool: + if isinstance(value, str): + return target in value + if isinstance(value, dict): + return value.get("target") == target or value.get("path_in_container") == target + return False + + +def has_capability(value: Any, capability: str) -> bool: + if not isinstance(value, list): + return False + return any(str(item).lower() == capability.lower() for item in value) + + +class Validator: + def __init__(self, root: Path, profiles: dict[str, Any], baseline: bool = False): + self.root = root + self.profiles = profiles + self.baseline = baseline + self.findings: list[Finding] = [] + self.root_readme = (root / "README.md").read_text(encoding="utf-8") + + def add( + self, + service: str, + severity: str, + code: str, + path: Path, + message: str, + remediation: str, + line: int | None = None, + ) -> None: + self.findings.append( + Finding(severity, code, rel(path, self.root), line, message, remediation, service) + ) + + def deterministic_severity(self) -> str: + """Return the enforcement level for deterministic contract findings.""" + return "warning" if self.baseline else "error" + + def check_comment_order( + self, + service: str, + path: Path, + text: str, + anchors: tuple[str, ...], + code: str, + ) -> None: + positions = [(text.find(anchor), anchor) for anchor in anchors if anchor in text] + if any(left[0] > right[0] for left, right in zip(positions, positions[1:])): + self.add( + service, + self.deterministic_severity(), + code, + path, + "Template comment anchors are out of order.", + "Restore the template section order and keep service-specific comments beside the relevant setting.", + ) + + def validate(self, service_dir: Path, is_new: bool = False) -> None: + service = service_dir.name + readme = service_dir / "README.md" + env_file = service_dir / ".env" + compose = service_dir / "compose.yaml" + legacy_compose = service_dir / "compose.yml" + raw_profile_entry = (self.profiles.get("services") or {}).get(service, {}) + profile_entry = raw_profile_entry if isinstance(raw_profile_entry, dict) else {} + profile = profile_entry.get("profile", "sidecar-web") + profile_defs = self.profiles.get("profiles") or {} + + if profile not in profile_defs: + self.add(service, "error", "PROFILE_UNKNOWN", PROFILE_FILE, + f"Profile {profile!r} is not defined.", + "Use sidecar-web or add a reviewed profile definition.") + profile = "sidecar-web" + if profile != "sidecar-web" and not profile_entry.get("reason"): + self.add(service, self.deterministic_severity(), "PROFILE_REASON_MISSING", PROFILE_FILE, + f"Profile {profile!r} does not document why this service differs from the default topology.", + "Add a concise maintainer-owned reason to tools/service-profiles.yml.") + if profile == "multi-container" and not profile_entry.get("ingress"): + self.add(service, self.deterministic_severity(), "PROFILE_INGRESS_MISSING", PROFILE_FILE, + "The multi-container profile must identify its ingress service.", + "Set services..ingress to the application routed through Tailscale.") + + if not readme.exists(): + self.add(service, "error", "FILE_README_MISSING", readme, + "README.md is required.", "Copy the template README and document service-specific behavior.") + if not env_file.exists(): + self.add(service, "error", "FILE_ENV_MISSING", env_file, + ".env is required.", "Copy the template .env and add non-secret service variables.") + if not compose.exists(): + if legacy_compose.exists(): + severity = "warning" if not is_new else "error" + self.add(service, severity, "COMPOSE_LEGACY_NAME", legacy_compose, + "New services must use compose.yaml; compose.yml is a legacy compatibility exception.", + "Rename the new service Compose file to compose.yaml.") + compose = legacy_compose + else: + self.add(service, "error", "FILE_COMPOSE_MISSING", compose, + "compose.yaml is required.", "Copy the template Compose file.") + return + elif is_new and legacy_compose.exists(): + self.add(service, "error", "COMPOSE_BOTH_NAMES", legacy_compose, + "A service must not ship both compose.yaml and compose.yml.", + "Keep compose.yaml as the sole Compose entrypoint.") + + if env_file.exists(): + self.validate_env(service, env_file, is_new, profile) + readme_text = readme.read_text(encoding="utf-8") if readme.exists() else "" + if readme.exists(): + self.validate_readme(service, readme, readme_text, profile, is_new) + root_link = f"(services/{service})" + root_lines = [ + (number, line) for number, line in enumerate(self.root_readme.splitlines(), 1) + if root_link in line + ] + if not root_lines: + self.add(service, self.deterministic_severity(), "README_INDEX_MISSING", + self.root / "README.md", "The service is not linked from the root README.", + "Add a categorized service row to README.md.") + elif not any( + "|" in line + and len( + [part.strip() for part in line.split("|") if root_link not in part and part.strip()] + ) >= 2 + for _, line in root_lines + ): + self.add(service, self.deterministic_severity(), "README_INDEX_DESCRIPTION_MISSING", + self.root / "README.md", "The root README link does not include a service description.", + "Add the service to a categorized table with a concise description.", root_lines[0][0]) + + try: + data = load_yaml(compose) + except DuplicateKeyError as exc: + self.add(service, "error", "YAML_DUPLICATE_KEY", compose, + str(exc), "Remove duplicate YAML keys; merge the mappings explicitly.", exc.line) + return + except yaml.YAMLError as exc: + line = getattr(getattr(exc, "problem_mark", None), "line", None) + self.add(service, "error", "YAML_INVALID", compose, + f"Compose YAML cannot be parsed: {exc}", "Fix YAML syntax and rerun validation.", + line + 1 if isinstance(line, int) else None) + return + except OSError as exc: + self.add(service, "error", "COMPOSE_READ_FAILED", compose, + str(exc), "Make the Compose file readable.") + return + + self.validate_compose(service, compose, data, profile, profile_entry, readme_text, is_new) + self.validate_compose_config(service, compose) + + def validate_env(self, service: str, path: Path, is_new: bool, profile: str) -> None: + text = path.read_text(encoding="utf-8") + values, lines = parse_env(path) + for anchor in ENV_COMMENT_ANCHORS: + alternatives = (anchor, "#Time Zone setting for containers") if anchor.startswith("# Time Zone") else (anchor,) + if not any(candidate in text for candidate in alternatives): + self.add(service, self.deterministic_severity(), "ENV_COMMENT_MISSING", path, + f"Template comment section {anchor!r} is missing.", + "Restore the template comment section without removing service-specific comments.") + self.check_comment_order(service, path, text, ENV_COMMENT_ANCHORS, "ENV_COMMENT_ORDER") + required_keys = ("SERVICE", "DNS_SERVER", "TS_AUTHKEY", "TZ") + if profile != "tailscale-node" and not any(key.startswith("IMAGE_URL") for key in values): + required_keys += ("IMAGE_URL",) + if profile not in {"tailscale-node", "multi-container"} and not any(key.startswith("SERVICEPORT") for key in values): + required_keys += ("SERVICEPORT",) + for key in required_keys: + if key not in values: + self.add(service, self.deterministic_severity(), "ENV_KEY_MISSING", path, + f"Required variable {key} is missing.", + "Define the variable using the template .env structure.") + for key in ("SERVICE", "IMAGE_URL", "TZ"): + if key in values and not values[key].strip(): + self.add(service, "error", "ENV_VALUE_EMPTY", path, + f"{key} must have a value.", "Set a safe non-secret value in .env.", lines.get(key)) + if "SERVICE" in values and re.search(r"\s", values["SERVICE"]): + self.add(service, "error", "ENV_SERVICE_INVALID", path, + "SERVICE must not contain whitespace.", "Use a DNS/container-safe service name.", lines.get("SERVICE")) + for key, value in values.items(): + if SECRET_KEY_RE.search(key) and value and not is_placeholder(value): + self.add(service, self.deterministic_severity(), "ENV_SECRET_LIKE_VALUE", path, + f"{key} contains a non-placeholder secret-like value.", + "Use a blank or clearly documented placeholder; never commit real credentials.", lines.get(key)) + + def validate_readme(self, service: str, path: Path, text: str, profile: str, is_new: bool) -> None: + lower = text.lower() + for placeholder in PLACEHOLDER_PATTERNS: + if placeholder.lower() in lower: + self.add(service, self.deterministic_severity(), "README_PLACEHOLDER", path, + f"Template placeholder text remains: {placeholder!r}.", + "Replace template placeholders with service-specific documentation.", line_for(text, placeholder)) + if not re.search(r"^# .*tailscale.*configuration", text, re.I | re.M): + self.add(service, self.deterministic_severity(), "README_TITLE", path, + "README title should identify the service and Tailscale configuration.", + "Use the template title structure.", 1) + required = { + "overview": "Configuration Overview" in text, + "upstream_link": bool(re.search(r"https?://", text)), + "tailscale": "tailscale" in lower, + } + if profile != "tailscale-node": + required.update({ + "ports": bool(re.search(r"\bport\b|listen|endpoint", lower)), + "storage": bool(re.search(r"volume|storage|persistent|data", lower)), + "prerequisites": bool(re.search(r"prerequisite|docker group|permission|uid|gid", lower)), + "serve": bool(re.search(r"magicdns|serve|funnel|https", lower)), + "links": bool(re.search(r"official|upstream|documentation|docs", lower)), + }) + for name, present in required.items(): + if not present: + self.add(service, self.deterministic_severity(), "README_CONTENT_MISSING", path, + f"README is missing required documentation area: {name}.", + "Document the template's service behavior, prerequisites, ports, storage, networking, and upstream links.") + if profile == "tailscale-node" and "exit node" not in lower and "subnet" not in lower and "connector" not in lower: + self.add(service, self.deterministic_severity(), "README_NODE_CONTEXT_MISSING", path, + "Tailscale-node README does not explain the advertised routing role.", + "Document the node role and required Tailscale approval steps.") + + def validate_compose( + self, + service: str, + path: Path, + data: Any, + profile: str, + profile_entry: dict[str, Any], + readme_text: str, + is_new: bool, + ) -> None: + if not isinstance(data, dict) or not isinstance(data.get("services"), dict): + self.add(service, "error", "COMPOSE_SERVICES_MISSING", path, + "Compose file must define a services mapping.", "Use the template Compose structure.") + return + services = data["services"] + app_services = [name for name in services if name != "tailscale"] + if profile == "sidecar-web" and len(app_services) != 1: + self.add(service, self.deterministic_severity(), "PROFILE_REQUIRED", path, + "This Compose file has more than one non-Tailscale service but uses the default sidecar-web profile.", + "Add a reviewed multi-container profile entry with the routed ingress service and topology reason.") + for name, value in services.items(): + if not isinstance(value, dict) or name == "tailscale": + continue + image = str(value.get("image", "")) + image_ref = image.rsplit("/", 1)[-1] + if image.endswith(":latest") or (image and not image.startswith("${") and ":" not in image_ref and "@" not in image): + self.add(service, "warning", "IMAGE_MUTABLE_TAG", path, + f"Service {name!r} uses a mutable or implicit-latest image reference {image!r}.", + "Prefer a reviewed immutable version when changing this service; mass pinning is a separate rollout.") + tailscale = services.get("tailscale") + if not isinstance(tailscale, dict): + self.add(service, "error", "TAILSCALE_SERVICE_MISSING", path, + "A tailscale service is required for this profile.", "Add the template Tailscale sidecar.") + return + self.validate_tailscale(service, path, tailscale, data, profile, is_new) + if profile == "tailscale-node": + return + + ingress_name = profile_entry.get("ingress") + if not ingress_name: + if "application" in services: + ingress_name = "application" + else: + candidates = [ + name for name, value in services.items() + if isinstance(value, dict) and value.get("container_name") == "app-${SERVICE}" + ] + if candidates: + ingress_name = candidates[0] + else: + candidates = [ + name for name, value in services.items() + if isinstance(value, dict) and value.get("network_mode") == "service:tailscale" + ] + ingress_name = candidates[0] if candidates else None + ingress = services.get(ingress_name) if ingress_name else None + if not isinstance(ingress, dict): + self.add(service, "error", "INGRESS_SERVICE_MISSING", path, + "The service profile does not identify a valid ingress service.", + "Add an explicit ingress service to tools/service-profiles.yml.") + return + if ingress.get("network_mode") != "service:tailscale": + self.add(service, "error", "SIDECAR_NETWORK_MODE", path, + f"Ingress service {ingress_name!r} must use network_mode: service:tailscale.", + "Route the ingress service through the Tailscale sidecar.", line_for(path.read_text(encoding="utf-8"), "network_mode:")) + depends = ingress.get("depends_on") + tail_dep = depends.get("tailscale") if isinstance(depends, dict) else None + if not isinstance(tail_dep, dict) or tail_dep.get("condition") != "service_healthy": + self.add(service, "error", "TAILSCALE_HEALTH_DEPENDENCY", path, + f"Ingress service {ingress_name!r} must depend on a healthy tailscale service.", + "Add depends_on.tailscale.condition: service_healthy.") + container_name = str(ingress.get("container_name", "")) + if profile == "sidecar-web" and container_name != "app-${SERVICE}": + self.add(service, "error", "APP_CONTAINER_NAME", path, + "The default ingress container must be named app-${SERVICE}.", + "Use the template container name or add a reviewed multi-container profile.") + elif profile == "multi-container" and not (container_name.startswith("app-") or container_name == "${SERVICE}"): + self.add(service, "error", "APP_CONTAINER_NAME", path, + f"Ingress container {container_name!r} is not an approved app container name.", + "Use an app-* name or document the intentional name in the profile.") + if is_new and "healthcheck" not in ingress: + self.add(service, "error", "APP_HEALTHCHECK_MISSING", path, + "New ingress services must define an application healthcheck.", + "Use an image-supported command or endpoint and document it in the README.") + + published_ports = { + name: value.get("ports") + for name, value in services.items() + if isinstance(value, dict) and value.get("ports") + } + if published_ports: + if not re.search(r"(?i)\b(lan|local network|host port|0\.0\.0\.0)\b", readme_text): + self.add(service, self.deterministic_severity(), "PORTS_UNDOCUMENTED", path, + f"Published host ports on {', '.join(published_ports)} are not explained in the README.", + "Explain why host/LAN exposure is required and which ports are published.") + + compose_text = path.read_text(encoding="utf-8") + tail_environment = env_items(tailscale.get("environment")) + for anchor in COMPOSE_COMMENT_ANCHORS: + if anchor.startswith("# Tailscale Serve") and "TS_SERVE_CONFIG" not in tail_environment: + continue + if anchor not in compose_text: + self.add(service, self.deterministic_severity(), "COMPOSE_COMMENT_MISSING", path, + f"Template comment anchor {anchor!r} is missing.", + "Restore the template comment adjacent to the relevant setting.") + self.check_comment_order(service, path, compose_text, COMPOSE_COMMENT_ANCHORS, "COMPOSE_COMMENT_ORDER") + if "TS_SERVE_CONFIG" in tail_environment: + config = data.get("configs", {}).get("ts-serve") if isinstance(data.get("configs"), dict) else None + content = config.get("content", "") if isinstance(config, dict) else "" + if not content or "Proxy" not in content: + self.add(service, self.deterministic_severity(), "SERVE_PROXY_MISSING", path, + "TS_SERVE_CONFIG is enabled but the ts-serve config has no Proxy handler.", + "Add a documented proxy target or remove TS_SERVE_CONFIG when Serve is not used.") + proxies = re.findall(r"[\"']Proxy[\"']\s*:\s*[\"']([^\"']+)", str(content)) + for proxy in proxies: + if "${" in proxy: + self.add(service, self.deterministic_severity(), "SERVE_PROXY_INTERPOLATION", path, + f"Serve proxy target {proxy!r} still contains Compose interpolation.", + "Use the service's actual internal loopback port; Serve config does not consume .env values.") + if re.search(r":80(?:/|$)", proxy) and self._env_service_port(service) not in {None, "", "80"}: + self.add(service, "warning", "SERVE_PROXY_DEFAULT_PORT", path, + f"Serve proxy target {proxy!r} still uses the template's port 80.", + "Verify the service's actual internal listening port against upstream documentation.") + + def _env_service_port(self, service: str) -> str | None: + env = self.root / "services" / service / ".env" + if not env.exists(): + return None + values, _ = parse_env(env) + return values.get("SERVICEPORT") + + def validate_tailscale( + self, + service: str, + path: Path, + tailscale: dict[str, Any], + data: dict[str, Any], + profile: str, + is_new: bool, + ) -> None: + text = path.read_text(encoding="utf-8") + image = str(tailscale.get("image", "")) + if not image.startswith("tailscale/tailscale"): + self.add(service, "error", "TAILSCALE_IMAGE", path, + "tailscale.image must use the tailscale/tailscale image.", + "Use tailscale/tailscale with a reviewed tag.") + if image.endswith(":latest"): + self.add(service, "warning", "IMAGE_MUTABLE_TAG", path, + "Tailscale uses the mutable latest tag.", + "Prefer a reviewed version when changing this service; pinning all existing services is a separate rollout.") + if tailscale.get("container_name") != "tailscale-${SERVICE}": + self.add(service, "error", "TAILSCALE_CONTAINER_NAME", path, + "Tailscale container must be named tailscale-${SERVICE}.", "Use the template container name.") + if tailscale.get("hostname") != "${SERVICE}": + self.add(service, "error", "TAILSCALE_HOSTNAME", path, + "Tailscale hostname must be ${SERVICE}.", "Use SERVICE as the Tailscale hostname.") + environment = env_items(tailscale.get("environment")) + for key in ("TS_AUTHKEY", "TS_STATE_DIR", "TS_USERSPACE", "TS_ENABLE_HEALTH_CHECK", "TS_LOCAL_ADDR_PORT"): + if key not in environment: + self.add(service, "error", "TAILSCALE_ENV_MISSING", path, + f"Tailscale environment variable {key} is missing.", "Restore the template Tailscale environment.") + if environment.get("TS_ENABLE_HEALTH_CHECK", "").lower() != "true": + self.add(service, "error", "TAILSCALE_HEALTH_DISABLED", path, + "TS_ENABLE_HEALTH_CHECK must be true.", "Enable the Tailscale health endpoint.") + health = tailscale.get("healthcheck") + health_text = json.dumps(health, sort_keys=True) if health is not None else "" + if "/healthz" not in health_text: + self.add(service, "error", "TAILSCALE_HEALTHCHECK", path, + "Tailscale healthcheck must probe /healthz.", "Use the template healthcheck endpoint.") + volumes = tailscale.get("volumes", []) + if profile != "tailscale-node" and not any(contains_mount(item, "/config") for item in volumes): + self.add(service, self.deterministic_severity(), "TAILSCALE_CONFIG_VOLUME", path, + "Tailscale must mount a config directory at /config.", "Keep the template config mount.") + if not any(contains_mount(item, "/var/lib/tailscale") for item in volumes): + self.add(service, "error", "TAILSCALE_STATE_VOLUME", path, + "Tailscale must persist /var/lib/tailscale.", "Keep the template state mount.") + if not any(contains_device(item, "/dev/net/tun") for item in tailscale.get("devices", [])): + self.add(service, "error", "TAILSCALE_TUN_DEVICE", path, + "Tailscale must receive /dev/net/tun.", "Keep the template TUN device mapping.") + if not has_capability(tailscale.get("cap_add"), "net_admin"): + self.add(service, "error", "TAILSCALE_NET_ADMIN", path, + "Tailscale must have net_admin capability.", "Keep the template capability.") + if not tailscale.get("restart"): + self.add(service, "error", "TAILSCALE_RESTART", path, + "Tailscale must define a restart policy.", "Use restart: always or an explicitly documented equivalent.") + if "# Tailscale Sidecar Configuration" not in text: + self.add(service, self.deterministic_severity(), "COMPOSE_COMMENT_MISSING", path, + "The Tailscale sidecar comment is missing.", "Restore the template comment.") + + def validate_compose_config(self, service: str, compose: Path) -> None: + try: + result = subprocess.run( + ["docker", "compose", "-f", compose.name, "config", "--quiet"], + cwd=compose.parent, + text=True, + capture_output=True, + timeout=90, + check=False, + ) + except FileNotFoundError: + self.add(service, "warning", "COMPOSE_NOT_AVAILABLE", compose, + "Docker Compose is not installed; static checks completed without config validation.", + "Run docker compose config --quiet in CI or a Docker-enabled environment.") + return + except subprocess.TimeoutExpired: + self.add(service, "error", "COMPOSE_CONFIG_TIMEOUT", compose, + "docker compose config --quiet timed out.", "Fix external env_file references and rerun the command.") + return + if result.returncode: + detail = (result.stderr or result.stdout).strip().splitlines() + message = detail[-1] if detail else "docker compose config --quiet failed" + self.add(service, "error", "COMPOSE_CONFIG_INVALID", compose, + message, "Run docker compose config --quiet from the service directory and fix the reported issue.") + + +def changed_services(root: Path, reference: str) -> tuple[list[Path], set[str]]: + try: + result = subprocess.run( + ["git", "diff", "--name-status", reference, "HEAD"], + cwd=root, + text=True, + capture_output=True, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return [], set() + dirs: set[str] = set() + new: set[str] = set() + for raw in result.stdout.splitlines(): + parts = raw.split("\t") + if len(parts) < 2: + continue + status, changed = parts[0], parts[-1] + path = Path(changed) + if len(path.parts) < 3 or path.parts[0] != "services": + continue + service = path.parts[1] + dirs.add(service) + if status.startswith("A"): + new.add(service) + return [root / "services" / name for name in sorted(dirs)], new + + +def discover_all(root: Path) -> list[Path]: + services = root / "services" + return sorted(path for path in services.iterdir() if path.is_dir()) if services.exists() else [] + + +def emit(findings: Iterable[Finding], output_format: str) -> None: + items = list(findings) + if output_format == "json": + print(json.dumps([asdict(item) for item in items], indent=2)) + return + for item in items: + location = item.path if item.line is None else f"{item.path}:{item.line}" + message = f"[{item.code}] {item.message} Fix: {item.remediation}" + if output_format == "github": + command = "warning" if item.severity == "warning" else "error" + print(f"::{command} file={item.path},line={item.line or 1}::{message}") + else: + print(f"{location} [{item.severity.upper()}] {message}") + if not items: + print("ScaleTail service validation passed.") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="*", help="service directories to validate") + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--all", action="store_true", help="validate every service directory") + parser.add_argument("--baseline", action="store_true", help="audit mode: report findings without failing") + parser.add_argument("--changed-from", metavar="REF", help="validate only service directories changed since REF") + parser.add_argument("--new-service", action="append", default=[], help="mark a service path as newly added") + parser.add_argument("--format", choices=("text", "github", "json"), default="text") + args = parser.parse_args(argv) + root = args.root.resolve() + profiles = load_yaml(root / "tools" / "service-profiles.yml") or {} + if args.changed_from: + paths, new_services = changed_services(root, args.changed_from) + if not paths: + paths = [root / path for path in args.paths] + new_services.update(Path(path).name for path in args.new_service) + elif args.all or not args.paths: + paths = discover_all(root) + new_services = set(args.new_service) + else: + paths = [Path(path).resolve() for path in args.paths] + new_services = set(args.new_service) + validator = Validator(root, profiles, baseline=args.baseline) + for path in paths: + if path.is_dir() and path.parent.name == "services": + validator.validate(path, is_new=path.name in new_services) + emit(validator.findings, args.format) + if args.baseline: + return 0 + return 1 if any(item.severity == "error" for item in validator.findings) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 005562fa438670d9ea163a24149d8b3f8b1459a1 Mon Sep 17 00:00:00 2001 From: crypt0rr <57799908+crypt0rr@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:42:06 +0200 Subject: [PATCH 2/2] Harden service contract enforcement --- .github/ISSUE_TEMPLATE/bug.yml | 9 + .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/linting.yml | 6 +- .github/workflows/service-contract.yml | 35 ++- CONTRIBUTING.md | 12 +- README.md | 4 + documentation/service-contract-baseline.md | 4 +- templates/service-template/README.md | 8 +- tools/tests/test_validate_services.py | 116 ++++++++ tools/validate_services.py | 321 +++++++++++++++++---- 10 files changed, 447 insertions(+), 70 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index c7079bbb..acf3e1ca 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -115,6 +115,15 @@ body: validations: required: true + - type: input + id: compose-version + attributes: + label: Docker Compose Version + description: What Docker Compose version are you using? (Run `docker compose version`). + placeholder: 'v2.27.0' + validations: + required: true + - type: textarea id: logs attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 44de5ed2..c087ac8d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -18,7 +18,7 @@ - [ ] I have added verification that the stack works as expected. - [ ] I have updated necessary documentation (e.g. frontpage [README.md](https://github.com/tailscale-dev/ScaleTail/blob/main/README.md) ). - [ ] I have selected the correct label(s) for this PR. -- [ ] I preserved the template comments and ran `python tools/validate_services.py` plus `docker compose config --quiet` for each changed service. +- [ ] I preserved the template comments and ran `python tools/validate_services.py services/` plus `(cd services/ && docker compose config --quiet)` for each changed service. - [ ] For a new service, I checked official upstream documentation for the image, internal port, healthcheck, volumes, permissions, dependencies, and architecture support. ## Additional Context diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 4172a839..fa2685a5 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -27,10 +27,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone this repo - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false - name: Lint services - uses: rvben/rumdl@v0.2.41 + uses: rvben/rumdl@86694902f916bedaa5e37f024cdc7858929092e6 with: path: "services/" config: ".markdownlint.yml" diff --git a/.github/workflows/service-contract.yml b/.github/workflows/service-contract.yml index c9ec2211..2acd5ceb 100644 --- a/.github/workflows/service-contract.yml +++ b/.github/workflows/service-contract.yml @@ -34,23 +34,30 @@ jobs: service-contract: name: Service contract runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone this repo - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: fetch-depth: 0 + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: python-version: "3.12" - name: Install validator dependencies - run: python -m pip install --requirement tools/requirements.txt + run: >- + python -m pip install --disable-pip-version-check --no-input + --only-binary=:all: PyYAML==6.0.2 - name: Run validator tests run: python -m unittest discover -s tools/tests -v + - name: Validate repository contract + run: python tools/validate_services.py --check-repository --format github + - name: Validate changed services env: EVENT_NAME: ${{ github.event_name }} @@ -94,12 +101,30 @@ jobs: else continue fi - (cd "$service_dir" && docker compose -f "$compose_file" config --quiet) + if [ -L "$service_dir/$compose_file" ]; then + echo "Refusing symlinked Compose file: $service_dir/$compose_file" >&2 + exit 1 + fi + (cd "$service_dir" && timeout 90s docker compose -f "$compose_file" config --quiet) done <<< "$service_dirs" - name: Lint service documentation - uses: rvben/rumdl@v0.2.41 + uses: rvben/rumdl@86694902f916bedaa5e37f024cdc7858929092e6 with: path: "services/" config: ".markdownlint.yml" report-type: annotations + + - name: Lint template documentation + uses: rvben/rumdl@86694902f916bedaa5e37f024cdc7858929092e6 + with: + path: "templates/service-template/" + config: ".markdownlint.yml" + report-type: annotations + + - name: Lint root documentation + uses: rvben/rumdl@86694902f916bedaa5e37f024cdc7858929092e6 + with: + path: "README.md" + config: ".markdownlint.yml" + report-type: annotations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c4d585e..c6db2a8c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,20 +33,24 @@ Run the repository validator before opening a pull request: ```console python -m pip install -r tools/requirements.txt -python tools/validate_services.py services/ -docker compose config --quiet +python tools/validate_services.py services/ --new-service +(cd services/ && docker compose config --quiet) ``` The `service-contract` GitHub check runs these deterministic checks for changed services. It does not pull images or start third-party containers. Multi-container -and Tailscale-node layouts must be listed in `tools/service-profiles.yml` with -an ingress service and a reason for the exception. +layouts must be listed in `tools/service-profiles.yml` with an ingress service; +all non-default profiles need a maintainer-owned reason. Tailscale-node profiles +are restricted to the approved routing services. The validator cannot prove that an upstream image's internal port, healthcheck, volume path, UID/GID, or device requirements are correct. Verify those details against the service's official documentation and record the links and gotchas in the service README before requesting review. +The repository-wide baseline and remediation backlog are recorded in +[`documentation/service-contract-baseline.md`](documentation/service-contract-baseline.md). + ## Updating an existing service - Keep the sidecar pattern intact (`network_mode: service:tailscale`, health checks, `depends_on`). diff --git a/README.md b/README.md index e0fdb8b6..b701a54e 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ ScaleTail provides ready-to-run [Docker Compose](https://docs.docker.com/compose | 📚 **BookLore** | A self-hosted application for managing and reading books. | [Details](services/booklore) | | 🎥 **Frigate** | A self-hosted NVR with real-time AI object detection for IP cameras and local video monitoring. | [Details](services/frigate) | | 🎮 **Hytale** | A self-hosted Hytale game server. | [Details](services/hytale) | +| 🧱 **Minecraft** | A self-hosted Minecraft server for private multiplayer over your Tailnet. | [Details](services/minecraft) | | 🖼️ **Immich** | A self-hosted Google Photos alternative with face recognition and mobile sync. | [Details](services/immich) | | 📺 **Jellyfin** | An open-source media system that puts you in control of managing and streaming your media. | [Details](services/jellyfin) | | 📖 **Kavita** | An open-source, self-hosted digital library for comics, manga, and ebooks. | [Details](services/kavita) | @@ -123,6 +124,7 @@ ScaleTail provides ready-to-run [Docker Compose](https://docs.docker.com/compose | 💼 Service | 📝 Description | 🔗 Link | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| 🧩 **AFFiNE** | A collaborative workspace combining documents, whiteboards, and databases. | [Details](services/affine) | | 💰 **Actual Budget** | A self-hosted personal finance and budgeting app focused on privacy and full data ownership. | [Details](services/actual-budget) | | ⚓ **Anchor** | An offline-first, self-hosted note-taking app with sync, attachments, sharing, and optional OIDC authentication. | [Details](services/anchor) | | 📄 **BentoPDF** | A lightweight, self-hosted web app for viewing and managing PDF documents. | [Details](services/bentopdf) | @@ -150,6 +152,7 @@ ScaleTail provides ready-to-run [Docker Compose](https://docs.docker.com/compose | 📝 **Memos** | A lightweight, self-hosted note-taking and knowledge management platform for capturing ideas, daily notes, and personal knowledge. | [Details](services/memos) | | 📝 **Nanote** | A lightweight, self-hosted note-taking app with Markdown support. | [Details](services/nanote) | | 🤖 **Open WebUI** | A self-hosted AI platform with a ChatGPT-style interface for local and cloud-based models. | [Details](services/open-webui) | +| 🗂️ **NextExplorer** | A self-hosted file explorer with a polished interface and fine-grained access control. | [Details](services/next-explorer) | | 🔗 **Pingvin Share** | **PROJECT ARCHIVED** A self-hosted file sharing platform. | [Details](services/pingvin-share) | | 📅 **Radicale** | A lightweight CalDAV and CardDAV server for self-hosted calendar, to-do, and contact sync. | [Details](services/radicale) | | 🔄 **Resilio Sync** | A fast, reliable, and simple file sync and share solution. | [Details](services/resilio-sync) | @@ -215,6 +218,7 @@ ScaleTail provides ready-to-run [Docker Compose](https://docs.docker.com/compose | 🔁 **ConvertX** | A fast, full-featured self-hosted conversion API for images, docs, videos, and more. | [Details](services/convertx) | | 🔔 **Gotify** | A simple server for sending and receiving messages in real-time. | [Details](services/gotify) | | 🔐 **Hemmelig** | A self-hosted, zero-knowledge encrypted secret sharing platform with expiring secrets. | [Details](services/hemmelig) | +| 📂 **Filebrowser** | A lightweight web file manager for managing files on a mounted directory. | [Details](services/filebrowser) | | 📦 **Homebox** | A self-hosted home inventory and asset management system. | [Details](services/homebox) | | 🚗 **LubeLogger** | Self-hosted vehicle maintenance tracker with private access. | [Details](services/lube-logger) | | 📱 **Mini-QR** | A minimal, self-hosted QR code generator with a mobile-friendly UI. | [Details](services/miniqr) | diff --git a/documentation/service-contract-baseline.md b/documentation/service-contract-baseline.md index db28513d..0a3c342f 100644 --- a/documentation/service-contract-baseline.md +++ b/documentation/service-contract-baseline.md @@ -17,8 +17,8 @@ services; legacy findings remain visible in baseline mode until they are fixed. - `services/netbox`: `env_file: /.env` is an invalid absolute path from the service directory, and the ingress service lacks the Tailscale health dependency. - `services/affine`, `services/flaresolverr`, `services/mattermost`, `services/next-explorer`, and `services/seafile`: ingress dependency chains need runtime-aware review and repair. - `services/recyclarr`, `services/beszel-agent`, and `services/configarr`: the Tailscale sidecar does not persist its `/config` mount and needs an explicit decision or repair. -- `affine`, `filebrowser`, `minecraft`, and `next-explorer` are missing from the root README service index. -- Existing `.env` files contain secret-like defaults and mutable image tags; these are warnings in the baseline and must not be copied into new services. +- Root README coverage for `affine`, `filebrowser`, `minecraft`, and `next-explorer` has been repaired; keep the repository index check enabled to prevent regressions. +- Existing `.env` files contain secret-like defaults and mutable image tags; these are baseline warnings to review and avoid when adding or changing services. Run the audit with: diff --git a/templates/service-template/README.md b/templates/service-template/README.md index 84bd5db4..aa737adf 100644 --- a/templates/service-template/README.md +++ b/templates/service-template/README.md @@ -34,6 +34,8 @@ Please check the following contents for validity as some variables need to be de waits for the Tailscale healthcheck. - Verify the Serve proxy target against the app's actual internal listening port. Serve configuration does not consume `.env` port variables. -- Run `python tools/validate_services.py services/` and - `docker compose config --quiet` from this service directory before opening a - pull request. +- From the repository root, run + `python tools/validate_services.py services/ --new-service ` + for a new service. +- From the service directory, run `docker compose config --quiet` before + opening a pull request. diff --git a/tools/tests/test_validate_services.py b/tools/tests/test_validate_services.py index b548db35..0652aa74 100644 --- a/tools/tests/test_validate_services.py +++ b/tools/tests/test_validate_services.py @@ -48,6 +48,9 @@ - TS_USERSPACE=false - TS_ENABLE_HEALTH_CHECK=true - TS_LOCAL_ADDR_PORT=127.0.0.1:41234 + configs: + - source: ts-serve + target: /config/serve.json volumes: - ./config:/config # Config folder used to store Tailscale files - ./ts/state:/var/lib/tailscale # Tailscale requirement @@ -86,6 +89,26 @@ def test_duplicate_yaml_keys_are_rejected(self): with self.assertRaises(validate_services.DuplicateKeyError): validate_services.load_yaml(path) + def test_duplicate_explicit_key_after_merge_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "compose.yaml" + path.write_text( + "defaults: &defaults\n image: one\nservices:\n app:\n <<: *defaults\n image: two\n image: three\n", + encoding="utf-8", + ) + with self.assertRaises(validate_services.DuplicateKeyError): + validate_services.load_yaml(path) + + def test_duplicate_explicit_key_before_merge_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "compose.yaml" + path.write_text( + "defaults: &defaults\n image: one\nservices:\n app:\n image: two\n <<: *defaults\n image: three\n", + encoding="utf-8", + ) + with self.assertRaises(validate_services.DuplicateKeyError): + validate_services.load_yaml(path) + def test_malformed_yaml_is_rejected(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "compose.yaml" @@ -93,6 +116,60 @@ def test_malformed_yaml_is_rejected(self): with self.assertRaises(validate_services.yaml.YAMLError): validate_services.load_yaml(path) + def test_mount_and_device_targets_are_exact(self): + self.assertTrue(validate_services.contains_mount("./state:/var/lib/tailscale:ro", "/var/lib/tailscale")) + self.assertFalse(validate_services.contains_mount("./state:/var/lib/tailscale-backup", "/var/lib/tailscale")) + self.assertTrue(validate_services.contains_device("/dev/net/tun:/dev/net/tun", "/dev/net/tun")) + self.assertFalse(validate_services.contains_device("/dev/net/tun2:/dev/net/tun2", "/dev/net/tun")) + + def test_env_comments_are_not_values_and_auth_keys_are_secrets(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / ".env" + path.write_text("IMAGE_URL= # image\nTS_AUTHKEY=tskey-auth-real\n", encoding="utf-8") + values, _ = validate_services.parse_env(path) + self.assertEqual(values["IMAGE_URL"], "") + validator = validate_services.Validator(validate_services.ROOT, {"services": {}}) + validator.validate_env("demo", path, is_new=True, profile="sidecar-web") + self.assertIn("ENV_SECRET_LIKE_VALUE", {finding.code for finding in validator.findings}) + + def test_env_aliases_do_not_satisfy_required_keys(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / ".env" + path.write_text(TEMPLATE_ENV.replace("IMAGE_URL=", "IMAGE_URL_ALIAS=").replace("SERVICEPORT=", "SERVICEPORT_ALIAS="), encoding="utf-8") + validator = validate_services.Validator(validate_services.ROOT, {"services": {}}) + validator.validate_env("demo", path, is_new=True, profile="sidecar-web") + missing = [finding.message for finding in validator.findings if finding.code == "ENV_KEY_MISSING"] + self.assertTrue(any("IMAGE_URL" in message for message in missing)) + self.assertTrue(any("SERVICEPORT" in message for message in missing)) + + def test_repository_check_rejects_unknown_profile_definition(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "services" / "demo").mkdir(parents=True) + (root / "README.md").write_text("| Demo | A service | [Details](services/demo) |\n", encoding="utf-8") + validator = validate_services.Validator( + root, + {"profiles": {"sidecar-web": {}, "multi-container": {}, "tailscale-node": {}, "unsafe": {}}, + "services": {}}, + ) + validator.validate_repository() + self.assertIn("PROFILE_DEFINITION_UNKNOWN", {finding.code for finding in validator.findings}) + + def test_tailscale_image_must_be_official_repository(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "compose.yaml" + path.write_text("# Tailscale Sidecar Configuration\n", encoding="utf-8") + validator = validate_services.Validator(validate_services.ROOT, {"services": {}}) + validator.validate_tailscale( + "demo", + path, + {"image": "registry.example/tailscale/tailscale:1.0"}, + {}, + "sidecar-web", + False, + ) + self.assertIn("TAILSCALE_IMAGE", {finding.code for finding in validator.findings}) + def test_new_service_requires_tailscale_health_dependency(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -132,6 +209,20 @@ def test_tailscale_node_does_not_require_serve_config(self): self.assertNotIn("TAILSCALE_CONFIG_VOLUME", codes) self.assertNotIn("INGRESS_SERVICE_MISSING", codes) + def test_tailscale_node_requires_role_argument(self): + validator = validate_services.Validator(validate_services.ROOT, {"services": {}}) + with tempfile.TemporaryDirectory() as directory: + validator.validate_tailscale_node( + "tailscale-exit-node", + Path(directory) / "compose.yaml", + {"environment": ["TS_EXTRA_ARGS="], "network_mode": "bridge", "sysctls": { + "net.ipv4.ip_forward": 1, + "net.ipv6.conf.all.forwarding": 1, + }}, + "tailscale-node", + ) + self.assertIn("NODE_ROLE_ARGUMENT_MISSING", {finding.code for finding in validator.findings}) + def test_extra_application_requires_explicit_profile(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -217,6 +308,31 @@ def test_invalid_serve_proxy_is_reported(self): validator.validate(service, is_new=True) self.assertIn("SERVE_PROXY_INTERPOLATION", {finding.code for finding in validator.findings}) + def test_serve_config_requires_a_matching_compose_mount(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + service = root / "services" / "demo" + (root / "tools").mkdir(parents=True) + service.mkdir(parents=True) + (root / "README.md").write_text("| Demo | A service | (services/demo) |\n", encoding="utf-8") + (service / ".env").write_text(TEMPLATE_ENV, encoding="utf-8") + (service / "README.md").write_text(TEMPLATE_README, encoding="utf-8") + compose = BASE_COMPOSE.replace( + " configs:\n - source: ts-serve\n target: /config/serve.json\n", "" + ) + (service / "compose.yaml").write_text(compose, encoding="utf-8") + validator = validate_services.Validator( + root, + {"profiles": {"sidecar-web": {}}, "services": {}}, + ) + with patch.object( + validate_services.subprocess, + "run", + return_value=CompletedProcess([], 0, "", ""), + ): + validator.validate(service, is_new=True) + self.assertIn("SERVE_CONFIG_MOUNT_MISSING", {finding.code for finding in validator.findings}) + if __name__ == "__main__": unittest.main() diff --git a/tools/validate_services.py b/tools/validate_services.py index f614a937..3984a680 100644 --- a/tools/validate_services.py +++ b/tools/validate_services.py @@ -48,7 +48,7 @@ class UniqueKeyLoader(yaml.SafeLoader): def _construct_unique_mapping(loader: UniqueKeyLoader, node: yaml.MappingNode, deep: bool = False): mapping: dict[Any, Any] = {} - merged_keys: set[Any] = set() + explicit_keys: set[Any] = set() for key_node, value_node in node.value: # YAML merge keys intentionally provide defaults that an explicit key # in the current mapping may override (for example netbox-worker). @@ -60,11 +60,11 @@ def _construct_unique_mapping(loader: UniqueKeyLoader, node: yaml.MappingNode, d for merged_key, merged_value in merged_items: if merged_key not in mapping: mapping[merged_key] = merged_value - merged_keys.add(merged_key) continue key = loader.construct_object(key_node, deep=deep) - if key in mapping and key not in merged_keys: + if key in explicit_keys: raise DuplicateKeyError(key, key_node.start_mark.line + 1) + explicit_keys.add(key) mapping[key] = loader.construct_object(value_node, deep=deep) return mapping @@ -101,13 +101,30 @@ def _construct_unique_mapping(loader: UniqueKeyLoader, node: yaml.MappingNode, d "Explain what the app does", "SERVICE with Tailscale Sidecar", ) -SECRET_KEY_RE = re.compile(r"(PASSWORD|SECRET|TOKEN|PRIVATE_KEY|API_KEY|ENCRYPTION_KEY)", re.I) +SECRET_KEY_RE = re.compile( + r"(PASSWORD|SECRET|TOKEN|PRIVATE_KEY|API_KEY|ENCRYPTION_KEY|AUTHKEY|AUTH_KEY|TS_KEY)", + re.I, +) ENV_KEY_RE = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$") +MAX_FILE_BYTES = 2_000_000 +KNOWN_PROFILES = frozenset({"sidecar-web", "multi-container", "tailscale-node"}) +APPROVED_TAILSCALE_NODE_SERVICES = frozenset({ + "tailscale-app-connector-node", + "tailscale-exit-node", + "tailscale-subnet-router-node", +}) + + +def read_text_bounded(path: Path) -> str: + if path.is_symlink() or not path.is_file(): + raise OSError(f"{path} must be a regular file, not a symlink or directory") + if path.stat().st_size > MAX_FILE_BYTES: + raise OSError(f"{path} exceeds the {MAX_FILE_BYTES}-byte validation limit") + return path.read_text(encoding="utf-8") def load_yaml(path: Path) -> Any: - with path.open(encoding="utf-8") as handle: - return yaml.load(handle, Loader=UniqueKeyLoader) + return yaml.load(read_text_bounded(path), Loader=UniqueKeyLoader) def rel(path: Path, root: Path = ROOT) -> str: @@ -126,17 +143,19 @@ def line_for(text: str, needle: str) -> int | None: def clean_env_value(value: str) -> str: value = value.strip() - if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"": - value = value[1:-1] if " #" in value: value = value.split(" #", 1)[0].rstrip() + if value.startswith("#"): + return "" + if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"": + value = value[1:-1] return value def parse_env(path: Path) -> tuple[dict[str, str], dict[str, int]]: values: dict[str, str] = {} lines: dict[str, int] = {} - for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + for number, raw in enumerate(read_text_bounded(path).splitlines(), 1): match = ENV_KEY_RE.match(raw.strip()) if not match: continue @@ -179,7 +198,8 @@ def env_items(environment: Any) -> dict[str, str]: def contains_mount(value: Any, target: str) -> bool: if isinstance(value, str): - return target in value.split(":", 2)[-1] + parts = value.split(":") + return len(parts) >= 2 and parts[1] == target if isinstance(value, dict): return value.get("target") == target return False @@ -187,7 +207,8 @@ def contains_mount(value: Any, target: str) -> bool: def contains_device(value: Any, target: str) -> bool: if isinstance(value, str): - return target in value + parts = value.split(":") + return value == target or (len(parts) >= 2 and parts[1] == target) if isinstance(value, dict): return value.get("target") == target or value.get("path_in_container") == target return False @@ -205,7 +226,10 @@ def __init__(self, root: Path, profiles: dict[str, Any], baseline: bool = False) self.profiles = profiles self.baseline = baseline self.findings: list[Finding] = [] - self.root_readme = (root / "README.md").read_text(encoding="utf-8") + try: + self.root_readme = read_text_bounded(root / "README.md") + except OSError: + self.root_readme = "" def add( self, @@ -244,18 +268,129 @@ def check_comment_order( "Restore the template section order and keep service-specific comments beside the relevant setting.", ) + def validate_repository(self) -> None: + profile_file = self.root / "tools" / "service-profiles.yml" + profile_defs = self.profiles.get("profiles") if isinstance(self.profiles, dict) else None + services = self.profiles.get("services") if isinstance(self.profiles, dict) else None + if not isinstance(profile_defs, dict): + self.add("repository", self.deterministic_severity(), "PROFILE_DEFINITIONS_INVALID", profile_file, + "Profile definitions must be a mapping.", + "Define sidecar-web, multi-container, and tailscale-node as profile mappings.") + profile_defs = {} + for profile in KNOWN_PROFILES: + if profile not in profile_defs: + self.add("repository", self.deterministic_severity(), "PROFILE_DEFINITION_MISSING", profile_file, + f"Required profile {profile!r} is missing.", + "Restore the maintainer-owned profile definition.") + for profile in profile_defs: + if profile not in KNOWN_PROFILES: + self.add("repository", self.deterministic_severity(), "PROFILE_DEFINITION_UNKNOWN", profile_file, + f"Profile definition {profile!r} is not approved.", + "Remove the profile or add its behavior to the validator and maintainer documentation.") + if not isinstance(services, dict): + self.add("repository", self.deterministic_severity(), "PROFILE_SERVICES_INVALID", profile_file, + "Profile services must be a mapping.", + "Define service profile entries under services:.") + services = {} + service_dirs = {path.name for path in discover_all(self.root)} + for service, entry in services.items(): + if not isinstance(service, str) or not isinstance(entry, dict): + self.add("repository", self.deterministic_severity(), "PROFILE_ENTRY_INVALID", profile_file, + f"Profile entry {service!r} must be a mapping.", + "Use services..profile, reason, and ingress fields.") + continue + if service not in service_dirs: + self.add(service, self.deterministic_severity(), "PROFILE_SERVICE_UNKNOWN", profile_file, + f"Profile entry refers to unknown service {service!r}.", + "Remove the stale entry or add the corresponding service directory.") + profile = entry.get("profile", "sidecar-web") + if not isinstance(profile, str) or profile not in KNOWN_PROFILES: + self.add(service, self.deterministic_severity(), "PROFILE_UNKNOWN", profile_file, + f"Profile {profile!r} is not one of the approved profiles.", + "Use sidecar-web, multi-container, or tailscale-node.") + continue + if profile != "sidecar-web" and ( + not isinstance(entry.get("reason"), str) or not entry["reason"].strip() + ): + self.add(service, self.deterministic_severity(), "PROFILE_REASON_MISSING", profile_file, + f"Profile {profile!r} does not document its topology exception.", + "Add a concise maintainer-owned reason.") + if profile == "multi-container" and ( + not isinstance(entry.get("ingress"), str) or not entry["ingress"].strip() + ): + self.add(service, self.deterministic_severity(), "PROFILE_INGRESS_MISSING", profile_file, + "Multi-container profiles must identify a string ingress service.", + "Set services..ingress to the routed application service.") + if profile == "tailscale-node" and service not in APPROVED_TAILSCALE_NODE_SERVICES: + self.add(service, "error", "PROFILE_NODE_NOT_APPROVED", profile_file, + "Only the approved Tailscale routing services may use tailscale-node.", + "Add maintainer-reviewed node behavior to the validator before using this profile.") + if profile == "multi-container" and service in service_dirs: + compose = self._compose_path(service) + try: + compose_data = load_yaml(compose) + compose_services = compose_data.get("services", {}) if isinstance(compose_data, dict) else {} + ingress = entry.get("ingress") + if isinstance(compose_services, dict) and isinstance(ingress, str) and ingress not in compose_services: + self.add(service, self.deterministic_severity(), "PROFILE_INGRESS_UNKNOWN", profile_file, + f"Ingress service {ingress!r} is not defined in the Compose file.", + "Set ingress to an existing application service.") + except (OSError, yaml.YAMLError): + pass + if not self.root_readme: + self.add("repository", self.deterministic_severity(), "README_ROOT_MISSING", self.root / "README.md", + "The root README is missing or is not a readable regular file.", + "Restore README.md as a regular UTF-8 file.") + else: + for service_dir in discover_all(self.root): + self.validate_root_index(service_dir.name) + + def _compose_path(self, service: str) -> Path: + directory = self.root / "services" / service + compose = directory / "compose.yaml" + return compose if compose.exists() else directory / "compose.yml" + + def validate_root_index(self, service: str) -> None: + root_link = f"(services/{service})" + root_lines = [ + (number, line) for number, line in enumerate(self.root_readme.splitlines(), 1) + if root_link in line + ] + if not root_lines: + self.add(service, self.deterministic_severity(), "README_INDEX_MISSING", + self.root / "README.md", "The service is not linked from the root README.", + "Add a categorized service row to README.md.") + elif not any( + "|" in line + and len([part.strip() for part in line.split("|") if root_link not in part and part.strip()]) >= 2 + for _, line in root_lines + ): + self.add(service, self.deterministic_severity(), "README_INDEX_DESCRIPTION_MISSING", + self.root / "README.md", "The root README link does not include a service description.", + "Add the service to a categorized table with a concise description.", root_lines[0][0]) + def validate(self, service_dir: Path, is_new: bool = False) -> None: service = service_dir.name + if service_dir.is_symlink() or not service_dir.is_dir() or service_dir.parent.name != "services": + self.add(service, "error", "SERVICE_DIRECTORY_UNSAFE", service_dir, + "Service directories must be real directories directly under services/.", + "Replace symlinked or relocated service directories with a regular checkout directory.") + return readme = service_dir / "README.md" env_file = service_dir / ".env" compose = service_dir / "compose.yaml" legacy_compose = service_dir / "compose.yml" - raw_profile_entry = (self.profiles.get("services") or {}).get(service, {}) + service_profiles = self.profiles.get("services") if isinstance(self.profiles, dict) else {} + if not isinstance(service_profiles, dict): + service_profiles = {} + raw_profile_entry = service_profiles.get(service, {}) profile_entry = raw_profile_entry if isinstance(raw_profile_entry, dict) else {} profile = profile_entry.get("profile", "sidecar-web") - profile_defs = self.profiles.get("profiles") or {} + profile_defs = self.profiles.get("profiles") if isinstance(self.profiles, dict) else {} + if not isinstance(profile_defs, dict): + profile_defs = {} - if profile not in profile_defs: + if not isinstance(profile, str) or profile not in KNOWN_PROFILES or profile not in profile_defs: self.add(service, "error", "PROFILE_UNKNOWN", PROFILE_FILE, f"Profile {profile!r} is not defined.", "Use sidecar-web or add a reviewed profile definition.") @@ -264,10 +399,16 @@ def validate(self, service_dir: Path, is_new: bool = False) -> None: self.add(service, self.deterministic_severity(), "PROFILE_REASON_MISSING", PROFILE_FILE, f"Profile {profile!r} does not document why this service differs from the default topology.", "Add a concise maintainer-owned reason to tools/service-profiles.yml.") - if profile == "multi-container" and not profile_entry.get("ingress"): + if profile == "multi-container" and ( + not isinstance(profile_entry.get("ingress"), str) or not profile_entry["ingress"].strip() + ): self.add(service, self.deterministic_severity(), "PROFILE_INGRESS_MISSING", PROFILE_FILE, "The multi-container profile must identify its ingress service.", "Set services..ingress to the application routed through Tailscale.") + if profile == "tailscale-node" and service not in APPROVED_TAILSCALE_NODE_SERVICES: + self.add(service, "error", "PROFILE_NODE_NOT_APPROVED", PROFILE_FILE, + "Only the approved Tailscale routing services may use tailscale-node.", + "Add maintainer-reviewed node behavior to the validator before using this profile.") if not readme.exists(): self.add(service, "error", "FILE_README_MISSING", readme, @@ -275,6 +416,11 @@ def validate(self, service_dir: Path, is_new: bool = False) -> None: if not env_file.exists(): self.add(service, "error", "FILE_ENV_MISSING", env_file, ".env is required.", "Copy the template .env and add non-secret service variables.") + for candidate in (readme, env_file, compose, legacy_compose): + if candidate.exists() and (candidate.is_symlink() or not candidate.is_file()): + self.add(service, "error", "FILE_UNSAFE", candidate, + "Service contract files must be regular files, not symlinks or directories.", + "Replace the path with a regular file inside the checkout.") if not compose.exists(): if legacy_compose.exists(): severity = "warning" if not is_new else "error" @@ -291,30 +437,17 @@ def validate(self, service_dir: Path, is_new: bool = False) -> None: "A service must not ship both compose.yaml and compose.yml.", "Keep compose.yaml as the sole Compose entrypoint.") - if env_file.exists(): + if env_file.exists() and env_file.is_file() and not env_file.is_symlink(): self.validate_env(service, env_file, is_new, profile) - readme_text = readme.read_text(encoding="utf-8") if readme.exists() else "" - if readme.exists(): + try: + readme_text = read_text_bounded(readme) if readme.exists() else "" + except OSError as exc: + readme_text = "" + self.add(service, "error", "README_READ_FAILED", readme, str(exc), + "Make README.md a readable regular UTF-8 file within the size limit.") + if readme.exists() and readme.is_file() and not readme.is_symlink(): self.validate_readme(service, readme, readme_text, profile, is_new) - root_link = f"(services/{service})" - root_lines = [ - (number, line) for number, line in enumerate(self.root_readme.splitlines(), 1) - if root_link in line - ] - if not root_lines: - self.add(service, self.deterministic_severity(), "README_INDEX_MISSING", - self.root / "README.md", "The service is not linked from the root README.", - "Add a categorized service row to README.md.") - elif not any( - "|" in line - and len( - [part.strip() for part in line.split("|") if root_link not in part and part.strip()] - ) >= 2 - for _, line in root_lines - ): - self.add(service, self.deterministic_severity(), "README_INDEX_DESCRIPTION_MISSING", - self.root / "README.md", "The root README link does not include a service description.", - "Add the service to a categorized table with a concise description.", root_lines[0][0]) + self.validate_root_index(service) try: data = load_yaml(compose) @@ -337,8 +470,13 @@ def validate(self, service_dir: Path, is_new: bool = False) -> None: self.validate_compose_config(service, compose) def validate_env(self, service: str, path: Path, is_new: bool, profile: str) -> None: - text = path.read_text(encoding="utf-8") - values, lines = parse_env(path) + try: + text = read_text_bounded(path) + values, lines = parse_env(path) + except OSError as exc: + self.add(service, "error", "ENV_READ_FAILED", path, str(exc), + "Make .env a readable regular UTF-8 file within the size limit.") + return for anchor in ENV_COMMENT_ANCHORS: alternatives = (anchor, "#Time Zone setting for containers") if anchor.startswith("# Time Zone") else (anchor,) if not any(candidate in text for candidate in alternatives): @@ -347,16 +485,19 @@ def validate_env(self, service: str, path: Path, is_new: bool, profile: str) -> "Restore the template comment section without removing service-specific comments.") self.check_comment_order(service, path, text, ENV_COMMENT_ANCHORS, "ENV_COMMENT_ORDER") required_keys = ("SERVICE", "DNS_SERVER", "TS_AUTHKEY", "TZ") - if profile != "tailscale-node" and not any(key.startswith("IMAGE_URL") for key in values): + if profile == "sidecar-web" and "IMAGE_URL" not in values: required_keys += ("IMAGE_URL",) - if profile not in {"tailscale-node", "multi-container"} and not any(key.startswith("SERVICEPORT") for key in values): + if profile == "sidecar-web" and "SERVICEPORT" not in values: required_keys += ("SERVICEPORT",) for key in required_keys: if key not in values: self.add(service, self.deterministic_severity(), "ENV_KEY_MISSING", path, f"Required variable {key} is missing.", "Define the variable using the template .env structure.") - for key in ("SERVICE", "IMAGE_URL", "TZ"): + empty_keys = ("SERVICE", "TZ") + if profile == "sidecar-web": + empty_keys += ("IMAGE_URL",) + for key in empty_keys: if key in values and not values[key].strip(): self.add(service, "error", "ENV_VALUE_EMPTY", path, f"{key} must have a value.", "Set a safe non-secret value in .env.", lines.get(key)) @@ -439,9 +580,15 @@ def validate_compose( return self.validate_tailscale(service, path, tailscale, data, profile, is_new) if profile == "tailscale-node": + self.validate_tailscale_node(service, path, tailscale, profile) return ingress_name = profile_entry.get("ingress") + if ingress_name is not None and not isinstance(ingress_name, str): + self.add(service, self.deterministic_severity(), "PROFILE_INGRESS_INVALID", path, + "The configured ingress service name must be a string.", + "Set ingress to the name of an application service.") + ingress_name = None if not ingress_name: if "application" in services: ingress_name = "application" @@ -467,7 +614,7 @@ def validate_compose( if ingress.get("network_mode") != "service:tailscale": self.add(service, "error", "SIDECAR_NETWORK_MODE", path, f"Ingress service {ingress_name!r} must use network_mode: service:tailscale.", - "Route the ingress service through the Tailscale sidecar.", line_for(path.read_text(encoding="utf-8"), "network_mode:")) + "Route the ingress service through the Tailscale sidecar.", line_for(read_text_bounded(path), "network_mode:")) depends = ingress.get("depends_on") tail_dep = depends.get("tailscale") if isinstance(depends, dict) else None if not isinstance(tail_dep, dict) or tail_dep.get("condition") != "service_healthy": @@ -499,7 +646,7 @@ def validate_compose( f"Published host ports on {', '.join(published_ports)} are not explained in the README.", "Explain why host/LAN exposure is required and which ports are published.") - compose_text = path.read_text(encoding="utf-8") + compose_text = read_text_bounded(path) tail_environment = env_items(tailscale.get("environment")) for anchor in COMPOSE_COMMENT_ANCHORS: if anchor.startswith("# Tailscale Serve") and "TS_SERVE_CONFIG" not in tail_environment: @@ -512,6 +659,17 @@ def validate_compose( if "TS_SERVE_CONFIG" in tail_environment: config = data.get("configs", {}).get("ts-serve") if isinstance(data.get("configs"), dict) else None content = config.get("content", "") if isinstance(config, dict) else "" + config_mount = any( + (isinstance(item, dict) and item.get("source") == "ts-serve" and item.get("target") == "/config/serve.json") + or (isinstance(item, str) and len(item.split(":")) >= 2 + and item.split(":")[0] == "ts-serve" + and item.split(":")[1] == "/config/serve.json") + for item in tailscale.get("configs", []) + ) + if not config_mount: + self.add(service, self.deterministic_severity(), "SERVE_CONFIG_MOUNT_MISSING", path, + "TS_SERVE_CONFIG is enabled but ts-serve is not mounted at /config/serve.json.", + "Mount the ts-serve config source at the path named by TS_SERVE_CONFIG.") if not content or "Proxy" not in content: self.add(service, self.deterministic_severity(), "SERVE_PROXY_MISSING", path, "TS_SERVE_CONFIG is enabled but the ts-serve config has no Proxy handler.", @@ -527,11 +685,40 @@ def validate_compose( f"Serve proxy target {proxy!r} still uses the template's port 80.", "Verify the service's actual internal listening port against upstream documentation.") + def validate_tailscale_node(self, service: str, path: Path, tailscale: dict[str, Any], profile: str) -> None: + environment = env_items(tailscale.get("environment")) + extra_args = environment.get("TS_EXTRA_ARGS", "") + expected_args = { + "tailscale-exit-node": "--advertise-exit-node", + "tailscale-app-connector-node": "--advertise-connector", + } + expected_arg = expected_args.get(service) + if expected_arg and expected_arg not in extra_args: + self.add(service, self.deterministic_severity(), "NODE_ROLE_ARGUMENT_MISSING", path, + f"{service} must advertise its role with {expected_arg}.", + "Restore the role-specific TS_EXTRA_ARGS value.") + if service == "tailscale-subnet-router-node" and not environment.get("TS_ROUTES", "").strip(): + self.add(service, self.deterministic_severity(), "NODE_ROUTES_MISSING", path, + "The subnet-router profile must define TS_ROUTES.", + "Set the approved subnet route list in .env and pass it to Tailscale.") + if tailscale.get("network_mode") != "bridge": + self.add(service, self.deterministic_severity(), "NODE_NETWORK_MODE", path, + "Tailscale routing nodes must use bridge network mode.", + "Use network_mode: bridge so forwarding and advertised routes work.") + sysctls = tailscale.get("sysctls") + if not isinstance(sysctls, dict) or sysctls.get("net.ipv4.ip_forward") not in {1, "1"} or sysctls.get("net.ipv6.conf.all.forwarding") not in {1, "1"}: + self.add(service, self.deterministic_severity(), "NODE_FORWARDING_SYSCTLS", path, + "Tailscale routing nodes must enable IPv4 and IPv6 forwarding.", + "Set both forwarding sysctls in the node Compose service.") + def _env_service_port(self, service: str) -> str | None: env = self.root / "services" / service / ".env" if not env.exists(): return None - values, _ = parse_env(env) + try: + values, _ = parse_env(env) + except OSError: + return None return values.get("SERVICEPORT") def validate_tailscale( @@ -543,9 +730,11 @@ def validate_tailscale( profile: str, is_new: bool, ) -> None: - text = path.read_text(encoding="utf-8") + text = read_text_bounded(path) image = str(tailscale.get("image", "")) - if not image.startswith("tailscale/tailscale"): + image_without_digest = image.split("@", 1)[0] + image_repo = image_without_digest.rsplit(":", 1)[0] + if image_repo not in {"tailscale/tailscale", "docker.io/tailscale/tailscale"}: self.add(service, "error", "TAILSCALE_IMAGE", path, "tailscale.image must use the tailscale/tailscale image.", "Use tailscale/tailscale with a reviewed tag.") @@ -611,6 +800,14 @@ def validate_compose_config(self, service: str, compose: Path) -> None: self.add(service, "error", "COMPOSE_CONFIG_TIMEOUT", compose, "docker compose config --quiet timed out.", "Fix external env_file references and rerun the command.") return + interpolation_warnings = [ + line.strip() for line in (result.stderr or "").splitlines() + if "not set" in line.lower() or "undefined" in line.lower() + ] + if interpolation_warnings and result.returncode == 0: + self.add(service, self.deterministic_severity(), "COMPOSE_INTERPOLATION_WARNING", compose, + interpolation_warnings[0], + "Define the referenced variable or document it as an explicitly optional value.") if result.returncode: detail = (result.stderr or result.stdout).strip().splitlines() message = detail[-1] if detail else "docker compose config --quiet failed" @@ -641,7 +838,7 @@ def changed_services(root: Path, reference: str) -> tuple[list[Path], set[str]]: continue service = path.parts[1] dirs.add(service) - if status.startswith("A"): + if status.startswith(("A", "R")): new.add(service) return [root / "services" / name for name in sorted(dirs)], new @@ -674,24 +871,42 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--root", type=Path, default=ROOT) parser.add_argument("--all", action="store_true", help="validate every service directory") parser.add_argument("--baseline", action="store_true", help="audit mode: report findings without failing") + parser.add_argument("--check-repository", action="store_true", help="validate profile metadata and root README coverage") parser.add_argument("--changed-from", metavar="REF", help="validate only service directories changed since REF") parser.add_argument("--new-service", action="append", default=[], help="mark a service path as newly added") parser.add_argument("--format", choices=("text", "github", "json"), default="text") args = parser.parse_args(argv) root = args.root.resolve() - profiles = load_yaml(root / "tools" / "service-profiles.yml") or {} + profile_error: str | None = None + try: + loaded_profiles = load_yaml(root / "tools" / "service-profiles.yml") + profiles = loaded_profiles if isinstance(loaded_profiles, dict) else {} + if not isinstance(loaded_profiles, dict): + profile_error = "service-profiles.yml must contain a top-level mapping" + except (OSError, yaml.YAMLError) as exc: + profiles = {} + profile_error = str(exc) + validator = Validator(root, profiles, baseline=args.baseline) + if profile_error: + validator.add("repository", validator.deterministic_severity(), "PROFILE_FILE_INVALID", + root / "tools" / "service-profiles.yml", profile_error, + "Restore a readable YAML profile mapping.") + if args.check_repository: + validator.validate_repository() if args.changed_from: paths, new_services = changed_services(root, args.changed_from) if not paths: paths = [root / path for path in args.paths] new_services.update(Path(path).name for path in args.new_service) + elif args.check_repository and not args.paths: + paths = [] + new_services = set(args.new_service) elif args.all or not args.paths: paths = discover_all(root) - new_services = set(args.new_service) + new_services = {Path(path).name for path in args.new_service} else: paths = [Path(path).resolve() for path in args.paths] - new_services = set(args.new_service) - validator = Validator(root, profiles, baseline=args.baseline) + new_services = {Path(path).name for path in args.new_service} for path in paths: if path.is_dir() and path.parent.name == "services": validator.validate(path, is_new=path.name in new_services)