104 lines
4.1 KiB
Python
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()
|