diff --git a/AGENTS.md b/AGENTS.md index 4d41f45..e822b8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -348,16 +348,14 @@ Each app in a target's `apps` mapping lists its own `env_refs` — release refs ## Notable App Configurations -- **traefik**: Entry point, uses external network. Note: currently carries the Watchtower label (see below) - a target that doesn't also run a `watchtower` container (hawkeye doesn't, as of this writing) would never get `docker compose up` run for it by the automated path. Known gap, not yet resolved. -- **watchtower**: Infrastructure app — recommended on every server. Handles automatic image updates for apps that opt in via the `com.centurylinklabs.watchtower.enable=true` label. `deploy/deploy.py` skips running `docker compose pull`/`up` for any app with this label entirely, so Watchtower is their sole lifecycle manager. +- **traefik**: Entry point, uses external network. - Apps with databases include a versioned template (e.g. `postgres-18.yml`) and create app-specific database named `${APP_NAME}` - Config templates use `envsubst`-equivalent substitution (`deploy/render.py`) - variables must be shell-compatible (`${VAR}` syntax) -## Watchtower-managed Apps - -Apps that carry the `com.centurylinklabs.watchtower.enable=true` label are updated automatically by Watchtower and are **skipped by `deploy/deploy.py`** - their `.env`/config still gets pushed on every deploy, but `docker compose pull`/`up` is never run for them. The label in the compose file is the single source of truth — no separate skip list exists. - -Currently opted in: `traefik`, `semaphore`, `watchtower` itself. +Watchtower was dropped entirely (there is no `apps/watchtower/`, and no app +carries its label) — `deploy/deploy.py` already runs `docker compose pull && +up -d` for every app in a target's `apps` mapping on every deploy, which +made Watchtower's own polling redundant. See `RETIRED.md`. ## Important: Template Files vs Generated Files diff --git a/README.md b/README.md index 633e727..2b47ee6 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ chmod 600 apps-data/traefik/acme.json 1. **Change Default Credentials** - Update vault-sourced secrets and re-deploy 2. **Use Strong Passwords** - Generate with: `openssl rand -base64 32` -3. **Keep Images Updated** - Watchtower-managed apps update automatically; others get the latest image on every deploy (`docker compose pull` runs before `up`) +3. **Keep Images Updated** - every deploy runs `docker compose pull` before `up`, so all apps get their latest image on every deploy 4. **Restrict Network Access** - Use firewall rules to limit access to Traefik ports (80, 443) 5. **Enable HTTPS** - Always use HTTPS, never expose HTTP to internet 6. **Backup Data** - Regularly back up `apps-data/` (backup automation is a separate, not-yet-decided piece of tooling) @@ -217,6 +217,7 @@ chmod 600 apps-data/traefik/acme.json ## 📚 Additional Resources - [AGENTS.md](AGENTS.md) - Technical documentation for AI agents and developers +- [RETIRED.md](RETIRED.md) - Apps removed from the active stack, and why - [Docker Documentation](https://docs.docker.com/) - [Docker Compose Documentation](https://docs.docker.com/compose/) - [Traefik Documentation](https://doc.traefik.io/) diff --git a/RETIRED.md b/RETIRED.md new file mode 100644 index 0000000..3486dde --- /dev/null +++ b/RETIRED.md @@ -0,0 +1,10 @@ +# Retired Apps + +Apps removed from the active stack. Technical details are recoverable from git history. Each entry captures only the human context: why the app was dropped. + +--- + +## watchtower + +- **Retired:** 2026-08-21 +- **Reason:** Redundant once the deploy pipeline itself became idempotent — every deploy already runs `docker compose pull && up -d` for every app, which re-pulls and reconciles on its own. A separate container polling for image updates added nothing except another thing needing Docker socket access on every host. diff --git a/apps/semaphore/docker-compose.yml b/apps/semaphore/docker-compose.yml index 5c6ab93..32b6416 100644 --- a/apps/semaphore/docker-compose.yml +++ b/apps/semaphore/docker-compose.yml @@ -26,7 +26,6 @@ services: - 3000 labels: - "traefik.http.services.${APP_NAME}.loadbalancer.server.port=3000" - - "com.centurylinklabs.watchtower.enable=true" environment: *environment depends_on: - postgres diff --git a/apps/traefik/docker-compose.yml b/apps/traefik/docker-compose.yml index f6ec909..0aed756 100644 --- a/apps/traefik/docker-compose.yml +++ b/apps/traefik/docker-compose.yml @@ -4,8 +4,6 @@ services: traefik: image: traefik environment: *environment - labels: - - "com.centurylinklabs.watchtower.enable=true" ports: - "${HTTP_PORT}:80" - "${HTTPS_PORT}:443" diff --git a/apps/watchtower/docker-compose.yml b/apps/watchtower/docker-compose.yml deleted file mode 100644 index 3ec2f1f..0000000 --- a/apps/watchtower/docker-compose.yml +++ /dev/null @@ -1,37 +0,0 @@ -x-environment: &environment - WATCHTOWER_LABEL_ENABLE: true - WATCHTOWER_NOTIFICATION_REPORT: true - WATCHTOWER_NOTIFICATIONS_LEVEL: warn - WATCHTOWER_SCHEDULE: "30 30 23 * * *" - TZ: ${APPS_TIMEZONE} - WATCHTOWER_CLEANUP: true - WATCHTOWER_NO_STARTUP_MESSAGE: true - WATCHTOWER_ROLLING_RESTART: false - WATCHTOWER_NOTIFICATIONS_HOSTNAME: ${APPS_HOST_NAME} - WATCHTOWER_NOTIFICATION_TITLE_TAG: "🗼" - WATCHTOWER_NOTIFICATION_URL: "telegram://${WATCHTOWER_TELEGRAM_TOKEN:-${APPS_TELEGRAM_TOKEN}}@telegram?chats=${APPS_TELEGRAM_CHAT}&preview=false" - WATCHTOWER_NOTIFICATION_TEMPLATE: | - {{- if .Report -}} - {{- with .Report -}} - {{- range .Updated}} - - {{.Name}} ({{.ImageName}}): Updated - {{- end -}} - {{- range .Skipped}} - - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} - {{- end -}} - {{- range .Failed}} - - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} - {{- end -}} - {{- end -}} - {{- else -}} - {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}} - {{- end -}} -services: - watchtower: - image: nickfedor/watchtower - environment: *environment - labels: - - "com.centurylinklabs.watchtower.enable=true" - volumes: - - /var/run/docker.sock:/var/run/docker.sock - restart: unless-stopped diff --git a/deploy/deploy.py b/deploy/deploy.py index 25c8973..ea27ec8 100644 --- a/deploy/deploy.py +++ b/deploy/deploy.py @@ -29,7 +29,6 @@ from vault import decrypt_env, parse_dotenv APPS_BUNDLE_ASSET = "flightdeck-apps.zip" -WATCHTOWER_LABEL = "com.centurylinklabs.watchtower.enable=true" class DeployError(Exception): @@ -105,10 +104,6 @@ def list_required_networks(release_dir): ] -def is_watchtower_managed(compose_path): - return WATCHTOWER_LABEL in Path(compose_path).read_text() - - def archive_release(release_dir, work_dir): archive_path = work_dir / "release.tar.gz" with tarfile.open(archive_path, "w:gz") as tar: @@ -163,7 +158,7 @@ def prune_releases(connection, releases_path, keep_releases): connection.run("rm -rf " + " ".join(shlex.quote(release) for release in stale), hide=True) -def deploy_to_host(host, archive_path, rendered_configs, all_apps, run_apps, networks, config): +def deploy_to_host(host, archive_path, rendered_configs, apps, networks, config): connection = Connection(host) connection.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) @@ -179,12 +174,12 @@ def deploy_to_host(host, archive_path, rendered_configs, all_apps, run_apps, net bootstrap_host(connection, base_path, networks) push_release(connection, archive_path, release_path) push_app_configs(connection, base_path, rendered_configs) - for app in all_apps: + for app in apps: connection.run(f"mkdir -p {shlex.quote(f'{base_path}/apps-data/{app}')}", hide=True) connection.run(f"ln -sfn {shlex.quote(release_path)} {shlex.quote(current_path)}", hide=True) - for app in run_apps: + for app in apps: compose_dir = f"{current_path}/apps/{app}" connection.run(f"cd {shlex.quote(compose_dir)} && docker compose pull && docker compose up -d --remove-orphans") @@ -218,14 +213,11 @@ def main(): archive_path = archive_release(release_dir, work_dir) networks = list_required_networks(release_dir) - all_apps = list(config["apps"]) - run_apps = [ - app for app in all_apps if not is_watchtower_managed(release_dir / "apps" / app / "docker-compose.yml") - ] + apps = list(config["apps"]) for host in config["hosts"]: print(f"Deploying to {host}") - deploy_to_host(host, archive_path, rendered_configs, all_apps, run_apps, networks, config) + deploy_to_host(host, archive_path, rendered_configs, apps, networks, config) if __name__ == "__main__": diff --git a/deploy/tests/test_deploy.py b/deploy/tests/test_deploy.py index e12a78f..4d3942b 100644 --- a/deploy/tests/test_deploy.py +++ b/deploy/tests/test_deploy.py @@ -264,20 +264,6 @@ def test_returns_only_external_networks(self): self.assertNotIn("internal", networks) -class IsWatchtowerManagedTest(unittest.TestCase): - def test_true_when_label_present(self): - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "docker-compose.yml" - path.write_text('labels:\n - "com.centurylinklabs.watchtower.enable=true"\n') - self.assertTrue(deploy.is_watchtower_managed(path)) - - def test_false_when_label_absent(self): - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "docker-compose.yml" - path.write_text("services: {}\n") - self.assertFalse(deploy.is_watchtower_managed(path)) - - class ArchiveReleaseTest(unittest.TestCase): def test_archives_release_contents_without_wrapper_dir(self): with tempfile.TemporaryDirectory() as directory: @@ -337,8 +323,7 @@ def test_full_sequence(self): "deploy@host", archive_path, rendered_configs, - all_apps=["traefik", "rybbit"], - run_apps=["rybbit"], + apps=["traefik", "rybbit"], networks=["traefik", "databases", "mcp"], config=config, ) @@ -359,7 +344,7 @@ def test_full_sequence(self): self.assertIn("mkdir -p /home/deploy/flightdeck/apps-data/rybbit", joined) self.assertIn("ln -sfn", joined) self.assertIn("apps/rybbit && docker compose pull && docker compose up -d --remove-orphans", joined) - self.assertNotIn("apps/traefik && docker compose", joined) + self.assertIn("apps/traefik && docker compose pull && docker compose up -d --remove-orphans", joined) prune_command = next(command for command in fake.commands if command.startswith("rm -rf") and "rel" in command) for stale in ("rel5", "rel6"):