100 lines
4.3 KiB
Python
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')
|