fix(deploy): correct service selection and recovery validation
ci / Workflows (pull_request) Successful in 6s
ci / Shell (pull_request) Successful in 16s
ci / Python and tests (pull_request) Successful in 6s
ci / Compose (pull_request) Successful in 12s
ci / Formatting (pull_request) Successful in 15s
ci / Dockerfiles (pull_request) Successful in 4s
ci / YAML (pull_request) Successful in 19s
ci / Kubernetes (pull_request) Successful in 7s
ci / image-plan (pull_request) Skipped
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped

This commit is contained in:
forust committed 2026-10-08 12:26:10 +02:00
1 parent 4c7c53e0f2
commit c4cbd87590
7 files changed
+272 -18

No files matched your search

+63 -2
View File
@@ -42,7 +42,37 @@ def prepare(source_file):
images_file = directory / 'compose-images.json'
locks = json.loads(images_file.read_text()) if images_file.exists() else previous.get('compose-images', {})
release = json.loads((directory / 'release.json').read_text())
before = json.loads(json.dumps(config))
state = Path(os.environ.get('HOMELAB_STATE', Path.home() / '.local/state/homelab-deploy'))
baseline = state / 'compose-configs' / f'{relative.parent.name}.json'
if not baseline.exists() and re.fullmatch(r'[0-9]+-[0-9]+', previous.get('run_id', '')):
baseline = state / 'runs' / previous['run_id'] / 'compose' / baseline.name
bootstrap = not baseline.exists()
if not bootstrap:
before = json.loads(baseline.read_text())
else:
# Bootstrap from the persistent configuration, never from the new source.
persistent_file = config_repo / relative
if persistent_file.exists():
before = json.loads(
output(
'docker',
'compose',
'--project-directory',
str(project_dir),
'-f',
str(persistent_file),
'config',
'--format',
'json',
cwd=config_repo,
)
)
elif output('docker', 'ps', '-aq', '--filter', f'label=com.docker.compose.project={project}'):
raise ValueError(f'{project}: no previous Compose configuration; restore it before deploy')
else:
before = {'name': project, 'services': {}}
if before['name'] != project:
raise ValueError('Compose project name changed; manual migration is required')
for service, settings in config['services'].items():
reference = settings.get('image')
nextcloud_aio_master = project == 'nextcloud' and service == 'nextcloud-aio-mastercontainer'
@@ -63,6 +93,12 @@ def prepare(source_file):
pinned = resolve(reference)
settings['image'] = pinned
locks[reference] = pinned
for service, settings in before['services'].items():
reference = settings['image']
image_repo = reference.split('@')[0].rsplit('/', 1)
image_repo[-1] = image_repo[-1].split(':')[0]
image_repo = '/'.join(image_repo)
nextcloud_aio_master = project == 'nextcloud' and service == 'nextcloud-aio-mastercontainer'
# Capture what is running, not the current value of its mutable tag.
ids = output(
'docker',
@@ -74,6 +110,31 @@ def prepare(source_file):
f'label=com.docker.compose.service={service}',
).splitlines()
actual = set()
if bootstrap and ids:
expected_hash = output(
'docker',
'compose',
'--project-directory',
str(project_dir),
'-f',
str(persistent_file),
'config',
'--hash',
service,
cwd=config_repo,
).split()[-1]
for container in ids:
running_hash = output(
'docker',
'inspect',
container,
'--format',
'{{ index .Config.Labels "com.docker.compose.config-hash" }}',
)
if running_hash != expected_hash:
raise ValueError(
f'{project}/{service}: persistent config differs from running config; restore the previous config'
)
for container in ids:
image_id = output('docker', 'inspect', container, '--format', '{{.Image}}')
digests = json.loads(output('docker', 'image', 'inspect', image_id, '--format', '{{json .RepoDigests}}'))
@@ -95,7 +156,7 @@ def prepare(source_file):
images_file.write_text(json.dumps(locks, indent=2) + '\n')
print(f'Compose {project}: images pinned; local paths preserved')
print(
f'Recovery: docker compose --project-directory {project_dir} -p {project} -f {directory}/compose-before/{relative.parent.name}.json up -d --pull never'
f'Recovery: docker compose --project-directory {project_dir} -p {project} -f {directory}/compose-before/{relative.parent.name}.json up -d --pull never --remove-orphans'
)
+4
View File
@@ -165,6 +165,10 @@ def finish_success(directory, plan):
if previous.exists()
else {}
)
configs = STATE / 'compose-configs'
configs.mkdir(mode=0o700, exist_ok=True)
for config in (directory / 'compose').glob('*.json'):
atomic_json(configs / config.name, json.loads(config.read_text()))
atomic_json(STATE / 'last-success.json', plan)
status = json.loads((directory / 'status.json').read_text())
status['state'] = 'success'
+44 -9
View File
@@ -571,6 +571,41 @@ skip_uninstalled_vmagent_crd() {
return 1
}
# Render one complete resource list so new namespaces can be identified across
# files and Kustomize apps. A missing undeclared namespace remains an error.
render_selected_resources() {
local m k
{
for m in "${K8S_MANIFESTS[@]}"; do
if skip_uninstalled_vmagent_crd "$m" >/dev/null; then continue; fi
kubectl create --dry-run=client --validate=false -f "$m" -o json || return 1
done
for k in "${KUSTOMIZE_APPS[@]}"; do
kubectl kustomize "$k" | kubectl create --dry-run=client --validate=false -f - -o json || return 1
done
} | jq -s '{apiVersion: "v1", kind: "List", items: [ .[] | if .kind == "List" then .items[] else . end ]}'
}
validate_server_resources() {
local defer_new="$1" resources existing filtered
resources="$(render_selected_resources)" || return 1
existing="$(kubectl get namespaces -o json)" || return 1
filtered="$(jq --argjson existing "$existing" --argjson defer "$defer_new" '
[.items[] | select(.kind == "Namespace") | .metadata.name] as $declared
| [$existing.items[].metadata.name] as $present
| .items |= map(
(.metadata.namespace // "default") as $ns
| if .kind == "Namespace" or ($present | index($ns)) != null then .
elif ($declared | index($ns)) == null then error("Undeclared missing namespace: " + $ns)
elif $defer then empty
else error("Namespace still missing after namespace apply: " + $ns)
end)
' <<<"$resources")" || return 1
if [ "$(jq '.items | length' <<<"$filtered")" -gt 0 ]; then
kubectl apply --dry-run=server -f - <<<"$filtered" >/dev/null
fi
}
stage_validate() {
check_prune_mode || return 1
cd "$REPO"
@@ -597,15 +632,7 @@ stage_validate() {
kubectl apply -k "$k" --dry-run=client >/dev/null
done
log "Validate k8s manifests (kubectl dry-run=server)"
for m in ${K8S_MANIFESTS[@]+"${K8S_MANIFESTS[@]}"}; do
if skip_uninstalled_vmagent_crd "$m"; then
continue
fi
kubectl apply --dry-run=server -f "$m" >/dev/null
done
for k in ${KUSTOMIZE_APPS[@]+"${KUSTOMIZE_APPS[@]}"}; do
kubectl apply -k "$k" --dry-run=server >/dev/null
done
validate_server_resources true
log "Checking referenced Secrets exist"
echo " (deploy never applies *secret*.yaml; create missing ones manually)"
check_referenced_secrets
@@ -657,6 +684,14 @@ stage_apply_k8s() {
record_apply kubectl "${m#"$REPO"/}" success
done
fi
# Kustomize may declare namespaces inside its rendered resources too.
local namespace_resources
namespace_resources="$(render_selected_resources | jq '.items |= map(select(.kind == "Namespace"))')" || return 1
if [ "$(jq '.items | length' <<<"$namespace_resources")" -gt 0 ]; then
kubectl apply -f - <<<"$namespace_resources" || return 1
fi
# Complete the deferred server checks before Helm or application resources change.
validate_server_resources false || return 1
if selected_service k8s prometheus-stack && [ -f "$REPO/prometheus-stack/k8s/active" ]; then
if [ ! -f "$CONFIG_REPO/prometheus-stack/k8s/grafana-values.yaml" ]; then
echo "ERROR: prometheus-stack/k8s/grafana-values.yaml (gitignored) missing on workstation, restore it first."
+4 -4
View File
@@ -83,20 +83,20 @@ def make_plan(repo, config_repo, release, previous, mode, live_helm):
removed = []
else:
paths = output('git', '-C', str(repo), 'diff', '--name-only', previous['sha'], release['sha']).splitlines()
changed = {path.split('/')[0] for path in paths}
changed = {service for service in all_services for path in paths if path.startswith(service + '/')}
if any(path.startswith('.gitea/') for path in paths):
changed |= all_services
changed |= {s for s in all_services if previous.get('local_inputs', {}).get(s) != local_inputs[s]}
for file in tracked(repo):
service = file.split('/')[0]
if service not in all_services or not file.endswith(('.yaml', '.yml')):
owners = {service for service in all_services if file.startswith(service + '/')}
if not owners or not file.endswith(('.yaml', '.yml')):
continue
text = (repo / file).read_text()
if any(
image in text and previous.get('images', {}).get(image) != digest
for image, digest in release['images'].items()
):
changed.add(service)
changed |= owners
removed = sorted(
set(previous.get('active', {}).get('k8s', []) + previous.get('active', {}).get('compose', []))
- all_services