Files
ProjectQuint/Tools/release_configuration.py

100 lines
4.3 KiB
Python

"""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')