218 lines
9.9 KiB
YAML
218 lines
9.9 KiB
YAML
# Target: /opt/homelab/compose/homelab-upgrade/compose.yaml on VM103, next to update.sh.
|
|
# The image is built by Gitea Actions and pulled from the Gitea registry (see docs/DEPLOY.md); TAG comes from .env (update.sh),
|
|
# default "latest". Data lives in /srv/appdata/homelab-upgrade/data (uid 1000), manifest backups in .../backups (root, 0700),
|
|
# the Proxmox timer's record of the newest vzdump in .../host.
|
|
name: homelab-upgrade
|
|
|
|
x-common: &common
|
|
image: git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade:${TAG:-latest}
|
|
restart: unless-stopped
|
|
user: "1000:1000"
|
|
read_only: true
|
|
cap_drop: [ALL]
|
|
security_opt: ["no-new-privileges:true"]
|
|
tmpfs: [/tmp]
|
|
volumes:
|
|
- /srv/appdata/homelab-upgrade/data:/data
|
|
|
|
services:
|
|
# The web app: reads the database, queues runs (by hand and by each workflow's cron schedule) and records decisions;
|
|
# executes nothing. No Docker socket, no host port.
|
|
server:
|
|
<<: *common
|
|
command: ["node", "src/main/server.ts"]
|
|
environment:
|
|
TZ: Europe/Warsaw
|
|
FUNCTION_RUNTIME_URL: http://function-runtime:8085
|
|
healthcheck:
|
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/api/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 10s
|
|
networks: [default, proxy]
|
|
labels:
|
|
traefik.enable: "true"
|
|
traefik.docker.network: "proxy"
|
|
traefik.http.routers.homelab-upgrade.rule: 'Host(`upgrade.mariuszpacek.pl`)'
|
|
traefik.http.routers.homelab-upgrade.entrypoints: "web-secured"
|
|
traefik.http.routers.homelab-upgrade.priority: "100"
|
|
traefik.http.routers.homelab-upgrade.middlewares: "homelab-upgrade-lan"
|
|
traefik.http.routers.homelab-upgrade.tls: "true"
|
|
traefik.http.routers.homelab-upgrade.tls.certresolver: "http"
|
|
traefik.http.routers.homelab-upgrade.service: "homelab-upgrade"
|
|
traefik.http.services.homelab-upgrade.loadbalancer.server.port: "8080"
|
|
# No login yet: LAN only.
|
|
traefik.http.middlewares.homelab-upgrade-lan.ipwhitelist.sourcerange: "192.168.0.1/24,10.0.0.0/16"
|
|
# Tile on dash.mariuszpacek.pl: homepage discovers it through its own Docker connection (my-docker), no edit of its files.
|
|
homepage.group: "Infra"
|
|
homepage.name: "Upgrade"
|
|
homepage.icon: "mdi-update"
|
|
homepage.href: "https://upgrade.mariuszpacek.pl"
|
|
homepage.description: "Compose app updates"
|
|
homepage.server: "my-docker"
|
|
homepage.container: "homelab-upgrade-server-1"
|
|
|
|
# The driver: runs queued workflow runs one step at a time and pushes to the owner's phone when a run waits for them,
|
|
# fails, or a real run ends. It finds the running containers by their compose labels (docker ps through the socket proxy
|
|
# below, GET only) and asks the registries what is newer. It reads no compose project file and changes nothing itself:
|
|
# for apps with real updates on (app_config.apply) it asks the executor after deterministic backup, preflight and diff
|
|
# gates. It reads the Proxmox timer's record of the newest vzdump.
|
|
driver:
|
|
<<: *common
|
|
command: ["node", "src/main/driver.ts"]
|
|
environment:
|
|
TZ: Europe/Warsaw
|
|
DOCKER_HOST: tcp://docker-proxy:2375
|
|
EXECUTOR_URL: http://executor:8081
|
|
HOST_BACKUP_FILE: /host/vzdump.json
|
|
# A model profile (Models page) picks the provider: claude or codex.
|
|
MODEL_CLAUDE_URL: http://model-claude:8083
|
|
MODEL_CODEX_URL: http://model-codex:8084
|
|
FUNCTION_RUNTIME_URL: http://function-runtime:8085
|
|
# From .env next to this file (never in the repo): HA_URL, HA_TOKEN, HA_NOTIFY.
|
|
HA_URL: ${HA_URL:-}
|
|
HA_TOKEN: ${HA_TOKEN:-}
|
|
HA_NOTIFY: ${HA_NOTIFY:-notify.notify}
|
|
volumes:
|
|
- /srv/appdata/homelab-upgrade/data:/data
|
|
- /srv/appdata/homelab-upgrade/host:/host:ro
|
|
networks: [default, docker, executor]
|
|
depends_on: [docker-proxy, executor, model-claude, model-codex, function-runtime]
|
|
|
|
# The executor publishes Compose changes to Git and waits for Arcane to deploy them, one project at a time.
|
|
# Explicitly direct projects use the local Compose CLI. Which apps get real updates is set in the app (Apps → Real updates); EXECUTOR_PROJECTS="*" leaves it there,
|
|
# a comma-separated list would narrow it. Never homelab-upgrade itself.
|
|
# Gates: pinned image, private backup of exactly the planned manifest, expanded diff = the image only.
|
|
# Root because the manifests are root:root 600; no capabilities, read-only root, no port, reachable only by the driver.
|
|
# /opt/homelab/compose is mounted at the same path so that relative paths in manifests resolve as on the host;
|
|
# /var/lib/docker only for the free-space check.
|
|
executor:
|
|
image: git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade:${TAG:-latest}
|
|
command: ["node", "src/main/executor.ts"]
|
|
restart: unless-stopped
|
|
user: "0:0"
|
|
read_only: true
|
|
cap_drop: [ALL]
|
|
security_opt: ["no-new-privileges:true"]
|
|
tmpfs: [/tmp]
|
|
environment:
|
|
TZ: Europe/Warsaw
|
|
EXECUTOR_PORT: "8081"
|
|
EXECUTOR_PROJECTS: "*"
|
|
AGENT_EXEC_RULES_FILE: /app/config/agent-exec.json
|
|
DRIVER_HOST: driver
|
|
# Provisioned on VM103; webhook tokens and SSH keys stay outside this repository (docs/DEPLOY.md).
|
|
GIT_DEPLOYMENTS_FILE: /git-config/deployments.json
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
- /opt/homelab/compose:/opt/homelab/compose
|
|
- /srv/appdata/homelab-upgrade/backups:/backups
|
|
- /var/lib/docker:/var/lib/docker:ro
|
|
- /srv/appdata/homelab-upgrade/git:/git
|
|
- /srv/appdata/homelab-upgrade/git-ssh:/git-ssh:ro
|
|
- /srv/appdata/homelab-upgrade/git-config:/git-config:ro
|
|
healthcheck:
|
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8081/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 10s
|
|
# Git mode also needs default for outbound SSH to Gitea and HTTPS to Arcane.
|
|
networks: [executor, default]
|
|
|
|
# The Claude model sidecar: unmodified `claude` CLI logged in with owner's account (private /claude volume).
|
|
# No Docker socket, no project files, no database. Egress (Anthropic) through default; executor and driver through executor.
|
|
model-claude:
|
|
image: git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade:${TAG:-latest}
|
|
command: ["node", "src/main/model-claude.ts"]
|
|
restart: unless-stopped
|
|
user: "1000:1000"
|
|
read_only: true
|
|
cap_drop: [ALL]
|
|
security_opt: ["no-new-privileges:true"]
|
|
tmpfs: [/tmp]
|
|
environment:
|
|
TZ: Europe/Warsaw
|
|
HOME: /claude
|
|
CLAUDE_CONFIG_DIR: /claude
|
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
|
|
MODEL_PORT: "8083"
|
|
HL_URL: http://executor:8081
|
|
DRIVER_HOST: driver
|
|
volumes:
|
|
- /srv/appdata/homelab-upgrade/claude:/claude
|
|
networks: [default, executor]
|
|
|
|
# The Codex model sidecar: `codex exec --ephemeral` logged in with owner's account (private /codex volume).
|
|
# No Docker socket, no project files, no database. Egress (OpenAI) through default; executor and driver through executor.
|
|
model-codex:
|
|
image: git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade:${TAG:-latest}
|
|
command: ["node", "src/main/model-codex.ts"]
|
|
restart: unless-stopped
|
|
user: "1000:1000"
|
|
read_only: true
|
|
cap_drop: [ALL]
|
|
security_opt: ["no-new-privileges:true"]
|
|
tmpfs: [/tmp]
|
|
environment:
|
|
TZ: Europe/Warsaw
|
|
HOME: /codex
|
|
CODEX_CONFIG_DIR: /codex
|
|
CODEX_HOME: /codex
|
|
CODEX_BIN: /opt/codex/bin/codex
|
|
MODEL_CODEX_PORT: "8084"
|
|
HL_URL: http://executor:8081
|
|
DRIVER_HOST: driver
|
|
volumes:
|
|
- /srv/appdata/homelab-upgrade/codex:/codex
|
|
networks: [default, executor]
|
|
|
|
# The isolated function runtime sidecar: runs untrusted workflow functions inside bwrap sandbox namespaces.
|
|
# Read-only root, ephemeral execution, no /data, no Docker socket, no network by default.
|
|
# bwrap builds its sandbox from an unprivileged user namespace, which Docker's defaults each block: seccomp (unshare),
|
|
# AppArmor (making / a slave mount) and masked /proc (a fresh procfs). Tested on VM103, 2026-09-22. The container stays
|
|
# non-root with no capabilities; bwrap is the isolation for the function code, and the runtime refuses to run without it.
|
|
function-runtime:
|
|
image: git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade:${TAG:-latest}
|
|
command: ["node", "src/main/function-runtime.ts"]
|
|
restart: unless-stopped
|
|
user: "1000:1000"
|
|
read_only: true
|
|
cap_drop: [ALL]
|
|
security_opt: ["no-new-privileges:true", "seccomp=unconfined", "apparmor=unconfined", "systempaths=unconfined"]
|
|
tmpfs: [/tmp]
|
|
environment:
|
|
TZ: Europe/Warsaw
|
|
FUNCTION_RUNTIME_PORT: "8085"
|
|
networks: [default]
|
|
|
|
# The only holder of the Docker socket. It forwards GET on containers (POST stays off), so a client cannot start, stop,
|
|
# create or delete anything. Known limit: a container inspect includes that container's environment, so the driver can
|
|
# read other containers' variables; this is why the driver is trusted code until the box exists (docs/DESIGN.md).
|
|
docker-proxy:
|
|
image: tecnativa/docker-socket-proxy:0.3.0@sha256:9e4b9e7517a6b660f2cc903a19b257b1852d5b3344794e3ea334ff00ae677ac2
|
|
restart: unless-stopped
|
|
environment:
|
|
CONTAINERS: "1"
|
|
POST: "0"
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
networks: [docker]
|
|
# Not read_only: the entrypoint writes haproxy.cfg next to its template in /usr/local/etc/haproxy at every start
|
|
# (a read-only root made it restart in a loop on VM103, 2026-09-19). A tmpfs there would hide the template.
|
|
tmpfs: [/run]
|
|
cap_drop: [ALL]
|
|
security_opt: ["no-new-privileges:true"]
|
|
|
|
networks:
|
|
proxy:
|
|
name: proxy
|
|
external: true
|
|
# Between the driver and the socket proxy only: no route out.
|
|
docker:
|
|
internal: true
|
|
# Private driver/executor API; Git mode gives the executor a separate route to Gitea and Arcane.
|
|
executor:
|
|
internal: true
|