Files
computer-playbook/scripts/tests/deploy/swarm/utils/_context.sh
T
kevinveenbirkenbachandClaude Opus 5 16dd0e2333 feat(swarm): pin a single service without pinning its role
A role-level `placement: manager` pins every service of the role and keeps
every volume node-local, so a role needing one node-bound service had to give
up NFS for all of them. GitLab's gitaly is that case: its repositories volume
is node-local (`nfs: false`, gitaly does not support NFS) and a reschedule
left the data behind on the old node.

`deploy.yml.j2` now resolves `services.<service>.placement` first and keeps the
role-wide list as the fallback, so gitaly carries the constraint alone while
webservice, sidekiq, workhorse, shell, migrations and gitlabmcp stay
schedulable and `gitlab_shared`, `gitlab_uploads` and `gitlab_builds` keep
their NFS backing. Role placement still comes from the primary entry, which is
what drives that backing.

Two things had to come with it. The swarm drill decided that a role is
manager-pinned by grepping the whole `meta/services.yml`, so a per-service pin
would have skipped GitLab's drain and reschedule steps while six of its
services still run on workers; it now reads the primary entry. And
`resource_filter` returns a present-but-null value rather than the hard
default, which rendered `node.role == None`, a constraint no node satisfies.

`test_no_placement_manager.py` now inspects every `placement` value instead of
only `manager`, requires the justification for each, and rejects a value that
is not a swarm node role, since a misspelled one hangs the converge wait
rather than failing the deploy. Requirement 023 records the shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-16 23:29:50 +02:00

