#!/usr/bin/env python3 """Create a local project from the engineering recipe. Never creates remote repositories.""" import argparse import importlib.util import json from pathlib import Path import re import shutil import subprocess HOME = Path(__file__).resolve().parent def create(destination, name, components, repository, tag, runner, landscape): destination = Path(destination).resolve() if not re.fullmatch('[a-z][a-z0-9-]*', name): raise ValueError('Use a lowercase project name with optional digits and hyphens') if destination.exists(): raise ValueError('Destination must not exist; existing project files are never overwritten') commit = subprocess.check_output(['git', '-C', str(HOME), 'rev-parse', tag + '^{commit}'], text=True).strip() replacements = {'@PROJECT@': name, '@RUNNER@': runner, '@RUNNER_NAME@': runner, '@LANDSCAPE@': landscape, '@ENGINEERING@': repository.removesuffix('.git'), '@MODULES@': 'backend' if 'backend' in components else ''} try: destination.mkdir(parents=True) for part in ['common', *components]: for source in (HOME / 'templates' / part).rglob('*'): if source.is_file(): target = destination / source.relative_to(HOME / 'templates' / part) target.parent.mkdir(parents=True, exist_ok=True) text = source.read_text() for before, after in replacements.items(): text = text.replace(before, after) target.write_text(text) lock = {'repository': repository, 'tag': tag, 'commit': commit} (destination / 'engineering.lock.json').write_text(json.dumps(lock, indent=2) + '\n') (destination / 'scripts').mkdir() shutil.copy2(HOME / 'bootstrap.py', destination / 'scripts/engineering.py') settings = {'PROJECT_NAME': name, 'BACKEND_MODULE': 'backend' if 'backend' in components else '', 'FRONTEND_MODULE': 'frontend' if 'frontend' in components else '', 'FORMAT_DIRECTORIES': 'backend/src', 'COMPOSE_PROJECT': name + '-production', 'BROWSER_TESTS': 'false', 'CI_DATABASE': 'false', 'TARGET_PLATFORM': 'linux/amd64', 'RUNNER_LABEL': runner, 'RUNNER_NAME': runner, 'RUNNER_SCOPE': 'user', 'FRONTEND_TEST_COMMAND': '["npm","test"]', 'DEPENDENCY_SERVICES': '', 'REQUIRED_DEPLOY_SECRETS': ''} (destination / 'engineering.properties').write_text(''.join(k + '=' + v + '\n' for k, v in settings.items())) services = [] published_role = 'frontend' if 'frontend' in components else 'backend' for role in components: services.append(f''' {role}: image: ${{{role.upper()}_IMAGE:?Required verified digest}} platform: linux/amd64 restart: unless-stopped labels: nl.agilecc.release: ${{RELEASE_VERSION:?Required version}} nl.agilecc.revision: ${{RELEASE_SHA:?Required SHA}} ''') if role == published_role: services.append(" ports:\n - '${BIND_IP:?Required private IP}:${FRONTEND_PORT:?Required port}:8080'\n") (destination / 'deploy/compose.yaml').write_text('services:\n' + ''.join(services)) if published_role == 'backend': p = destination / 'deploy/health.sh' p.write_text(p.read_text().replace('$FRONTEND_PORT/', '$FRONTEND_PORT/hello')) spec = importlib.util.spec_from_file_location('bootstrap', HOME / 'bootstrap.py') module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) module.materialize(destination) except BaseException: shutil.rmtree(destination) raise return destination def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('name') parser.add_argument('destination', type=Path) parser.add_argument('--components', choices=['backend', 'frontend', 'both'], default='both') parser.add_argument('--repository', default='http://192.168.2.28:3000/wanjakrah/engineering.git') parser.add_argument('--version', required=True) parser.add_argument('--runner', default='mini-pc', help='Shared Gitea host runner label (default: mini-pc)') parser.add_argument('--landscape', default='http://192.168.2.28:3000/wanjakrah/landscape') args = parser.parse_args() try: path = create(args.destination, args.name, ['backend', 'frontend'] if args.components == 'both' else [args.components], args.repository, args.version, args.runner, args.landscape) print('Created ' + str(path)) except (ValueError, OSError, subprocess.CalledProcessError) as error: parser.exit(1, str(error) + '\n') if __name__ == '__main__': main()