#!/usr/bin/env bash # scripts/deploy/deploy-service.sh — deploy exactly ONE service on the local node. # # Extracted from deploy-node.sh so that the human whole-node deploy and the # agent-driven single-service redeploy (services/deploy-runner/) share ONE # compose invocation. That sharing is the whole point: docker compose derives # the PROJECT NAME from the directory of the first -f file (verified: # `docker compose -f services/node-agent/docker-compose.yml config` → name # "node-agent", independent of cwd; adding --project-directory changes it). # A caller that assembles the -f arguments differently lands in a DIFFERENT # project, where `--remove-orphans` tears down the already-running stack — # exactly what wiped the control-plane on VPS on 2026-06-25. Every caller goes # through here. # # Usage: # deploy-service.sh --repo --host-dir path> # --service # [--build-if-needed] [--force-recreate] [--remove-orphans] # # Flags: # --build-if-needed add --build when the service ships its own Dockerfile # (whole-node deploy path — see the rationale below) # --force-recreate recreate containers even when config is unchanged # (remediation path: plain `up -d` is a no-op for an # unhealthy-but-unchanged service, i.e. it would "succeed" # without doing anything) # --remove-orphans human whole-node deploy only; never used by the agent # path, where a project-name mismatch must not be able to # delete containers # # Exit codes: # 0 deployed # 2 usage / validation error # 3 service owns its deploy path (services//deploy-local.sh) — skipped # * docker compose exit code set -euo pipefail REPO_PATH="" HOST_DIR="" SERVICE="" BUILD_IF_NEEDED=false FORCE_RECREATE=false REMOVE_ORPHANS=false usage() { sed -n '2,30p' "${BASH_SOURCE[0]}" >&2 exit 2 } while [[ $# -gt 0 ]]; do case "$1" in --repo) REPO_PATH="${2:-}"; shift 2 ;; --host-dir) HOST_DIR="${2:-}"; shift 2 ;; --service) SERVICE="${2:-}"; shift 2 ;; --build-if-needed) BUILD_IF_NEEDED=true; shift ;; --force-recreate) FORCE_RECREATE=true; shift ;; --remove-orphans) REMOVE_ORPHANS=true; shift ;; -h|--help) usage ;; *) echo "deploy-service.sh: unknown argument: $1" >&2; usage ;; esac done [[ -n "$REPO_PATH" && -n "$HOST_DIR" && -n "$SERVICE" ]] || { echo "deploy-service.sh: --repo, --host-dir and --service are required" >&2 usage } # Service names are used to build filesystem paths. Reject anything that is not # a plain kebab-case service name so a malformed (or maliciously crafted) # dispatched action can never escape services/ via ../. if [[ ! "$SERVICE" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; then echo "deploy-service.sh: invalid service name: '${SERVICE}'" >&2 exit 2 fi [[ -d "$REPO_PATH" ]] || { echo "deploy-service.sh: repo not found: ${REPO_PATH}" >&2; exit 2; } [[ -d "$HOST_DIR" ]] || { echo "deploy-service.sh: host dir not found: ${HOST_DIR}" >&2; exit 2; } SERVICE_DIR="${REPO_PATH}/services/${SERVICE}" COMPOSE_FILE="${SERVICE_DIR}/docker-compose.yml" if [[ ! -f "$COMPOSE_FILE" ]]; then echo "deploy-service.sh: compose file not found for ${SERVICE} at ${COMPOSE_FILE}" >&2 exit 2 fi # Services that ship their own deploy-local.sh have a dedicated, orchestrated # deploy path (e.g. control-plane via deploy-control-plane.sh → deploy-local.sh) # which does host-level preparation (dirs, ownership, sudo) this script cannot # do. Deploying them from here would also run `up` against a stack whose own # path expects to own it. Their own deploy path owns them; skip. if [[ -f "${SERVICE_DIR}/deploy-local.sh" ]]; then echo "Skipping ${SERVICE}: ma własną ścieżkę deployu (services/${SERVICE}/deploy-local.sh), pomijam" exit 3 fi COMPOSE_ARGS=(docker compose -f "$COMPOSE_FILE") OVERRIDE_FILE="${HOST_DIR}/runtime/${SERVICE}/docker-compose.override.yml" if [[ -f "$OVERRIDE_FILE" ]]; then echo "Using override file for ${SERVICE}" COMPOSE_ARGS+=(-f "$OVERRIDE_FILE") fi ENV_FILE="${SERVICE_DIR}/.env" if [[ -f "$ENV_FILE" ]]; then COMPOSE_ARGS+=(--env-file "$ENV_FILE") fi COMPOSE_ARGS+=(up -d) # Services with their own Dockerfile need a rebuild on a whole-node deploy — # otherwise `up -d` sees "container running, image tag unchanged" and skips # rebuilding even when src/ changed, silently leaving the old code running # (backlog c858dbc). The agent redeploy path deliberately does NOT pass # --build-if-needed: a redeploy is a reconcile, not a code ship, and building # on the 4 GiB VPS mid-incident is an OOM risk. if [[ "$BUILD_IF_NEEDED" == "true" && -f "${SERVICE_DIR}/Dockerfile" ]]; then echo "Building ${SERVICE} (has Dockerfile)" COMPOSE_ARGS+=(--build) fi if [[ "$FORCE_RECREATE" == "true" ]]; then COMPOSE_ARGS+=(--force-recreate) fi if [[ "$REMOVE_ORPHANS" == "true" ]]; then COMPOSE_ARGS+=(--remove-orphans) fi echo "Deploying service: ${SERVICE}..." echo "+ ${COMPOSE_ARGS[*]}" "${COMPOSE_ARGS[@]}"