178 lines
5.8 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../.." && pwd)"
# shellcheck source=scripts/meta/env/load.sh
source "${_REPO_ROOT}/scripts/meta/env/load.sh"
if [ -s "${SWARM_DRILL_ENV:-}" ]; then
set -a
# shellcheck source=/dev/null
source "${SWARM_DRILL_ENV}"
set +a
fi
: "${APP_ID:?APP_ID required}"
ENTITY="$(PYTHONPATH="${_REPO_ROOT}" "${PYTHON}" -c "from utils.roles.entity.name import entity_name; print(entity_name('${APP_ID}'))")"
STACK_NAME="${ENTITY}"
CUSTOM_IMAGE_REPO="${ENTITY}_custom"
ROLE_DIR="${_REPO_ROOT}/roles/${APP_ID}"
PRIMARY_SERVICE_KEY="$(PYTHONPATH="${_REPO_ROOT}" "${PYTHON}" -c "
import os, yaml
key = '${ENTITY}'
svc = os.path.join('${ROLE_DIR}', 'meta', 'services.yml')
if os.path.exists(svc):
data = yaml.safe_load(open(svc)) or {}
entity = data.get('${ENTITY}') if isinstance(data, dict) else None
if isinstance(entity, dict):
k = (entity.get('proxy') or {}).get('service_key')
if isinstance(k, str) and k:
key = k
print(key)
")"
SERVICE_NAME="${STACK_NAME}_${PRIMARY_SERVICE_KEY}"
DB_DEP=none
if [ -f "${ROLE_DIR}/meta/services.yml" ]; then
if grep -qE '^mariadb:' "${ROLE_DIR}/meta/services.yml"; then
DB_DEP=mariadb
elif grep -qE '^postgres:' "${ROLE_DIR}/meta/services.yml"; then
DB_DEP=postgres
fi
fi
DEFAULT_PLACEMENT_MANAGER="$(PYTHONPATH="${_REPO_ROOT}" "${PYTHON}" -c "
from utils.roles.meta_lookup import get_role_placement
placement = get_role_placement('${ROLE_DIR}', role_name='${APP_ID}') or ''
print('true' if placement.strip() == 'manager' else 'false')
")"
PROBE_PORT="$(PYTHONPATH="${_REPO_ROOT}" "${PYTHON}" -c "
import os, yaml
port = 80
svc = os.path.join('${ROLE_DIR}', 'meta', 'services.yml')
if os.path.exists(svc):
data = yaml.safe_load(open(svc)) or {}
entity = data.get('${ENTITY}') if isinstance(data, dict) else None
if isinstance(entity, dict):
try:
port = entity['ports']['internal']['http'] or 80
except Exception:
port = 80
print(port)
")"
NFS_VOLUMES=""
if [ -f "${ROLE_DIR}/meta/volumes.yml" ]; then
NFS_VOLUMES="$(python3 -c "
import sys, yaml
with open('${ROLE_DIR}/meta/volumes.yml') as f:
data = yaml.safe_load(f) or {}
if isinstance(data, list):
entries = [e for e in data if isinstance(e, dict)]
elif isinstance(data, dict):
entries = [v for v in data.values() if isinstance(v, dict)]
else:
entries = []
for v in entries:
if v.get('type') != 'volume':
continue
if v.get('nfs') is False:
continue
docker_name = v.get('docker_name') or v.get('name')
if isinstance(docker_name, str):
print(docker_name)
")"
fi
PRIMARY_NFS_VOLUME="${NFS_VOLUMES%%$'\n'*}"
NFS_CHECK_MOUNTPOINT="/mnt/nfs-check"
export APP_ID ENTITY STACK_NAME SERVICE_NAME PRIMARY_SERVICE_KEY CUSTOM_IMAGE_REPO DB_DEP NFS_VOLUMES PRIMARY_NFS_VOLUME DEFAULT_PLACEMENT_MANAGER PROBE_PORT NFS_CHECK_MOUNTPOINT
# Exits the calling chaos step (09/10/11) with success when the role is
# manager-pinned (no worker task exists to drain).
skip_chaos_if_manager_pinned() {
if [ "${DEFAULT_PLACEMENT_MANAGER}" = true ]; then
echo "SKIP: ${ENTITY} declares placement: manager (single-node on the swarm manager) — drain-a-worker reschedule chaos does not apply"
exit 0
fi
}
has_swarm_service() {
local _names=""
[ -n "${MGR:-}" ] || return 1
if ! _names="$(docker exec "${MGR}" docker stack services \
--format '{{.Name}}' "${STACK_NAME}" 2>/dev/null)"; then
_names=""
fi
printf '%s\n' "${_names}" | grep -qxF "${SERVICE_NAME}"
}
# Prints the swarm service carrying APP_ID's database. A role that pins its own
# engine runs it as <entity>_database inside its own stack, a central provider
# as <dep>_<dep> in its own (plugins/lookup/database.py).
resolve_db_service() {
local _names=""
[ "${DB_DEP}" != "none" ] || return 0
[ -n "${MGR:-}" ] || return 0
if ! _names="$(timeout 30 docker exec "${MGR}" docker service ls --format '{{.Name}}' 2>/dev/null)"; then
return 0
fi
if printf '%s\n' "${_names}" | grep -qxF "${STACK_NAME}_database"; then
printf '%s\n' "${STACK_NAME}_database"
elif printf '%s\n' "${_names}" | grep -qxF "${DB_DEP}_${DB_DEP}"; then
printf '%s\n' "${DB_DEP}_${DB_DEP}"
fi
}
# Exits the calling gate (03/05/06/07) with success when the role deploys no
# swarm service to converge or drain.
skip_if_no_swarm_service() {
if ! has_swarm_service; then
echo "SKIP: ${ENTITY} (${APP_ID}) deployed no swarm service — converge/chaos gate does not apply"
exit 0
fi
}
# A stack whose sidecar is named after its app (<app>mcp) makes every
# `--filter name=<app>` a two-hit match, so both helpers below match exactly.
# Param: $1 node container, $2 swarm service name
service_container_id() {
docker exec "$1" docker ps \
--filter "label=com.docker.swarm.service.name=$2" \
--format '{{.ID}}' | head -1
}
# Param: $1 manager container, $2 swarm service name
service_replicas() {
docker exec "$1" docker service ls \
--filter "name=$2" --format '{{.Name}} {{.Replicas}}' |
awk -v svc="$2" '$1 == svc { print $2 }'
}
# Param: $1 node container, $2 app container id, $3 port
probe_app_reachable() {
docker exec "$1" docker exec "$2" sh -c \
"curl -sS http://localhost:$3/ || wget -qO- http://localhost:$3/ || nc -z localhost $3" >/dev/null 2>&1 ||
docker exec "$1" docker exec "$2" bash -c "exec 3<>/dev/tcp/localhost/$3" >/dev/null 2>&1 ||
docker exec "$1" sh -c "docker inspect -f '{{.State.Health.Status}}' $2 | grep -qx healthy" >/dev/null 2>&1
}
if [ "${SWARM_NFS_PILOT_VERBOSE:-0}" = "1" ]; then
echo "==> APP_ID=${APP_ID}"
echo " ENTITY=${ENTITY}"
echo " STACK_NAME=${STACK_NAME}"
echo " SERVICE_NAME=${SERVICE_NAME}"
echo " CUSTOM_IMAGE_REPO=${CUSTOM_IMAGE_REPO}"
echo " DB_DEP=${DB_DEP}"
echo " PRIMARY_NFS_VOLUME=${PRIMARY_NFS_VOLUME}"
echo " DEFAULT_PLACEMENT_MANAGER=${DEFAULT_PLACEMENT_MANAGER}"
echo " PROBE_PORT=${PROBE_PORT}"
fi