Files
homelab-upgrade/tools/compose-env-compat.py

104 lines
4.1 KiB
Python

#!/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()