Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
ed8ad09
feat: add Docker daemon config renderer for network policy
Nickfost Aug 29, 2026
48509ec
feat: add Docker daemon network policy apply engine stage
Nickfost Aug 29, 2026
eb131ce
fix: make Docker network policy apply transaction safe
Nickfost Aug 29, 2026
c8848aa
fix: require safe drain and report rollback failures
Nickfost Aug 29, 2026
5ca6e04
fix: harden Docker network policy file transaction
Nickfost Aug 29, 2026
5a12d4a
fix: close Docker policy verification gaps
Nickfost Aug 29, 2026
be86098
fix: restore prior Docker policy on desired-state removal
Nickfost Aug 29, 2026
17bedc2
fix: harden Docker policy command boundaries
Nickfost Aug 29, 2026
ebfbc27
fix: preserve Docker policy file durability
Nickfost Aug 29, 2026
42775b6
fix: verify Docker policy runtime transitions
Nickfost Aug 29, 2026
eed2bec
fix: preserve Docker settings on policy removal
Nickfost Aug 29, 2026
c21a620
docs: document Docker policy rollout gates
Nickfost Aug 29, 2026
11ca1ce
fix: preserve warning health fixtures in CI
Nickfost Aug 29, 2026
88ab763
fix: preserve health operational controls
Nickfost Aug 29, 2026
e20c894
fix: validate network policy inputs before drain
Nickfost Aug 29, 2026
e789a8a
fix: verify capacity after network policy removal
Nickfost Aug 30, 2026
a0fb1a5
fix: enforce trusted network policy paths
Nickfost Aug 30, 2026
4b81478
fix: retain durable network policy recovery state
Nickfost Aug 30, 2026
e5ffbdc
fix: resume controller after failed policy drain
Nickfost Aug 30, 2026
12707b1
fix: persist interrupted policy removal state
Nickfost Aug 30, 2026
89cf008
fix: separate policy checkpoint and daemon paths
Nickfost Aug 30, 2026
e76fcae
fix: validate rendered network policy capacity
Nickfost Aug 30, 2026
82d02a0
fix: abort policy apply on daemon conflicts
Nickfost Aug 30, 2026
53b829d
fix: trust rendered policy environment paths
Nickfost Aug 30, 2026
2e1e220
fix: persist durable policy rollback recovery
Nickfost Aug 30, 2026
035c16a
fix: abort policy removal on daemon conflicts
Nickfost Aug 30, 2026
ebdb964
fix: open installer lock through trusted path
Nickfost Aug 30, 2026
1615e2d
fix: recover safe interrupted policy removal
Nickfost Aug 30, 2026
452aede
fix: reject explicit empty network policy
Nickfost Aug 30, 2026
91a25b3
fix: validate network policy transaction paths
Nickfost Aug 30, 2026
fe04f50
fix: harden network policy recovery
Nickfost Aug 30, 2026
c131467
fix: serialize network policy recovery
Nickfost Aug 30, 2026
f0eb7e5
fix: redact rendered network policy failures
Nickfost Aug 30, 2026
f08cbc0
fix: close network policy recovery gaps
Nickfost Aug 30, 2026
6aeae2e
fix: close network policy recovery lock gaps
Nickfost Aug 30, 2026
4ac9ec7
fix: harden network policy retry rollback
Nickfost Aug 30, 2026
ba6cf53
test: make daemon metadata regression portable
Nickfost Aug 30, 2026
94268a2
fix: harden network policy recovery durability
Nickfost Aug 30, 2026
37212a2
fix: harden network policy recovery ordering
Nickfost Aug 30, 2026
ed928a3
fix: harden network policy reapply consistency
Nickfost Aug 30, 2026
18070a2
fix: harden network policy rollback recovery
Nickfost Aug 30, 2026
b020855
fix: preserve network policy removal provenance
Nickfost Aug 30, 2026
9d20100
fix: harden network policy reconciliation
Nickfost Aug 30, 2026
95d8bcc
fix: bound network policy transaction inputs
Nickfost Aug 30, 2026
abf1a84
fix: complete network policy recovery validation
Nickfost Aug 30, 2026
148f0d2
fix: close network policy validation gaps
Nickfost Aug 30, 2026
777af29
fix: complete network policy validation closure
Nickfost Aug 30, 2026
78de795
fix: reject daemon paths within checkpoints
Nickfost Aug 30, 2026
3aa7fa7
fix: close network policy recovery gaps
Nickfost Aug 31, 2026
240f70b
fix: redrain before interrupted recovery fallback
Nickfost Aug 31, 2026
5db6b72
fix: parse rendered health environment safely
Nickfost Aug 31, 2026
05ca901
fix: make first-apply recovery crash consistent
Nickfost Aug 31, 2026
4fc3f98
fix: close network policy verification gaps
Nickfost Aug 31, 2026
032bed9
fix: preserve network policy trust and recovery
Nickfost Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions scripts/apply-docker-network-policy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
# Engine stage: apply the reviewed Docker daemon network policy transactionally.
#
# Validates, drains, applies daemon.json atomically (preserving unrelated keys),
# restarts Docker ONLY via injected command boundary, runs capacity probes +
# health checks, and rolls back the exact prior config on any failure.
#
# Environment variables (all injected, never host-defaulted):
# CI_FLEET_DOCKER_DAEMON_CONFIG absolute path to daemon.json
# CI_FLEET_DOCKER_DRAIN_COMMAND path to a host drain script (runs before mutation)
# CI_FLEET_DOCKER_RESTART_COMMAND path to a Docker restart script
# CI_FLEET_DOCKER_NETWORK_PROBE path to a capacity probe script
# CI_FLEET_HEALTH_CHECK_COMMAND path to a health-check script
# CI_FLEET_COMMAND_TIMEOUT_SECONDS command timeout in seconds (default 300)
# CI_FLEET_TESTING when 1, relaxes root/strict checks
set -Eeuo pipefail

repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
testing=${CI_FLEET_TESTING:-0}

env_file=
checkpoint_dir=

usage() {
cat >&2 <<'EOF'
usage: apply-docker-network-policy.sh --env PATH [--checkpoint PATH]

--env PATH path to the rendered ci-fleet env file (required)
--checkpoint PATH directory to back up the prior daemon.json into
EOF
}

die() {
printf 'ERROR: %s\n' "$*" >&2
exit 2
}

while (($#)); do
case "$1" in
--env)
[[ $# -ge 2 ]] || die '--env requires a value'
env_file=$2
shift 2
;;
--checkpoint)
[[ $# -ge 2 ]] || die '--checkpoint requires a value'
checkpoint_dir=$2
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
usage
die "unknown argument: $1"
;;
esac
done

[[ -n "$env_file" ]] || die '--env is required'
[[ -r "$env_file" ]] || die "rendered env is unreadable: $env_file"
Comment thread
Nickfost marked this conversation as resolved.
Outdated

# --- No-op when no network policy is rendered ---
count=$(awk -F= '$1 == "CI_FLEET_DOCKER_DEFAULT_ADDRESS_POOL_COUNT" {print substr($0, index($0, "=") + 1)}' "$env_file")
if [[ -z "$count" || "$count" == "0" ]]; then
Comment thread
Nickfost marked this conversation as resolved.
Outdated
printf 'NETWORK_POLICY_NOOP\n'
exit 0
Comment thread
Nickfost marked this conversation as resolved.
Outdated
fi

# --- Resolve required injected commands ---
daemon_config=${CI_FLEET_DOCKER_DAEMON_CONFIG:-}
drain_command=${CI_FLEET_DOCKER_DRAIN_COMMAND:-}
restart_command=${CI_FLEET_DOCKER_RESTART_COMMAND:-}
probe_command=${CI_FLEET_DOCKER_NETWORK_PROBE:-}
health_command=${CI_FLEET_HEALTH_CHECK_COMMAND:-}
command_timeout=${CI_FLEET_COMMAND_TIMEOUT_SECONDS:-300}

[[ "$command_timeout" =~ ^[1-9][0-9]*$ ]] || die 'CI_FLEET_COMMAND_TIMEOUT_SECONDS must be a positive integer'
[[ -n "$daemon_config" ]] || die 'CI_FLEET_DOCKER_DAEMON_CONFIG is required when a network policy is configured'
Comment thread
Nickfost marked this conversation as resolved.
[[ -n "$restart_command" ]] || die 'CI_FLEET_DOCKER_RESTART_COMMAND is required when a network policy is configured'
[[ -n "$probe_command" ]] || die 'CI_FLEET_DOCKER_NETWORK_PROBE is required when a network policy is configured'
[[ -n "$health_command" ]] || die 'CI_FLEET_HEALTH_CHECK_COMMAND is required when a network policy is configured'
[[ -x "$restart_command" ]] || die "restart command is not executable: $restart_command"
[[ -z "$drain_command" || -x "$drain_command" ]] || die "drain command is not executable: $drain_command"
Comment thread
Nickfost marked this conversation as resolved.
Outdated
[[ -x "$probe_command" ]] || die "network probe is not executable: $probe_command"
[[ -x "$health_command" ]] || die "health-check command is not executable: $health_command"

# --- Ownership guard (relaxed in testing) ---
if [[ "$testing" != 1 ]]; then
[[ -w "$(dirname "$daemon_config")" ]] || die "daemon config directory is not writable: $(dirname "$daemon_config")"
if [[ -f "$daemon_config" ]]; then
file_owner=$(stat -c %u "$daemon_config")
[[ "$file_owner" == "0" ]] || die "daemon.json must be owned by root: $daemon_config"
Comment thread
Nickfost marked this conversation as resolved.
Comment thread
Nickfost marked this conversation as resolved.
fi
else
: # testing mode — skip root checks
fi

# --- Render desired daemon config block via shared validator ---
desired_pools_json=$(python3 - "$env_file" "$repo_root/scripts" <<'PY'
import json, os, sys
env_path, scripts_dir = sys.argv[1], sys.argv[2]
sys.path.insert(0, scripts_dir)
values = {}
with open(env_path, encoding="utf-8") as handle:
for line in handle:
line = line.rstrip("\n")
if "=" in line and line:
key, _, value = line.partition("=")
values[key] = value
from desired_state import render_docker_daemon_config
print(json.dumps(render_docker_daemon_config(values)))
Comment thread
Nickfost marked this conversation as resolved.
Outdated
PY
) || die "daemon policy rendering failed"

# Re-check: if rendering returned empty, treat as no-op.
if [[ "$desired_pools_json" == "{}" ]]; then
printf 'NETWORK_POLICY_NOOP\n'
exit 0
fi

# --- Drain before any daemon.json mutation or restart ---
if [[ -n "$drain_command" ]] && ! timeout "$command_timeout" "$drain_command" 2>&1; then
die "drain command failed before network-policy apply"
fi

# --- Stage merged daemon.json (preserve unrelated keys) ---
work_dir=$(mktemp -d "${CI_FLEET_TEMP_DIR:-/tmp}/.ci-fleet-apply.XXXXXX")
staging_daemon="$work_dir/daemon.json"

python3 - "$env_file" "$daemon_config" "$staging_daemon" "$desired_pools_json" <<'PY' || { rm -rf "$work_dir"; die "failed to stage merged daemon.json"; }
import json, os, sys
_, _, daemon_path, staging_path, desired_pools_json = sys.argv
prior = {}
if os.path.exists(daemon_path):
try:
text = open(daemon_path, encoding="utf-8").read()
Comment thread
Nickfost marked this conversation as resolved.
prior = json.loads(text)
Comment thread
Nickfost marked this conversation as resolved.
Outdated
if not isinstance(prior, dict):
raise ValueError("daemon.json root must be an object")
except (json.JSONDecodeError, ValueError) as exc:
raise SystemExit(f"ERROR: existing daemon.json is not a valid JSON object: {exc}")
Comment thread
Nickfost marked this conversation as resolved.
desired_pools = json.loads(desired_pools_json)
merged = dict(prior)
merged["default-address-pools"] = desired_pools.get("default-address-pools", [])
with open(staging_path, "w", encoding="utf-8") as handle:
json.dump(merged, handle, indent=2, sort_keys=True)
handle.write("\n")
os.chmod(staging_path, 0o644)
Comment thread
Nickfost marked this conversation as resolved.
PY

# --- Back up exact prior daemon.json for rollback ---
prior_daemon="$work_dir/prior"
mkdir -p "$prior_daemon"
if [[ -n "$checkpoint_dir" ]]; then
mkdir -p "$checkpoint_dir"
Comment thread
Nickfost marked this conversation as resolved.
Outdated
backup_dir="$checkpoint_dir"
backup_name="daemon.json"
else
backup_dir="$prior_daemon"
backup_name="daemon.json.before"
fi
had_prior=false
if [[ -f "$daemon_config" ]]; then
had_prior=true
cp -p "$daemon_config" "$backup_dir/$backup_name"
Comment thread
Nickfost marked this conversation as resolved.
Outdated
Comment thread
Nickfost marked this conversation as resolved.
Outdated
fi

daemon_dir=$(dirname "$daemon_config")

restore_daemon() {
if [[ "$had_prior" == true ]]; then
cp -p "$backup_dir/$backup_name" "$daemon_config"
Comment thread
Nickfost marked this conversation as resolved.
Outdated
else
rm -f "$daemon_config"
Comment thread
Nickfost marked this conversation as resolved.
fi
}

# Rollback: restore prior config, restart through the boundary, run health check.
# Failure evidence is surfaced through exit code only — no CIDRs or secrets leaked.
rollback_daemon() {
local failed=0
restore_daemon || failed=1
timeout "$command_timeout" "$restart_command" "$daemon_dir" >/dev/null 2>&1 || failed=1
timeout "$command_timeout" "$health_command" >/dev/null 2>&1 || failed=1
return "$failed"
}

# --- Transaction: apply → restart → probe → health, with rollback ---
# Apply daemon.json atomically (rename within same directory)
python3 - "$staging_daemon" "$daemon_dir" "$daemon_config" <<'PY' || { restore_daemon; rm -rf "$work_dir"; die "failed to apply daemon.json"; }
Comment thread
Nickfost marked this conversation as resolved.
Outdated
import os, shutil, sys, tempfile
_, _, daemon_dir, target = sys.argv
fd, tmp = tempfile.mkstemp(prefix=".daemon.json.", dir=daemon_dir)
try:
with open(sys.argv[1], "rb") as source, os.fdopen(fd, "wb") as staged:
shutil.copyfileobj(source, staged)
os.chmod(tmp, 0o644)
os.replace(tmp, target)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
PY

# Restart Docker through the injected command boundary (never host-direct).
if ! timeout "$command_timeout" "$restart_command" "$daemon_dir" 2>&1; then
Comment thread
Nickfost marked this conversation as resolved.
Outdated
rollback_daemon || true
rm -rf "$work_dir"
die "Docker restart command failed; prior daemon.json restored"
Comment thread
Nickfost marked this conversation as resolved.
Outdated
fi

# Bounded capacity probe
if ! timeout "$command_timeout" "$probe_command" 2>&1; then
rollback_daemon || true
rm -rf "$work_dir"
die "capacity probe failed after network-policy restart; prior daemon.json restored"
fi

# Health verification
if ! timeout "$command_timeout" "$health_command" 2>&1; then
rollback_daemon || true
rm -rf "$work_dir"
die "health check failed after network-policy restart; prior daemon.json restored"
fi

# --- Success ---
rm -rf "$work_dir"
printf 'NETWORK_POLICY_APPLIED daemon_config=%s\n' "$daemon_config"
39 changes: 39 additions & 0 deletions scripts/desired_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,45 @@ def validate_docker_network_policy(policy: dict[str, Any], *, path: str, max_run
return configured, reserve, networks_per_runner, parsed


def render_docker_daemon_config(rendered: dict[str, str]) -> dict[str, Any]:
"""Build the Docker daemon.json ``default-address-pools`` block from rendered env.

Reads only the already-validated ``CI_FLEET_DOCKER_DEFAULT_ADDRESS_POOL_*``
values produced by ``build_rendered_env``. Returns a dict suitable for
merging into ``daemon.json``. When no policy was rendered, returns an empty
dict (no ``default-address-pools`` key).
"""
count_str = rendered.get("CI_FLEET_DOCKER_DEFAULT_ADDRESS_POOL_COUNT", "0")
try:
count = int(count_str)
except ValueError as exc:
raise ValueError(f"CI_FLEET_DOCKER_DEFAULT_ADDRESS_POOL_COUNT: must be an integer, got {count_str!r}") from exc
if count == 0 and "CI_FLEET_DOCKER_DEFAULT_ADDRESS_POOL_COUNT" not in rendered:
return {}
if count < 0:
raise ValueError("CI_FLEET_DOCKER_DEFAULT_ADDRESS_POOL_COUNT: must be non-negative")
pools: list[dict[str, Any]] = []
for index in range(count):
base = rendered.get(f"CI_FLEET_DOCKER_DEFAULT_ADDRESS_POOL_{index}_BASE")
size_str = rendered.get(f"CI_FLEET_DOCKER_DEFAULT_ADDRESS_POOL_{index}_SIZE")
if base is None or size_str is None:
raise ValueError(f"CI_FLEET_DOCKER_DEFAULT_ADDRESS_POOL_{index}_BASE/SIZE: both required when count > 0")
Comment thread
Nickfost marked this conversation as resolved.
try:
network = ipaddress.ip_network(base, strict=True)
except ValueError as exc:
raise ValueError(f"CI_FLEET_DOCKER_DEFAULT_ADDRESS_POOL_{index}_BASE: malformed CIDR {base!r}") from exc
Comment thread
Nickfost marked this conversation as resolved.
Outdated
try:
size = int(size_str)
except ValueError as exc:
raise ValueError(f"CI_FLEET_DOCKER_DEFAULT_ADDRESS_POOL_{index}_SIZE: must be an integer, got {size_str!r}") from exc
if not isinstance(size, int) or size < 0 or size > 29:
raise ValueError(f"CI_FLEET_DOCKER_DEFAULT_ADDRESS_POOL_{index}_SIZE: must be between 0 and 29")
if size < network.prefixlen:
raise ValueError(f"CI_FLEET_DOCKER_DEFAULT_ADDRESS_POOL_{index}_SIZE: impossible subnet count for {base!r}")
Comment thread
Nickfost marked this conversation as resolved.
Outdated
pools.append({"base": base, "size": size})
return {"default-address-pools": [{"base": p["base"], "size": p["size"]} for p in pools]}


def select_controller(config: dict[str, Any], controller_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
controllers = config["controllers"]
if controller_id not in controllers:
Expand Down
Loading