fix(config): spread upgrades and keep Compose env files local

This commit is contained in:
homelab-updater committed 2026-09-30 01:15:37 +02:00
1 parent f7943ab577
commit 8a56de42f8
5 files changed
+250 -19

No files matched your search

+1
View File
@@ -8,3 +8,4 @@ coverage/
.env
.env.*
.claude/settings.local.json
to_delete/
+19 -19
View File
@@ -1,22 +1,22 @@
[
{ "app": "bazarr", "service": "bazarr", "imageRepo": "linuxserver/bazarr", "tagPattern": "^v[0-9.]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://bazarr.mariuszpacek.pl/", "expectStatus": 200 },
{ "app": "deluge", "service": "deluge", "imageRepo": "linuxserver/deluge", "tagPattern": "^[0-9][0-9.]*-ls[0-9]+$", "auto": "patch", "httpUrl": "https://deluge.mariuszpacek.pl/", "expectStatus": 200 },
{ "app": "dockge", "service": "dockge", "imageRepo": "louislam/dockge", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", "auto": "patch", "httpUrl": null, "expectStatus": 200, "notes": "compose pins a digest only" },
{ "app": "gitea", "service": "gitea", "imageRepo": "gitea/gitea", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", "auto": "patch", "httpUrl": "https://git.mariuszpacek.pl/", "expectStatus": 200, "notes": "hosts this pipeline" },
{"app": "gitea-db", "project": "gitea", "enabled": false, "service": "db", "imageRepo": "postgres", "tagPattern": "^14\\.[0-9]+$", "auto": "minor", "httpUrl": null, "expectStatus": 200, "notes": "floating tag 14: pin the running 14.x first"},
{ "app": "grafana", "service": "grafana", "imageRepo": "grafana/grafana", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", "auto": "patch", "httpUrl": null, "expectStatus": 302, "notes": "compose pins a digest only" },
{"app": "grafana-influxdb", "project": "grafana", "service": "influxdb", "imageRepo": "influxdb", "tagPattern": "^1\\.8\\.[0-9]+$", "auto": "patch", "httpUrl": null, "expectStatus": 200},
{ "app": "homeassistant", "enabled": false, "service": "homeassistant", "imageRepo": "homeassistant/home-assistant", "tagPattern": "^[0-9]{4}\\.[0-9]{1,2}\\.[0-9]+$", "auto": "minor", "httpUrl": null, "expectStatus": 200, "notes": "DESIGN: absolute STOP, off" },
{ "app": "homepage", "service": "homepage", "imageRepo": "ghcr.io/gethomepage/homepage", "tagPattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$", "auto": "minor", "httpUrl": "https://dash.mariuszpacek.pl/", "expectStatus": 200 },
{ "app": "jackett", "service": "jackett", "imageRepo": "ghcr.io/linuxserver/jackett", "tagPattern": "^v[0-9.]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://jackett.mariuszpacek.pl/", "expectStatus": 301 },
{ "app": "jellyfin", "service": "jellyfin", "imageRepo": "linuxserver/jellyfin", "tagPattern": "^[0-9.]+ubu[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": null, "expectStatus": 302 },
{ "app": "keycloak", "service": "keycloak", "imageRepo": "quay.io/keycloak/keycloak", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", "auto": "patch", "httpUrl": null, "expectStatus": 200 },
{"app": "bazarr", "service": "bazarr", "imageRepo": "linuxserver/bazarr", "tagPattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://bazarr.mariuszpacek.pl/", "expectStatus": 200},
{"app": "deluge", "service": "deluge", "imageRepo": "linuxserver/deluge", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://deluge.mariuszpacek.pl/", "expectStatus": 200},
{"app": "dockge", "enabled": false, "service": "dockge", "imageRepo": "louislam/dockge", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", "auto": "patch", "httpUrl": null, "expectStatus": 200, "notes": "compose pins a digest only"},
{"app": "gitea", "service": "gitea", "imageRepo": "gitea/gitea", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", "auto": "patch", "httpUrl": "https://git.mariuszpacek.pl/", "expectStatus": 200, "notes": "hosts this pipeline"},
{"app": "gitea-db", "project": "gitea", "enabled": false, "service": "db", "imageRepo": "postgres", "tagPattern": "^14\\.[0-9]+$", "auto": "minor", "httpUrl": null, "expectStatus": 200, "notes": "Pin the running version before enabling updates"},
{"app": "grafana", "service": "grafana", "imageRepo": "grafana/grafana", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", "auto": "patch", "httpUrl": null, "expectStatus": 302, "notes": "compose pins a digest only"},
{"app": "grafana-influxdb", "project": "grafana", "service": "influxdb", "imageRepo": "influxdb", "tagPattern": "^1\\.[0-9]+\\.[0-9]+$", "auto": "patch", "httpUrl": null, "expectStatus": 200, "enabled": false, "notes": "Updates paused until client migration is ready"},
{"app": "homeassistant", "enabled": false, "service": "homeassistant", "imageRepo": "homeassistant/home-assistant", "tagPattern": "^[0-9]{4}\\.(?:[1-9]|1[0-2])\\.[0-9]+$", "auto": "minor", "httpUrl": null, "expectStatus": 200, "notes": "Enable real updates after reviewing integrations"},
{"app": "homepage", "service": "homepage", "imageRepo": "ghcr.io/gethomepage/homepage", "tagPattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$", "auto": "minor", "httpUrl": "https://dash.mariuszpacek.pl/", "expectStatus": 200},
{"app": "jackett", "service": "jackett", "imageRepo": "ghcr.io/linuxserver/jackett", "tagPattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://jackett.mariuszpacek.pl/", "expectStatus": 301},
{"app": "jellyfin", "service": "jellyfin", "imageRepo": "linuxserver/jellyfin", "tagPattern": "^[0-9]+\\.[0-9]+(?:\\.[0-9]+)?ubu[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": null, "expectStatus": 302},
{"app": "keycloak", "service": "keycloak", "imageRepo": "quay.io/keycloak/keycloak", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", "auto": "patch", "httpUrl": null, "expectStatus": 200},
{"app": "keycloak-db", "project": "keycloak", "service": "db", "imageRepo": "postgres", "tagPattern": "^18\\.[0-9]+$", "auto": "minor", "httpUrl": null, "expectStatus": 200, "notes": "major upgrade needs dump and restore"},
{ "app": "nextcloud", "service": "app", "imageRepo": "nextcloud", "tagPattern": "^[0-9.]+-apache$", "auto": "patch", "httpUrl": null, "expectStatus": 200, "notes": "app and cron share the image" },
{"app": "nextcloud-db", "project": "nextcloud", "service": "db", "imageRepo": "mariadb", "tagPattern": "^10\\.6\\.[0-9]+$", "auto": "patch", "httpUrl": null, "expectStatus": 200},
{"app": "nextcloud-redis", "project": "nextcloud", "enabled": false, "service": "redis", "imageRepo": "redis", "tagPattern": "^7\\.[0-9]+\\.[0-9]+-alpine$", "auto": "minor", "httpUrl": null, "expectStatus": 200, "notes": "floating tag 7-alpine: pin the running 7.x first"},
{ "app": "ombi", "service": "ombi", "imageRepo": "linuxserver/ombi", "tagPattern": "^v[0-9.]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://ombi.mariuszpacek.pl/", "expectStatus": 200 },
{ "app": "orcaslicer", "service": "orcaslicer", "imageRepo": "linuxserver/orcaslicer", "tagPattern": "^v[0-9.]+-ls[0-9]+$", "auto": "minor", "httpUrl": "https://orca.mariuszpacek.pl/", "expectStatus": 401 },
{ "app": "radarr", "service": "radarr", "imageRepo": "ghcr.io/linuxserver/radarr", "tagPattern": "^[0-9.]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://radarr.mariuszpacek.pl/", "expectStatus": 302 },
{ "app": "sonarr", "service": "sonarr", "imageRepo": "ghcr.io/linuxserver/sonarr", "tagPattern": "^[0-9.]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://sonarr.mariuszpacek.pl/", "expectStatus": 302 }
{"app": "nextcloud", "service": "app", "imageRepo": "nextcloud", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+-apache$", "auto": "patch", "httpUrl": null, "expectStatus": 200, "notes": "app and cron share the image"},
{"app": "nextcloud-db", "project": "nextcloud", "service": "db", "imageRepo": "mariadb", "tagPattern": "^11\\.8\\.[0-9]+$", "auto": "patch", "httpUrl": null, "expectStatus": 200},
{"app": "nextcloud-redis", "project": "nextcloud", "enabled": false, "service": "redis", "imageRepo": "redis", "tagPattern": "^7\\.[0-9]+\\.[0-9]+-alpine$", "auto": "minor", "httpUrl": null, "expectStatus": 200, "notes": "Pin the running version before enabling updates"},
{"app": "ombi", "service": "ombi", "imageRepo": "linuxserver/ombi", "tagPattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://ombi.mariuszpacek.pl/", "expectStatus": 200},
{"app": "orcaslicer", "service": "orcaslicer", "imageRepo": "linuxserver/orcaslicer", "tagPattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "minor", "httpUrl": "https://orca.mariuszpacek.pl/", "expectStatus": 401},
{"app": "radarr", "service": "radarr", "imageRepo": "ghcr.io/linuxserver/radarr", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://radarr.mariuszpacek.pl/", "expectStatus": 302},
{"app": "sonarr", "service": "sonarr", "imageRepo": "ghcr.io/linuxserver/sonarr", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://sonarr.mariuszpacek.pl/", "expectStatus": 302}
]
+125
View File
@@ -0,0 +1,125 @@
{
"timeZone": "Europe/Warsaw",
"schedules": [
{
"workflowId": "update-bazarr",
"cron": "0 19 * * 1",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-jackett",
"cron": "0 21 * * 1",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-radarr",
"cron": "0 19 * * 2",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-sonarr",
"cron": "0 21 * * 2",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-deluge",
"cron": "0 19 * * 3",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-ombi",
"cron": "0 21 * * 3",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-homepage",
"cron": "0 19 * * 4",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-orcaslicer",
"cron": "0 21 * * 4",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-gitea",
"cron": "0 18 * * 5",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-keycloak",
"cron": "0 20 * * 5",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-keycloak-db",
"cron": "0 22 * * 5",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-homeassistant",
"cron": "0 18 * * 6",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-nextcloud",
"cron": "0 20 * * 6",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-nextcloud-db",
"cron": "0 22 * * 6",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-grafana",
"cron": "0 18 * * 0",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-jellyfin",
"cron": "0 20 * * 0",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-dockge",
"cron": "0 22 * * 0",
"mode": "real",
"enabled": false
},
{
"workflowId": "update-gitea-db",
"cron": "0 19 * * 5",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-nextcloud-redis",
"cron": "0 23 * * 6",
"mode": "real",
"enabled": true
},
{
"workflowId": "update-grafana-influxdb",
"cron": "0 17 * * 6",
"mode": "real",
"enabled": false
}
]
}
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Convert raw env files for Compose implementations without the raw parser.
Values stay local. Compare the complete resolved model before accepting each
change; restore all files on a failure. No containers are started or restarted.
"""
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
def quote_raw_env(text):
lines = []
for line in text.splitlines():
if not line.strip() or line.lstrip().startswith('#'):
lines.append(line)
continue
key, sep, value = line.partition('=')
if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', key.strip()):
raise ValueError('Unsupported raw env key; no values were changed')
if not sep:
lines.append(key.strip())
else:
# dotenv single quotes suppress interpolation and preserve raw quotes.
lines.append(key.strip() + "='" + value.replace("'", "\\'") + "'")
return '\n'.join(lines) + '\n'
def resolve(manifest, metadata=False):
args = ['docker', 'compose', '-f', str(manifest), 'config', '--format', 'json']
if metadata:
args += ['--no-env-resolution', '--no-interpolate']
result = subprocess.run(args, capture_output=True, text=True, timeout=60)
if result.returncode:
raise ValueError('Compose validation failed (output withheld)')
return json.loads(result.stdout)
def convert(manifest, backup_root, apply=False):
text = manifest.read_text()
if not re.search(r'^\s*format:\s*raw\s*$', text, re.M):
return False
metadata = resolve(manifest, True)
env_paths = {Path(env['path']) for service in metadata['services'].values()
for env in service.get('env_file', []) if env.get('format') == 'raw'}
if any(path.parent != manifest.parent for path in env_paths):
raise ValueError('Raw env file outside the project; conversion refused')
before = resolve(manifest)
originals = {path: path.read_bytes() for path in env_paths}
candidate = manifest.parent / '.compose.env-compat.yaml'
if candidate.exists():
raise ValueError('Candidate already exists')
changed = False
try:
for path, raw in originals.items():
path.write_text(quote_raw_env(raw.decode('utf-8')))
candidate.write_text(re.sub(r'^\s*format:\s*raw\s*\n', '\n', text, flags=re.M))
candidate.chmod(0o600)
after = resolve(candidate)
if before != after:
raise ValueError('Resolved model changed; conversion refused')
if apply:
directory = backup_root / manifest.parent.name
directory.mkdir(parents=True, mode=0o700, exist_ok=False)
directory.chmod(0o700)
for path, raw in {manifest: manifest.read_bytes(), **originals}.items():
target = directory / path.name
target.write_bytes(raw)
target.chmod(0o600)
candidate.chmod(manifest.stat().st_mode & 0o777)
os.replace(candidate, manifest)
changed = True
finally:
candidate.unlink(missing_ok=True)
if not changed:
for path, raw in originals.items():
path.write_bytes(raw)
return True
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, default=Path('/opt/homelab/compose'))
parser.add_argument('--backup-root', type=Path)
parser.add_argument('--apply', action='store_true')
args = parser.parse_args()
if args.apply and args.backup_root is None:
parser.error('--apply requires --backup-root')
for manifest in sorted(args.source.glob('*/compose.yaml')):
if manifest.parent.name == 'homelab-upgrade':
continue
try:
if convert(manifest, args.backup_root, args.apply):
print(manifest.parent.name + (': converted and verified' if args.apply else ': verified; files restored'))
except (ValueError, OSError, subprocess.TimeoutExpired):
raise SystemExit(manifest.parent.name + ': conversion failed; values withheld') from None
if __name__ == '__main__':
main()
+2
View File
@@ -73,6 +73,8 @@ if [ "$found" -ne 0 ]; then
fi
git -C "$DEST" init -q -b master
git -C "$DEST" config user.name "${GIT_AUTHOR_NAME:-Homelab Upgrade}"
git -C "$DEST" config user.email "${GIT_AUTHOR_EMAIL:-homelab-upgrade@mariuszpacek.pl}"
git -C "$DEST" add -A
git -C "$DEST" commit -q -m "Import the compose stacks of VM103 from $SOURCE"
git -C "$DEST" remote add origin "$REMOTE"