Compare commits

..
Author SHA1 Message Date
forust ef01abe42d Merge pull request 'Remove EDU ownership and add image build matrix' (#105) from feat/edu-handoff-matrix into codex/ci-visible-checks
ci / Dockerfiles (push) Skipped
ci / Compose (push) Skipped
ci / Workflows (push) Skipped
ci / Shell (push) Skipped
ci / Formatting (push) Skipped
ci / Python and tests (push) Skipped
ci / Kubernetes (push) Skipped
ci / Compose (pull_request) Successful in 13s
ci / YAML (push) Skipped
ci / Workflows (pull_request) Successful in 8s
ci / Python and tests (pull_request) Successful in 6s
ci / Shell (pull_request) Successful in 27s
ci / Formatting (pull_request) Successful in 26s
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped
ci / YAML (pull_request) Successful in 11s
ci / Dockerfiles (pull_request) Successful in 5s
ci / Kubernetes (pull_request) Successful in 7s
ci / image-plan (pull_request) Skipped
renovate-ci / validate-renovate (pull_request) Successful in 9s
Reviewed-on: #105
2026-10-07 11:45:40 +00:00
31 changed files with 94 additions and 633 deletions

No files matched your search

+30 -38
View File
@@ -1,50 +1,42 @@
# EDU ownership handoff
## Status
## Current status
The EDU ownership handoff is complete. The homelab repository no longer owns
EDU workloads, images, routes, alerts, or deployment selection. The EDU
repository is the only deployment owner: [forust/edu-master](https://git.forust.xyz/forust/edu-master).
EDU PR #1 merged at 2026-10-07 08:04:30 UTC. Main release `5094952464ce315130839303985fd04d721bc1f2` passed CI run 1585 and deploy run 1586. The workstation checkout `/srv/edu-master` is at that SHA. The release changed the application image digests:
Homelab PRs #99 and #105 are merged. PR #105 removed the EDU subtree and its
build, deploy, rollback, verification, route-probe, and registry references.
It also added the serial image build matrix for the homelab services. This
handoff record is the only remaining EDU-specific file in homelab Git.
- Session keeper: `sha256:1e59473bd40fe4c22622017d808a8927a68788275fe073dc23d718c44b2fd5dd`
- Webinar checker: `sha256:987d9bf0770272766523ea5b94c7f3f849175d737551d46591ae55e058cf9f12`
The dedicated workstation checkout is `/srv/edu-master`, at release
`4f2b2a0e37dc11ac2c75441a15076c178e219d37`. It contains `k8s/active`; root
`active` is absent. The old untracked `/srv/homelab/edu_master` checkout was
moved outside the homelab repository to
`/srv/edu-master-legacy-archive-20261007/edu_master`. Its private files remain
mode `0600` inside an archive directory with mode `0700`. The homelab deploy
checkout has no EDU marker or tracked EDU application/deployment files.
`AUTODEPLOY=false` remains in place for homelab deployment.
The live workloads remain healthy in context `Default`, namespace `edu-master`. Both health and live probes return 200. Redis AUTH passes, session TTL is 1178 seconds, the delivery backlog is zero, all nine EDU alert rules are healthy, and the scrape target is UP. The unauthorized-pod Redis check passed. The Redis PVC UID and Secret UID and values, including the Fernet key, match their pre-release state.
## Release evidence
The homelab EDU active marker was present after the EDU deployment. It was moved to the private snapshot as `homelab-k8s-active.marker` while holding `/tmp/homelab-apply.lock`. The homelab checkout at `/srv/homelab` is at `5f9354b` and has the tracked marker deletion. Its deploy preflight blocks a dirty checkout until this removal is reconciled. Preserve private ignored configuration when syncing that checkout.
EDU PR #4 merged after its review and CI checks. Main-push CI run 1652 passed
all validation and both image builds. Deploy run 1653 passed for the exact main
SHA above.
The remaining homelab change is PR #105, branch `feat/edu-handoff-matrix`, based on `codex/ci-visible-checks`. Its eight protected checks passed. Renovate runs 1587 and 1588 passed. Image publishing was skipped for the PR. The EDU runtime changes are in PR #3 from `fix/handoff-runtime` to `main`; CI run 1589 is in progress. Those runtime changes have not been released.
The workstation rollout completed for both Deployments. The deployment
verified `/health` and `/live` with HTTP 200, Redis AUTH, session TTL of 1058
seconds, a delivery backlog of zero, and all nine EDU vmalert rules with
matching expressions and healthy evaluation.
## Approval gate and next steps
The images now run by digest:
PR #99 must merge before PR #105 can target `main`. A merge attempt for PR #99 returned HTTP 405 because it needs one approval; the protected branch has `required_approvals=1` and whitelist approval is enabled. This approval gate prevents the remaining transfer steps.
- Session keeper: `sha256:998dea51aa3015fd9cabefb0f53b030157a650c3bef72e02fe84f17d5762613d`
- Webinar checker: `sha256:92f3c1fa2bb7f9b4680a9fc76a5b33dfbea8ef3dd9c6490ebc45876fd4c54461`
After the required approval:
Redis StatefulSet was unchanged. PVC `redis-data-pvc` remains bound to PV
`pvc-a4f2a79a-363a-4c12-ae91-92cdfc2a0d2e` with capacity 1 GiB. The existing
runtime Secret and Fernet key were preserved during the handoff. Notification
delivery was verified before closeout, as confirmed by the operator. The
deployment did not record downtime.
1. Merge PR #99.
2. Retarget PR #105 to `main`. Complete CI and review, then approve and merge it.
3. Under the homelab apply lock, sync `/srv/homelab` to the merged removal. Preserve private ignored configuration and keep the active marker removed. Confirm the deploy preflight is clean.
4. Merge the EDU runtime PR after its CI and review pass. The main-push CI run must complete successfully before its exact SHA can deploy.
5. Verify the new release SHA, image digests, workload health, Redis AUTH and TTL, backlog, PVC and Secret identity, and monitoring. Record the results in the EDU PR.
The release rollback snapshot is
`/home/forust/.local/state/edu-master-deploy/20261007T180541Z-4f2b2a0e37dc11ac2c75441a15076c178e219d37`.
The handoff data snapshot remains at
`/home/forust/.local/state/edu-master-deploy/handoff-20261007T080838Z`.
Both snapshots are outside Git. Do not restore old Redis data unless recovery
requires it. Never delete or recreate the Redis PVC.
`AUTODEPLOY=false` is explicitly configured. The EDU repository path and port secrets are confirmed, and `EDU_KUBE_CONTEXT=Default` is configured as a repository variable. Keep deployment and registry credentials outside Git. Never run both homelab and EDU deployment paths at the same time.
## Change summary
The homelab PR removes the EDU subtree, deployment and image selection, rollback and verification cases, route probes, Renovate references, and external-image exceptions. It adds a serial dynamic matrix for the three homelab images. Each job builds an image or reuses a matching immutable digest. The final job checks all image results and publishes full-SHA tags and the existing release artifact only after they pass. PRs do not publish images. The protected check names from PR #99 are preserved. PR #100's service-metrics work is independent of this handoff.
The EDU runtime PR adds the Playwright service manifest, reconciles Redis storage and Secret reload annotations, and adds pre-apply Redis backup and identity checks. It verifies application endpoints, Redis AUTH, session TTL, metrics, and all nine vmalert rules. Rollback checks workload and application health and reports when manual recovery is needed. Its deployment guard rejects an unexpected or dirty checkout and refuses deployment while either legacy homelab EDU marker exists.
## Rollback and limits
The private snapshot is `/home/forust/.local/state/edu-master-deploy/handoff-20261007T080838Z` on the workstation. It contains the pre-handoff Redis RDB and recovery data. RDB checksum verification confirmed twelve keys. Keep the snapshot outside Git. For an EDU release failure, restore the saved Kubernetes resources and inspect application health. The rollback does not automatically restore the Redis RDB; restore old Redis data only when recovery requires it.
For an ownership rollback, stop EDU deployment triggers first, restore the reviewed homelab source and marker, then reapply recorded immutable image digests. Verify both workload and application health. Never delete or recreate the Redis PVC.
The initial EDU release and the homelab marker move are complete. PR #99 approval and merge, PR #105 retarget and merge, homelab checkout reconciliation, EDU runtime PR merge, and release of those runtime changes remain pending. Synthetic Telegram delivery and Alertmanager-to-Telegram notification were not tested.
-1
View File
@@ -7,5 +7,4 @@ self-hosted-runner:
labels:
- arch
- homelab
- homelab-pr
- prod
+6 -57
View File
@@ -1,20 +1,17 @@
# Homelab CI/CD
The native Gitea runners run on **vps**; production runs on **workstation**.
Main-branch checks and image builds use `homelab:host`. Pull request and
non-main checks use `homelab-pr:host` under a separate account without Docker
access. The `homelab-pr` runner is registered at User scope for `forust`, so
any repository under that account can schedule jobs that request this label.
Each runner accepts one job at a time; the build waits for every check to pass.
CI and deploy runs also show a summary with
The native Gitea runner runs on **vps**; production runs on **workstation**.
Compose, workflow, shell, Python, formatting, YAML, Dockerfile and Kubernetes
checks appear as separate jobs. Jobs run on `homelab:host`, one at a time; the
build waits for every check to pass. CI and deploy runs also show a summary with
the release SHA, image build or reuse results, deploy mode, selected services,
and image digests. Failed runs keep a summary of completed image builds, stage
results, apply results, and recorded Kubernetes recovery. The final deploy
summary is in the smoke job; earlier jobs show the state observed at that time.
Apply success is separate from health and recovery. Update the installed
workstation controller with `setup-workstation.sh` when no deploy is running.
No job images or Kubernetes credentials are needed on the VPS. Builds use one
pinned BuildKit helper container. CI and deploy are separate workflows.
No job images or Kubernetes credentials
are needed on the VPS. Builds use one pinned BuildKit helper container. CI and deploy are separate workflows.
## Runner installation
@@ -40,32 +37,6 @@ pushes directly to the registry, and caps retained local cache at 1 GiB with a
2 GiB free-space target. This is not a hard limit on peak build disk usage.
Nothing runs `docker system prune`, removes unrelated images, or deletes volumes.
### Pull request runner
Install the unprivileged host runner on the VPS:
```sh
sudo bash .gitea/runner/setup-pr-runner.sh
```
Get a registration token from the user Actions runner settings. Run the
installer in a terminal. It asks for the token without echoing it, registers the
runner as `homelab-pr` with label `homelab-pr:host`, then enables the service.
The work directory is `/var/lib/gitea-pr-runner`. Confirm that Gitea lists the
runner as User scope before merging the workflow change. An unmatched label can
fall back to the default job image.
Renovate PR validation uses `pull_request_target`, which reads the workflow from
the base branch. It checks out the PR head only after runner selection and runs
that code on `homelab-pr`. Keep this workflow read-only and do not add secrets.
The PR runner has a separate home and tool cache. Do not add it to the `docker`
group or give it access to `/var/run/docker.sock`. It runs repository code from
pull requests, so keep its registration and permissions separate from the
trusted `homelab` runner. This separates users and host permissions, but both
runners still share the VPS kernel and network. Use a disposable VM if PRs from
untrusted external authors must be fully isolated.
## Workstation setup
As the existing SSH deploy user on workstation:
@@ -157,25 +128,3 @@ run first. Restore the runner config/unit from `.before-<timestamp>` backups,
reload systemd and restart the runner. Restore the prior workflows from Git.
Production data and persistent volumes stay where they were. Do not remove run
state or Compose recovery files until recovery is confirmed.
### Compose configuration recovery
Successful deploys save the complete resolved Compose configuration in
`~/.local/state/homelab-deploy/compose-configs/`. These files can contain secrets.
Keep them private and do not commit or upload them.
The next deploy uses this configuration for its recovery file, including old
commands, environment, mounts, ports, and removed services. The recovery command
uses `--remove-orphans` to remove services added by the failed deploy. It does
not restore volume data or reverse database migrations.
On the first run after this update, the controller can use the Compose file
from the previous successful run. If that file is absent, it reads the persistent
checkout and checks its service configuration hashes against existing containers.
A mismatch stops preflight. Restore the previous configuration before retrying.
Update the installed controller with `bash .gitea/runner/setup-workstation.sh`
from the reviewed checkout before using this change.
New namespaces are checked during preflight. Server validation of their resources
runs after namespace creation and before application resources are applied.
Plan mode does not create namespaces. A failed deferred check can leave an empty
namespace; inspect it before removing it.
-8
View File
@@ -1,8 +0,0 @@
runner:
file: /var/lib/gitea-pr-runner/.runner
capacity: 1
timeout: 5h
labels:
- homelab-pr:host
cache:
enabled: false
-27
View File
@@ -1,27 +0,0 @@
[Unit]
Description=Gitea Actions untrusted pull request runner
After=network-online.target
Wants=network-online.target
[Service]
User=gitea-pr-runner
Group=gitea-pr-runner
WorkingDirectory=/var/lib/gitea-pr-runner
Environment=HOME=/var/lib/gitea-pr-runner
Environment=PATH=/var/lib/gitea-pr-runner/.cache/homelab-ci/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/local/bin/gitea-runner daemon --config /etc/gitea-pr-runner/config.yaml
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
UMask=0077
[Install]
WantedBy=multi-user.target
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env bash
# Install a native runner for untrusted PR jobs without Docker access.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[ "$(id -u)" -eq 0 ] || { echo 'Run with sudo on the runner host' >&2; exit 1; }
for tool in cp cut date getent id install runuser systemctl useradd; do
command -v "$tool" >/dev/null || { echo "Install missing prerequisite: $tool" >&2; exit 1; }
done
command -v /usr/local/bin/gitea-runner >/dev/null || {
echo 'Install gitea-runner 3.0.2 at /usr/local/bin/gitea-runner first' >&2
exit 1
}
id gitea-pr-runner >/dev/null 2>&1 || \
useradd --system --create-home --home-dir /var/lib/gitea-pr-runner --shell /usr/bin/bash gitea-pr-runner
runner_home="$(getent passwd gitea-pr-runner | cut -d: -f6)"
[ "$runner_home" = /var/lib/gitea-pr-runner ] || {
echo 'Unexpected PR runner home; inspect the existing service first' >&2
exit 1
}
case " $(id -nG gitea-pr-runner) " in
*' docker '*)
echo 'The PR runner account must not belong to the docker group' >&2
exit 1
;;
esac
install -d -m 0755 /etc/gitea-pr-runner
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
for existing in /etc/gitea-pr-runner/config.yaml /etc/systemd/system/gitea-pr-runner.service; do
[ ! -f "$existing" ] || cp -p "$existing" "$existing.before-$stamp"
done
install -m 0644 "$here/pr-config.yaml" /etc/gitea-pr-runner/config.yaml
install -m 0644 "$here/pr-runner.service" /etc/systemd/system/gitea-pr-runner.service
if [ ! -f /var/lib/gitea-pr-runner/.runner ]; then
read -r -s -p 'Enter the Gitea repository runner registration token: ' runner_token
printf '\n'
[ -n "$runner_token" ] || { echo 'Runner token is required' >&2; exit 1; }
export GITEA_RUNNER_REGISTRATION_TOKEN="$runner_token"
unset runner_token
runuser --preserve-environment -u gitea-pr-runner -- \
/usr/local/bin/gitea-runner register \
--config /etc/gitea-pr-runner/config.yaml \
--instance https://gitea.forust.xyz \
--name homelab-pr \
--labels homelab-pr:host \
--no-interactive
unset GITEA_RUNNER_REGISTRATION_TOKEN
fi
chmod 0600 /var/lib/gitea-pr-runner/.runner
systemctl daemon-reload
systemctl enable --now gitea-pr-runner.service
systemctl restart gitea-pr-runner.service
echo "PR runner ready. Configuration backups: *.before-$stamp"
-41
View File
@@ -92,44 +92,3 @@ if check_referenced_secrets >"$scratch/secrets.log"; then
exit 1
fi
printf '%s\n' 'Deploy validation regressions passed.'
# New declared namespaces defer only their own resources during preflight.
render_selected_resources() {
cat <<'JSON'
{"apiVersion":"v1","kind":"List","items":[
{"apiVersion":"v1","kind":"Namespace","metadata":{"name":"new"}},
{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"new-config","namespace":"new"}},
{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"existing-config","namespace":"default"}}
]}
JSON
}
kubectl() {
case "$1" in
get) printf '%s\n' '{"items":[{"metadata":{"name":"default"}}]}' ;;
apply) cat >"$scratch/server-input.json" ;;
*) return 1 ;;
esac
}
validate_server_resources true
jq -e '.items | length == 2 and all(.metadata.name != "new-config")' "$scratch/server-input.json" >/dev/null
if validate_server_resources false 2>"$scratch/deferred.log"; then
echo 'Post-namespace validation accepted a missing namespace' >&2
exit 1
fi
kubectl() {
case "$1" in
get) printf '%s\n' '{"items":[{"metadata":{"name":"default"}},{"metadata":{"name":"new"}}]}' ;;
apply) cat >"$scratch/server-input.json" ;;
*) return 1 ;;
esac
}
validate_server_resources false
jq -e '.items | length == 3' "$scratch/server-input.json" >/dev/null
render_selected_resources() {
printf '%s\n' '{"items":[{"kind":"ConfigMap","metadata":{"name":"bad","namespace":"undeclared"}}]}'
}
if validate_server_resources true 2>"$scratch/undeclared.log"; then
echo 'Preflight accepted an undeclared missing namespace' >&2
exit 1
fi
printf '%s\n' 'Namespace validation regressions passed.'
+13 -13
View File
@@ -14,7 +14,7 @@ concurrency:
jobs:
compose:
name: Compose
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
runs-on: homelab
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -66,7 +66,7 @@ jobs:
fi
workflows:
name: Workflows
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
runs-on: homelab
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -102,7 +102,7 @@ jobs:
fi
shell:
name: Shell
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
runs-on: homelab
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -146,7 +146,7 @@ jobs:
fi
formatting:
name: Formatting
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
runs-on: homelab
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -194,7 +194,7 @@ jobs:
fi
python:
name: Python and tests
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
runs-on: homelab
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -232,7 +232,7 @@ jobs:
fi
yaml:
name: YAML
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
runs-on: homelab
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -280,7 +280,7 @@ jobs:
fi
dockerfiles:
name: Dockerfiles
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
runs-on: homelab
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -326,7 +326,7 @@ jobs:
fi
kubernetes:
name: Kubernetes
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
runs-on: homelab
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -395,7 +395,7 @@ jobs:
run: python3 .gitea/workflows/release.py prepare --output build-plan.json
- name: Store the image plan
id: artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf
with:
name: build-plan
path: build-plan.json
@@ -435,7 +435,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Download the checked image plan
id: inputs
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
with:
name: build-plan
- name: Build or reuse this image
@@ -447,7 +447,7 @@ jobs:
run: python3 .gitea/workflows/release.py image --image "$IMAGE_NAME" --output image.json
- name: Store the image result
id: artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf
with:
name: image-${{ matrix.name }}
path: image.json
@@ -482,7 +482,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Download all image results
id: inputs
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
with:
path: artifacts
- name: Pin SHA tags and write the complete release
@@ -495,7 +495,7 @@ jobs:
--plan artifacts/build-plan/build-plan.json
- name: Store commit release
id: artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf
with:
name: release-${{ github.sha }}
path: release.json
+3 -74
View File
@@ -42,50 +42,15 @@ 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())
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')
before = json.loads(json.dumps(config))
for service, settings in config['services'].items():
reference = settings.get('image')
nextcloud_aio_master = project == 'nextcloud' and service == 'nextcloud-aio-mastercontainer'
if not reference or settings.get('build'):
raise ValueError(f'{project}/{service}: Compose deploy requires a published image')
image_repo = reference.split('@')[0].rsplit('/', 1)
image_repo[-1] = image_repo[-1].split(':')[0]
image_repo = '/'.join(image_repo)
# Nextcloud AIO validates the mastercontainer image reference and rejects
# a digest. Keep its configured tag so AIO can start and manage its stack.
if nextcloud_aio_master:
pinned = reference
elif image_repo in release['images']:
if image_repo in release['images']:
pinned = image_repo + '@' + release['images'][image_repo]
elif os.environ.get('REFRESH_IMAGES') != 'true' and reference in locks:
pinned = locks[reference]
@@ -93,12 +58,6 @@ 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',
@@ -110,42 +69,12 @@ 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}}'))
actual.add(next((d for d in digests or [] if d.split('@')[0] == image_repo), image_id))
if len(actual) > 1:
raise ValueError(f'{project}/{service}: mixed running images, cannot capture one recovery config')
# AIO also rejects a digest in its recovery config. Preserve its tag in
# both deploy and recovery files.
if nextcloud_aio_master:
before['services'][service]['image'] = reference
else:
before['services'][service]['image'] = next(iter(actual)) if actual else reference
for name, data in (('compose', config), ('compose-before', before)):
folder = directory / name
@@ -156,7 +85,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 --remove-orphans'
f'Recovery: docker compose --project-directory {project_dir} -p {project} -f {directory}/compose-before/{relative.parent.name}.json up -d --pull never'
)
+1 -6
View File
@@ -141,8 +141,7 @@ def make_plan(directory):
planner = load_module('deploy_plan', source / '.gitea/workflows/deploy-plan.py')
request = json.loads((directory / 'request.json').read_text())
previous = json.loads((STATE / 'last-success.json').read_text()) if (STATE / 'last-success.json').exists() else None
# Helm 4 lists every release status by default and removed the --all flag.
helm = json.loads(command('helm', 'list', '-A', '-o', 'json'))
helm = json.loads(command('helm', 'list', '--all', '-A', '-o', 'json'))
plan = planner.make_plan(source, CONFIG_REPO, request['release'], previous, request['mode'], helm)
if request['refresh_images']:
plan['selected']['compose'] = plan['active']['compose']
@@ -165,10 +164,6 @@ 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'
+10 -46
View File
@@ -180,8 +180,7 @@ save_snapshot() {
| select(any(.metadata.ownerReferences[]?; .uid == $w.metadata.uid))
| select($w.kind != "StatefulSet" or .metadata.name == $w.status.currentRevision) | .revision] | max // 0) end)
}]' "$dir/workloads.json" >"$dir/revisions.json" || return 1
# Helm 4 lists every release status by default and removed the --all flag.
releases="$(helm list -A -o json)" || return 1
releases="$(helm list --all -A -o json)" || return 1
for entry in "${HELM_RELEASES[@]}"; do
IFS='|' read -r release _ namespace _ _ _ <<<"$entry"
if ! jq -e --arg r "$release" --arg n "$namespace" \
@@ -571,41 +570,6 @@ 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"
@@ -632,7 +596,15 @@ stage_validate() {
kubectl apply -k "$k" --dry-run=client >/dev/null
done
log "Validate k8s manifests (kubectl dry-run=server)"
validate_server_resources true
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
log "Checking referenced Secrets exist"
echo " (deploy never applies *secret*.yaml; create missing ones manually)"
check_referenced_secrets
@@ -684,14 +656,6 @@ 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 = {service for service in all_services for path in paths if path.startswith(service + '/')}
changed = {path.split('/')[0] for path in paths}
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):
owners = {service for service in all_services if file.startswith(service + '/')}
if not owners or not file.endswith(('.yaml', '.yml')):
service = file.split('/')[0]
if service not in all_services 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 |= owners
changed.add(service)
removed = sorted(
set(previous.get('active', {}).get('k8s', []) + previous.get('active', {}).get('compose', []))
- all_services
+1 -1
View File
@@ -80,7 +80,7 @@ jobs:
apply:
needs: [gate]
runs-on: homelab
timeout-minutes: 120
timeout-minutes: 100
steps:
- name: Checkout checked commit
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+16 -26
View File
@@ -1,9 +1,7 @@
name: renovate-ci
on:
# Read the workflow from the trusted base branch. PR code runs only on the
# unprivileged runner selected below.
pull_request_target:
pull_request:
paths:
- "renovate/**"
- ".gitea/workflows/renovate-ci.yaml"
@@ -28,47 +26,37 @@ permissions:
jobs:
validate-renovate:
runs-on: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
runs-on: homelab
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
# renovate/k8s/cronjob.yaml is the single source of truth for the version.
- name: Resolve the deployed Renovate version
# renovate/k8s/cronjob.yaml is the single source of truth for the image tag,
# so the same version that runs in the cluster is the one validated here.
- name: Resolve the deployed Renovate image
id: image
shell: bash
run: |
set -euo pipefail
image="$(sed -n 's|.*image:[[:space:]]*\(renovate/renovate:[^[:space:]]*\).*|\1|p' \
renovate/k8s/cronjob.yaml | head -1)"
if [[ ! "$image" =~ ^renovate/renovate:([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "::error::expected a pinned renovate/renovate semantic version in renovate/k8s/cronjob.yaml"
if [ -z "$image" ]; then
echo "::error::no renovate/renovate image found in renovate/k8s/cronjob.yaml"
exit 1
fi
version="${BASH_REMATCH[1]}"
echo "using Renovate $version"
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
- name: Prepare pinned validation tools
shell: bash
run: |
set -euo pipefail
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh kubeconform node)"
echo "$tools_dir" >> "$GITHUB_PATH"
echo "using $image"
echo "image=$image" >> "$GITHUB_OUTPUT"
- name: Validate Renovate repository config
shell: bash
env:
RENOVATE_VERSION: ${{ steps.image.outputs.version }}
run: |
set -euo pipefail
npm_cache="$(mktemp -d "${RUNNER_TEMP:-/tmp}/renovate-npm-cache.XXXXXXXX")"
trap 'rm -rf "$npm_cache"' EXIT
NPM_CONFIG_CACHE="$npm_cache" RENOVATE_CONFIG_FILE="$PWD/renovate/renovate.json" \
npm exec --yes --package="renovate@${RENOVATE_VERSION}" -- renovate-config-validator
docker run --rm \
-v "$PWD/renovate:/opt/renovate:ro" \
-e RENOVATE_CONFIG_FILE=/opt/renovate/renovate.json \
"${{ steps.image.outputs.image }}" \
renovate-config-validator /opt/renovate/renovate.json
# The CronJob cannot read the repository, so renovate/k8s/configmap.yaml
# carries an inlined copy of the config. Fail if it no longer matches.
@@ -82,6 +70,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh kubeconform)"
export PATH="$tools_dir:$PATH"
kubeconform \
-strict \
-ignore-missing-schemas \
+5 -11
View File
@@ -32,14 +32,11 @@ concurrency:
jobs:
run-renovate:
if: github.ref == 'refs/heads/main'
runs-on: homelab
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: refs/heads/main
# renovate/k8s/cronjob.yaml is the single source of truth for the image tag.
# Reading it here means this workflow validates and runs the exact version
@@ -51,23 +48,21 @@ jobs:
set -euo pipefail
image="$(sed -n 's|.*image:[[:space:]]*\(renovate/renovate:[^[:space:]]*\).*|\1|p' \
renovate/k8s/cronjob.yaml | head -1)"
if [[ ! "$image" =~ ^renovate/renovate:[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::expected a pinned renovate/renovate semantic version in renovate/k8s/cronjob.yaml"
if [ -z "$image" ]; then
echo "::error::no renovate/renovate image found in renovate/k8s/cronjob.yaml"
exit 1
fi
echo "using $image"
printf 'image=%s\n' "$image" >> "$GITHUB_OUTPUT"
echo "image=$image" >> "$GITHUB_OUTPUT"
- name: Validate Renovate config
shell: bash
env:
RENOVATE_IMAGE: ${{ steps.image.outputs.image }}
run: |
set -euo pipefail
docker run --rm \
-v "$PWD/renovate/renovate.json:/opt/renovate/renovate.json:ro" \
-e RENOVATE_CONFIG_FILE=/opt/renovate/renovate.json \
"$RENOVATE_IMAGE" \
"${{ steps.image.outputs.image }}" \
renovate-config-validator
- name: Run Renovate
@@ -78,7 +73,6 @@ jobs:
RENOVATE_REPOSITORIES: ${{ inputs.repositories }}
RENOVATE_DRY_RUN: ${{ inputs.dry_run && 'full' || '' }}
LOG_LEVEL: ${{ inputs.log_level }}
RENOVATE_IMAGE: ${{ steps.image.outputs.image }}
run: |
set -euo pipefail
@@ -95,4 +89,4 @@ jobs:
-e RENOVATE_CONFIG_FILE=/opt/renovate/renovate.json \
-e RENOVATE_BASE_DIR=/tmp/renovate \
-e LOG_LEVEL="${LOG_LEVEL:-info}" \
"$RENOVATE_IMAGE"
"${{ steps.image.outputs.image }}"
-2
View File
@@ -20,8 +20,6 @@ data:
GITEA__mailer__ENABLED: "false"
GITEA__metrics__ENABLED: "true"
# No code/issue search needed: bleve reindexes the whole issue index on
# every pod restart (cron.rebuild_issue_indexer RUN_AT_START) and hammers
# the rotational disk for an hour. "db" serves issue search from postgres.
-2
View File
@@ -3,8 +3,6 @@ kind: Service
metadata:
name: gitea-service
namespace: gitea
labels:
app: gitea
spec:
selector:
app: gitea
+1 -2
View File
@@ -7,8 +7,7 @@ spec:
entryPoints:
- websecure
routes:
# Metrics are scraped directly through the cluster Service.
- match: (Host(`gitea.forust.xyz`) || Host(`git.forust.xyz`)) && !PathPrefix(`/metrics`)
- match: Host(`gitea.forust.xyz`) || Host(`git.forust.xyz`)
kind: Rule
services:
- name: gitea-service
-16
View File
@@ -1,16 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: gitea
namespace: gitea
labels:
release: prometheus-stack
spec:
selector:
matchLabels:
app: gitea
endpoints:
- port: http
path: /metrics
interval: 30s
scrapeTimeout: 10s
+1 -1
View File
@@ -7,7 +7,7 @@
# # Dev server_url
# server_url: https://hs.dev_internal_domain.internal
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false
noise:
@@ -3,8 +3,6 @@ kind: Service
metadata:
name: headscale-server-external
namespace: headscale
labels:
app: headscale
spec:
ports:
- port: 8080
-18
View File
@@ -1,18 +0,0 @@
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMServiceScrape
metadata:
name: headscale
namespace: headscale
labels:
release: prometheus-stack
spec:
# The external Service has a manually managed EndpointSlice, not Endpoints.
discoveryRole: endpointslice
selector:
matchLabels:
app: headscale
endpoints:
- port: metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
-4
View File
@@ -6,10 +6,6 @@ metadata:
data:
TZ: "Europe/Bratislava"
IMMICH_TELEMETRY_INCLUDE: "all"
IMMICH_API_METRICS_PORT: "8081"
IMMICH_MICROSERVICES_METRICS_PORT: "8082"
# The database in this namespace, not the shared one in the database
# namespace: v3 needs VectorChord, and only the dedicated image carries it.
DB_HOSTNAME: "immich-postgres"
-12
View File
@@ -3,8 +3,6 @@ kind: Service
metadata:
name: immich-service
namespace: immich
labels:
app: immich
spec:
selector:
app: immich
@@ -12,12 +10,6 @@ spec:
- name: http
port: 2283
targetPort: 2283
- name: api-metrics
port: 8081
targetPort: api-metrics
- name: worker-metrics
port: 8082
targetPort: worker-metrics
---
apiVersion: apps/v1
kind: Deployment
@@ -49,10 +41,6 @@ spec:
ports:
- name: http
containerPort: 2283
- name: api-metrics
containerPort: 8081
- name: worker-metrics
containerPort: 8082
volumeMounts:
- name: immich-data
mountPath: /data
-20
View File
@@ -1,20 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: immich
namespace: immich
labels:
release: prometheus-stack
spec:
selector:
matchLabels:
app: immich
endpoints:
- port: api-metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
- port: worker-metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
-9
View File
@@ -3,8 +3,6 @@ kind: Service
metadata:
name: netbird-server-service
namespace: netbird
labels:
app: netbird-server
spec:
selector:
app: netbird-server
@@ -13,10 +11,6 @@ spec:
name: http
targetPort: 80
protocol: TCP
- port: 9090
name: metrics
targetPort: metrics
protocol: TCP
- port: 3478
name: stun
targetPort: 3478
@@ -65,9 +59,6 @@ spec:
- containerPort: 80
name: http
protocol: TCP
- containerPort: 9090
name: metrics
protocol: TCP
- containerPort: 3478
name: stun
protocol: UDP
-16
View File
@@ -1,16 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: netbird-server
namespace: netbird
labels:
release: prometheus-stack
spec:
selector:
matchLabels:
app: netbird-server
endpoints:
- port: metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
-26
View File
@@ -15,29 +15,3 @@ The VictoriaMetrics Operator chart and its CRDs are installed before the
Kubernetes manifests by the normal deploy workflow. On a cluster where the
operator CRDs are not installed yet, CI skips the server-side dry-run of the
`VMAgent` resource; the deploy installs the chart before applying that resource.
## Application metrics
The application ServiceMonitors use a 30s interval and a 10s timeout:
- Headscale: the external Service points to the Compose host on port 19090.
A VMServiceScrape uses EndpointSlice discovery for this manually managed target.
The Compose configuration must bind metrics to `0.0.0.0:9090`.
- NetBird: the combined server exports `/metrics` on port 9090. The existing
`server.metricsPort` setting enables the listener.
- Gitea: `GITEA__metrics__ENABLED` enables `/metrics` on the HTTP port. The public
ingress excludes this path. The monitor uses the internal Service directly.
- Immich: `IMMICH_TELEMETRY_INCLUDE=all` enables API and worker metrics on ports
8081 and 8082. The monitor scrapes both ports on each server replica.
Deploy through the existing CI and deploy workflow. Gitea and Immich reload their
ConfigMap changes through Reloader. Check the VMAgent targets after deployment
and query `up{scraper="victoria",namespace=~"netbird|gitea|immich|headscale"}` in
VictoriaMetrics. All targets should report 1.
For rollback, revert the application metrics changes, run CI, and deploy the
revert. Remove the three application ServiceMonitors and the Headscale VMServiceScrape explicitly: the deployment
workflow applies manifests and does not prune removed resources.
For Headscale rollback, remove its VMServiceScrape and Service label, restore the
previous Compose metrics bind address, and restart only the Headscale service.
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+3 -94
View File
@@ -128,23 +128,6 @@ class SelectionTests(unittest.TestCase):
self.assertEqual(result['selected']['k8s'], ['one'])
self.assertEqual(result['helm'], [])
def test_nested_service_change_and_owned_image_are_selected(self):
directory = self.repo / 'vpn/xui/k8s'
directory.mkdir(parents=True)
(directory / 'active').touch()
image = next(iter(release()['images']))
(directory / 'app.yaml').write_text('image: ' + image + ':main\n')
baseline_sha = self.commit()
baseline = planner.make_plan(self.repo, self.repo, release(baseline_sha), None, 'full', [])
(directory / 'app.yaml').write_text('image: ' + image + ':prod\n')
result = planner.make_plan(self.repo, self.repo, release(self.commit()), baseline, 'changed', [])
self.assertEqual(result['selected']['k8s'], ['vpn/xui'])
baseline = result
updated = release(result['sha'])
updated['images'][image] = 'sha256:' + 'e' * 64
result = planner.make_plan(self.repo, self.repo, updated, baseline, 'changed', [])
self.assertEqual(result['selected']['k8s'], ['vpn/xui'])
def test_failed_intermediate_deploy_does_not_lose_changes(self):
(self.repo / 'one/k8s/app.yaml').write_text('kind: StatefulSet\n')
self.commit() # This commit failed deploy: baseline must remain initial.
@@ -179,8 +162,7 @@ class ComposeConfigurationTests(unittest.TestCase):
source = run / 'source'
config_repo = root / 'persistent'
(source / 'headscale').mkdir(parents=True)
(config_repo / 'headscale').mkdir(parents=True)
(config_repo / 'headscale/compose.yaml').touch()
config_repo.mkdir()
(run / 'release.json').write_text(json.dumps(release()))
old = 'busybox@sha256:' + 'd' * 64
new = 'busybox@sha256:' + 'e' * 64
@@ -198,41 +180,19 @@ class ComposeConfigurationTests(unittest.TestCase):
'volumes': {'data': {'name': 'headscale_data'}},
}
previous_config = json.loads(json.dumps(config))
previous_config['services']['app']['command'] = ['old-command']
previous_config['services']['app']['environment'] = {'VALUE': 'old'}
previous_config['services']['removed'] = {'image': 'busybox:latest'}
config['services']['app']['command'] = ['new-command']
config['services']['app']['environment'] = {'VALUE': 'new'}
config['services']['added'] = {'image': 'busybox:latest'}
def fake_output(*args, **kwargs):
if args[:2] == ('docker', 'compose'):
self.assertEqual(kwargs['cwd'], config_repo)
self.assertIn(str(config_repo / 'headscale'), args)
if '--hash' in args:
return 'app matching-hash'
return json.dumps(
previous_config if str(config_repo / 'headscale/compose.yaml') in args else config
)
return json.dumps(config)
if args[:2] == ('docker', 'ps'):
return 'container'
if args[:2] == ('docker', 'inspect'):
if 'com.docker.compose.config-hash' in args[-1]:
return 'matching-hash'
return 'sha256:' + 'f' * 64
return json.dumps([old])
with (
patch.dict(
os.environ,
{
'CONFIG_REPO': str(config_repo),
'REPO': str(source),
'RUN_DIR': str(run),
'HOMELAB_STATE': str(root / 'state'),
},
),
patch.dict(os.environ, {'CONFIG_REPO': str(config_repo), 'REPO': str(source), 'RUN_DIR': str(run)}),
patch.object(compose_module, 'output', side_effect=fake_output),
patch.object(compose_module, 'resolve', return_value=new),
):
@@ -244,57 +204,6 @@ class ComposeConfigurationTests(unittest.TestCase):
self.assertEqual(pinned['services']['app']['volumes'], config['services']['app']['volumes'])
self.assertEqual(pinned['services']['app']['image'], new)
self.assertEqual(before['services']['app']['image'], old)
self.assertEqual(before['services']['app']['command'], ['old-command'])
self.assertEqual(before['services']['app']['environment'], {'VALUE': 'old'})
self.assertIn('removed', before['services'])
self.assertNotIn('added', before['services'])
def mismatched_output(*args, **kwargs):
if args[:2] == ('docker', 'inspect') and 'com.docker.compose.config-hash' in args[-1]:
return 'different-hash'
return fake_output(*args, **kwargs)
with (
patch.dict(
os.environ,
{
'CONFIG_REPO': str(config_repo),
'REPO': str(source),
'RUN_DIR': str(run),
'HOMELAB_STATE': str(root / 'state'),
},
),
patch.object(compose_module, 'output', side_effect=mismatched_output),
patch.object(compose_module, 'resolve', return_value=new),
self.assertRaisesRegex(ValueError, 'differs from running config'),
):
compose_module.prepare(source / 'headscale/compose.yaml')
state = root / 'state'
with patch.object(controller, 'STATE', state):
state.mkdir()
(run / 'status.json').write_text('{"state": "running", "stages": {}}')
with patch.object(controller, 'retain_completed'):
controller.finish_success(run, {})
self.assertEqual(json.loads((state / 'compose-configs/headscale.json').read_text()), pinned)
# A stale persistent checkout must not replace the successful baseline.
with (
patch.dict(
os.environ,
{
'CONFIG_REPO': str(config_repo),
'REPO': str(source),
'RUN_DIR': str(run),
'HOMELAB_STATE': str(state),
},
),
patch.object(compose_module, 'output', side_effect=fake_output),
patch.object(compose_module, 'resolve', return_value=new),
):
compose_module.prepare(source / 'headscale/compose.yaml')
before = json.loads((run / 'compose-before/headscale.json').read_text())
self.assertEqual(before['services']['app']['command'], ['new-command'])
self.assertIn('added', before['services'])
self.assertNotIn('removed', before['services'])
self.assertEqual((run / 'compose/headscale.json').stat().st_mode & 0o777, 0o600)
def test_registry_index_and_single_image_descriptors(self):