"""Machine-specific release configuration, kept outside the public source tree.""" from __future__ import annotations import argparse import json import os import shlex from dataclasses import dataclass from pathlib import Path class ConfigurationError(RuntimeError): pass @dataclass(frozen=True) class ReleaseConfiguration: stable_app: Path releases_root: Path deploy_host: str ssh_key: Path | None remote_source: str def require_deployment(self) -> None: if not self.deploy_host: raise ConfigurationError('Set PROJECTQUINT_DEPLOY_HOST or deploy_host in .release.local.json') if self.deploy_host.startswith('-') or any(character.isspace() for character in self.deploy_host): raise ConfigurationError('The deployment host must be one SSH destination') if self.ssh_key is None or not self.ssh_key.is_file(): raise ConfigurationError('PROJECTQUINT_DEPLOY_KEY must name an existing SSH identity file') # These paths are passed to a remote shell by the deployment tools. if not self.remote_source.startswith('/') or any( character not in '/._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' for character in self.remote_source ): raise ConfigurationError('The remote source must be an absolute path without shell metacharacters') def environment(self) -> dict[str, str]: self.require_deployment() return { 'PROJECTQUINT_STABLE_APP': str(self.stable_app), 'PROJECTQUINT_RELEASES_ROOT': str(self.releases_root), 'PROJECTQUINT_DEPLOY_HOST': self.deploy_host, 'PROJECTQUINT_DEPLOY_KEY': str(self.ssh_key), 'PROJECTQUINT_REMOTE_SOURCE': self.remote_source, } def shell_exports(self) -> str: return '\n'.join(f'export {key}={shlex.quote(value)}' for key, value in self.environment().items()) def load_configuration(root: Path) -> ReleaseConfiguration: path = root / '.release.local.json' values = {} if path.exists(): try: values = json.loads(path.read_text(encoding='utf-8')) except (OSError, ValueError) as error: raise ConfigurationError('Cannot read .release.local.json') from error allowed = {'stable_app', 'releases_root', 'deploy_host', 'ssh_key', 'remote_source'} if not isinstance(values, dict) or values.keys() - allowed: raise ConfigurationError('Unknown release configuration fields') if any(not isinstance(value, str) or not value for value in values.values()): raise ConfigurationError('Release configuration values must be non-empty strings') def value(key: str, environment: str, default: str) -> str: return os.environ.get(environment, values.get(key, default)) def absolute_path(key: str, environment: str, default: Path | None) -> Path | None: raw = value(key, environment, str(default) if default else '') if not raw: return None result = Path(raw) if not result.is_absolute(): raise ConfigurationError(f'{environment} must be an absolute path') return result stable_app = absolute_path('stable_app', 'PROJECTQUINT_STABLE_APP', root / '_workspace/builds/Stable/ProjectQuint.app') releases_root = absolute_path('releases_root', 'PROJECTQUINT_RELEASES_ROOT', root / '_workspace/releases') if stable_app is None or stable_app.suffix != '.app' or releases_root is None: raise ConfigurationError('Release output paths must include an absolute .app path and releases directory') return ReleaseConfiguration( stable_app=stable_app, releases_root=releases_root, deploy_host=value('deploy_host', 'PROJECTQUINT_DEPLOY_HOST', ''), ssh_key=absolute_path('ssh_key', 'PROJECTQUINT_DEPLOY_KEY', None), remote_source=value('remote_source', 'PROJECTQUINT_REMOTE_SOURCE', '/srv/projectquint/site-source'), ) if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--shell', action='store_true', required=True) parser.parse_args() try: print(load_configuration(Path(__file__).resolve().parents[1]).shell_exports()) except ConfigurationError as error: parser.exit(2, f'Release configuration: {error}\n')