"""Offline current-source image preparation and disposable Linux PDF qualification. Build and run are explicit, separate commands. No dependency download or production service. """ import argparse import datetime import hashlib import http.cookiejar import json import os from pathlib import Path import re import socket import subprocess import sys import time import urllib.request import uuid ROOT = Path(__file__).resolve().parents[2] BASE = 'sha256:0d1cb44ba8f71d54de77459915a7052b894191cfeb75aa32f0c1e7ffdc21fda6' PIN = 'mariadb:12.2.2@sha256:42a27baaba3e8f3f0159d16381d5a7156661aa91dcc65f663cabb6a6f9503c00' APP = '/app/cv-ux-current' CONFIG = '../docs/cv-redesign/linux/playwright.config.ts' PASSWORD = 'jt-integration-only' AUTH_PASSWORD = 'E2ePassword123!' JWT = 'e2e-only-key-at-least-thirty-two-characters-long' def utc(): return datetime.datetime.now(datetime.timezone.utc).isoformat() def scrub(value): text = str(value) for secret in (PASSWORD, AUTH_PASSWORD, JWT): text = text.replace(secret, '[synthetic-test-secret]') return text def digest(value): return hashlib.sha256(value).hexdigest() def identity(files): return digest(json.dumps(files, sort_keys=True, separators=(',', ':')).encode()) def write(path, value): path.write_text(json.dumps(value, indent=2) + '\n', encoding='utf-8') def fresh(path): path = Path(path).resolve() allowed = (ROOT / '.artifacts/cv-ux-01').resolve() if not path.is_relative_to(allowed) or path == allowed or path.exists(): raise ValueError('Select a nonexistent child of .artifacts/cv-ux-01; previous attempts are immutable.') path.mkdir(parents=True) return path class Commands: def __init__(self, out): self.out = out self.records = [] def run(self, args, *, env=None, cwd=ROOT, check=True, timeout=600): item = {'startedUtc': utc(), 'argv': [scrub(a) for a in args], 'cwd': str(cwd)} self.records.append(item) try: result = subprocess.run(['rtk', 'proxy', *map(str, args)], cwd=cwd, env=env, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout) item['exitCode'] = result.returncode output = result.stdout + result.stderr log = self.out / f'command-{len(self.records):03}.log' log.write_text(scrub(output), encoding='utf-8') item['log'] = str(log) item['logSha256'] = digest(log.read_bytes()) if check and result.returncode: raise RuntimeError(f'Command {len(self.records)} failed with exit {result.returncode}; inspect {log.name}') return result.stdout.strip() finally: item['finishedUtc'] = utc() write(self.out / 'commands.json', self.records) def sources(commands): paths = commands.run(['git', '-c', 'core.quotePath=false', 'ls-files', '--cached', '--others', '--exclude-standard']).splitlines() prefixes = ('JobTrackerApi/', 'job-tracker-ui/src/', 'job-tracker-ui/e2e/') exact = {'global.json', '.dockerignore', 'docker-compose.database.yml', 'NuGet.Config', 'nuget.config', 'job-tracker-ui/package.json', 'job-tracker-ui/package-lock.json', 'scripts/verify-cv-fidelity.py', 'docs/redesign/phase-9/p9-requal-01/pdf-proof.py'} selected = [name for name in paths if name.startswith(prefixes) or name in exact or name.startswith(('Directory.', 'job-tracker-ui/playwright', 'job-tracker-ui/scripts/')) or name.startswith(('docs/cv-redesign/', 'docs/pre-phase10-ux/')) and name.endswith(('.py', '.ts', '.json'))] return {name: digest((ROOT / name).read_bytes()) for name in sorted(set(selected)) if (ROOT / name).is_file()} def tree(path): return {file.relative_to(path).as_posix(): digest(file.read_bytes()) for file in sorted(path.rglob('*')) if file.is_file()} def image_id(value): if not re.fullmatch(r'sha256:[a-f0-9]{64}', value): raise ValueError('An immutable local image ID is required.') return value def schema_name(value): if not re.fullmatch(r'jt_it_[a-f0-9]{32}', value): raise ValueError('Only a generated disposable schema is allowed.') return value def guard_db(data, name, project, port): if not re.fullmatch(r'jobtracker-it-[a-f0-9]{32}', project) or name != project + '-mariadb-test-1': raise ValueError('Unexpected generated DB owner.') labels = data.get('Config', {}).get('Labels', {}) host = data.get('HostConfig', {}) if (data.get('Name') != '/' + name or data['Config']['Image'] != PIN or labels.get('com.docker.compose.project') != project or labels.get('com.docker.compose.service') != 'mariadb-test' or not data['State']['Running'] or data['State'].get('Health', {}).get('Status') != 'healthy' or host.get('Privileged') or host.get('NetworkMode') == 'host' or '/var/lib/mysql' not in host.get('Tmpfs', {}) or host.get('PortBindings', {}).get('3306/tcp') != [{'HostIp': '127.0.0.1', 'HostPort': str(port)}] or any(mount.get('Destination') == '/var/lib/mysql' and mount.get('Type') != 'tmpfs' for mount in data.get('Mounts', []))): raise ValueError('DB must be the exact healthy pinned loopback tmpfs test service.') def verify_build(record, expected_image, current_sources): if (record.get('exitCode') != 0 or record.get('baseImageId') != BASE or record.get('imageId') != image_id(expected_image) or record.get('applicationPath') != APP or record.get('sourceSha256') != identity(current_sources) or record.get('sources') != current_sources): raise ValueError('Current source/build/image identity differs; rebuild in a fresh root.') def build(args): out = fresh(args.output) commands = Commands(out) record = {'startedUtc': utc(), 'exitCode': 1, 'baseImageId': BASE, 'applicationPath': APP} try: record['sources'] = sources(commands) record['sourceSha256'] = identity(record['sources']) record['commit'] = commands.run(['git', 'rev-parse', 'HEAD']) record['status'] = commands.run(['git', 'status', '--short']) record['dotnetInfo'] = commands.run([args.dotnet, '--info']) assert commands.run(['docker', 'image', 'inspect', '--format', '{{.Id}}', BASE]) == BASE record['baseMetadata'] = json.loads(commands.run(['docker', 'image', 'inspect', '--format', '{{json .Config.Labels}}', BASE])) native_tag = 'jobtracker-cv-ux-native:' + uuid.uuid4().hex commands.run(['docker', 'tag', BASE, native_tag]) assert commands.run(['docker', 'image', 'inspect', '--format', '{{.Id}}', native_tag]) == BASE record['baseImageTag'] = native_tag publish = out / 'publish' env = {**os.environ, 'DOTNET_ROOT': str(Path(args.dotnet).parent)} record['publishEnvironment'] = {'DOTNET_ROOT': env['DOTNET_ROOT'], 'NuGetAudit': env.get('NuGetAudit')} commands.run([args.dotnet, 'publish', 'JobTrackerApi/JobTrackerApi.csproj', '--configuration', 'Release', '--no-restore', '--output', str(publish), '/p:UseAppHost=false', '--self-contained', 'false'], env=env) record['publish'] = tree(publish) record['publishSha256'] = identity(record['publish']) if 'JobTrackerApi.dll' not in record['publish'] or 'JobTrackerApi.deps.json' not in record['publish']: raise ValueError('Portable current API output is incomplete.') dockerfile = out / 'Dockerfile' dockerfile.write_text(f'FROM {native_tag}\nWORKDIR {APP}\nCOPY publish/ ./\n' + f'LABEL io.jobjakt.cv-ux.source-sha256="{record["sourceSha256"]}" ' + f'io.jobjakt.cv-ux.publish-sha256="{record["publishSha256"]}"\n' + 'ENTRYPOINT ["dotnet","JobTrackerApi.dll"]\n', encoding='utf-8') record['dockerfileSha256'] = digest(dockerfile.read_bytes()) tag = 'jobtracker-cv-ux-api:' + uuid.uuid4().hex commands.run(['docker', 'build', '--network', 'none', '--pull=false', '--file', str(dockerfile), '--tag', tag, str(out)]) record['imageTag'] = tag record['imageId'] = image_id(commands.run(['docker', 'image', 'inspect', '--format', '{{.Id}}', tag])) if record['sources'] != sources(commands) or record['publish'] != tree(publish): raise ValueError('Source or publish bytes changed during build.') record['exitCode'] = 0 except Exception as error: record['error'] = scrub(error) finally: record['finishedUtc'] = utc() write(out / 'build.json', record) print(json.dumps({'exitCode': record['exitCode'], 'record': str(out / 'build.json')})) return record['exitCode'] def free_port(port=0): with socket.socket() as probe: probe.bind(('127.0.0.1', port)) return probe.getsockname()[1] def auth_identity(version, commit): opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) request = urllib.request.Request('http://localhost:5302/api/auth/login', data=json.dumps({'email': 'e2e@example.test', 'password': AUTH_PASSWORD}).encode(), headers={'Content-Type': 'application/json', 'Origin': 'http://localhost:3300'}) with opener.open(request, timeout=10) as response: response.read() with opener.open('http://localhost:5302/api/auth/me', timeout=10) as response: me = json.load(response) if me.get('appVersion') != version or me.get('appCommitSha') != commit: raise ValueError('Authenticated API build metadata mismatch.') return {'appVersion': me['appVersion'], 'appCommitSha': me['appCommitSha']} def run(args): out = fresh(args.output) commands = Commands(out) record = {'startedUtc': utc(), 'exitCode': 1, 'cleanup': {}} suffix = uuid.uuid4().hex project = 'jobtracker-it-' + suffix db = project + '-mariadb-test-1' network = 'jobtracker-cv-ux-' + suffix api = network + '-api' schema = schema_name('jt_it_' + uuid.uuid4().hex) compose = ['docker', 'compose', '--project-name', project, '--project-directory', str(ROOT), '-f', str(ROOT / 'docker-compose.database.yml'), '--profile', 'integration'] db_attempted = created = attached = network_created = api_created = False def sql(query): return commands.run(['docker', 'exec', '-e', 'MYSQL_PWD=' + PASSWORD, db, 'mariadb', '-uroot', '--batch', '--skip-column-names', '-e', query]) def clean(key, action): try: action() record['cleanup'][key] = True except Exception as error: record['cleanup'][key + 'Error'] = scrub(error) record['exitCode'] = 1 try: build_record = json.loads(Path(args.build_record).read_text(encoding='utf-8')) current_sources = sources(commands) verify_build(build_record, args.image, current_sources) publish = Path(args.build_record).resolve().parent / 'publish' if tree(publish) != build_record['publish']: raise ValueError('Published API output drifted after build.') static = ROOT / 'job-tracker-ui/out' record['frontend'] = tree(static) if not record['frontend']: raise ValueError('Prebuilt frontend output is required.') if not any(b'http://localhost:5302/api' in file.read_bytes() for file in static.rglob('*.js')): raise ValueError('Build the static frontend with NEXT_PUBLIC_API_BASE_URL=http://localhost:5302/api; serving-time environment cannot change compiled client routing.') record.update({'buildRecord': str(Path(args.build_record).resolve()), 'buildRecordSha256': digest(Path(args.build_record).read_bytes()), 'sourceSha256': build_record['sourceSha256'], 'imageId': args.image, 'commit': build_record['commit'], 'schema': schema, 'dbContainer': db, 'dbProject': project, 'apiContainer': api, 'network': network}) image = json.loads(commands.run(['docker', 'image', 'inspect', args.image]))[0] if (image['Id'] != args.image or image['Config']['WorkingDir'] != APP or image['Config'].get('Labels', {}).get('io.jobjakt.cv-ux.source-sha256') != record['sourceSha256'] or image['Config'].get('Labels', {}).get('io.jobjakt.cv-ux.publish-sha256') != build_record['publishSha256']): raise ValueError('Image source/publish labels or current app directory mismatch.') free_port(5302) free_port(3300) record['chromium'] = commands.run(['docker', 'run', '--rm', '--network', 'none', '--entrypoint', '/usr/bin/chromium', args.image, '--version']) record['runtime'] = commands.run(['docker', 'run', '--rm', '--network', 'none', '--entrypoint', 'dotnet', args.image, '--info']) record['apiDllSha256'] = commands.run(['docker', 'run', '--rm', '--network', 'none', '--entrypoint', 'sha256sum', args.image, APP + '/JobTrackerApi.dll']).split()[0] if record['apiDllSha256'] != build_record['publish']['JobTrackerApi.dll']: raise ValueError('Image API DLL differs from current publish bytes.') port = free_port() record['dbPort'] = port environment = {**os.environ, 'MARIADB_TEST_PORT': str(port)} assert commands.run(['docker', 'image', 'inspect', '--format', '{{.Id}}', PIN]) db_attempted = True commands.run(compose + ['up', '--pull', 'never', '-d', '--wait', '--wait-timeout', '120', 'mariadb-test'], env=environment) guard_db(json.loads(commands.run(['docker', 'inspect', db]))[0], db, project, port) record['serverVersion'] = sql('SELECT VERSION()') if not record['serverVersion'].startswith('12.2.2-MariaDB'): raise ValueError('Exact MariaDB 12.2.2 is required.') assert sql(f"SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME='{schema}'") == '0' sql(f'CREATE DATABASE `{schema}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci') created = True # A regular owned bridge permits Docker's loopback-only published API port. commands.run(['docker', 'network', 'create', '--label', 'task=cv-ux-01', network]) network_created = True commands.run(['docker', 'network', 'connect', '--alias', 'mariadb', network, db]) attached = True version = 'cv-ux-linux-' + record['sourceSha256'][:12] variables = {'ASPNETCORE_ENVIRONMENT': 'Development', 'Database__Provider': 'mariadb', 'Database__ServerVersion': '12.2.2', 'ConnectionStrings__JobTracker': f'Server=mariadb;Database={schema};User ID=root;Password={PASSWORD};SslMode=None', 'Auth__Require': 'true', 'Auth__JwtKey': JWT, 'Auth__AdminEmail': 'e2e@example.test', 'Auth__AdminPassword': AUTH_PASSWORD, 'Auth__AllowRegistration': 'false', 'RateLimits__AuthLoginPermitLimit': '1000', 'Cors__Origins__0': 'http://localhost:3300', 'Workers__AiOperationsEnabled': 'false', 'Workers__SavedJobSearchAlertsEnabled': 'false', 'HttpsRedirection__Enabled': 'false', 'App__Version': version, 'App__CommitSha': record['commit'], 'Data__Root': '/data', 'Logging__LogLevel__Default': 'Warning'} create = ['docker', 'create', '--name', api, '--label', 'task=cv-ux-01', '--network', network, '-p', '127.0.0.1:5302:8080', '--tmpfs', '/data:rw,nosuid,size=512m'] for key, value in variables.items(): create += ['-e', key + '=' + value] api_created = True commands.run(create + [args.image]) assert commands.run(['docker', 'inspect', '--format', '{{.Image}}', api]) == args.image commands.run(['docker', 'start', api]) bindings = json.loads(commands.run(['docker', 'inspect', '--format', '{{json .NetworkSettings.Ports}}', api])) if bindings.get('8080/tcp') != [{'HostIp': '127.0.0.1', 'HostPort': '5302'}]: raise ValueError('Owned API loopback port was not published.') deadline = time.monotonic() + 120 while True: try: with urllib.request.urlopen('http://localhost:5302/health', timeout=3) as response: health = json.load(response) if health.get('version') != version: raise ValueError('Linux API health version mismatch.') with urllib.request.urlopen('http://localhost:5302/ready', timeout=3) as response: assert json.load(response)['status'] == 'ready' record['health'] = health record['apiBuildMetadata'] = auth_identity(version, record['commit']) break except Exception: if time.monotonic() >= deadline: raise time.sleep(1) migration_sql = f'SELECT MigrationId FROM `{schema}`.`__EFMigrationsHistory` ORDER BY MigrationId' record['migrations'] = sql(migration_sql).splitlines() if '20261025090000_AddCoverLetterDocumentDesign' not in record['migrations']: raise ValueError('Current letter design migration was not applied.') env = {**os.environ, 'CV_UX_LINUX_READY': version, 'CV_UX_LINUX_SOURCE': record['sourceSha256'], 'PLAYWRIGHT_STATIC_EXPORT': 'true', 'PLAYWRIGHT_PREBUILT_EXPORT': 'true', 'PLAYWRIGHT_REUSE_SERVERS': 'false', 'NEXT_PUBLIC_API_BASE_URL': 'http://localhost:5302/api', 'CV_FIDELITY_OUTPUT': str(out / 'fidelity'), 'CV_SETTINGS_FIDELITY_OUTPUT': str(out / 'settings-fidelity'), 'LETTER_FIDELITY_OUTPUT': str(out / 'letter-fidelity'), 'PLAYWRIGHT_JSON_OUTPUT_NAME': str(out / 'linux-renderer.json')} config = CONFIG if args.include_template_library: config = '../docs/pre-phase10-ux/linux-template.config.ts' env.update({'PRE_P10_DESIGN_VERSION': '2', 'CV_TEMPLATE_OUTPUT': str(out / 'template-library'), 'LETTER_LIBRARY_OUTPUT': str(out / 'letter-library')}) commands.run(['node', 'node_modules/@playwright/test/cli.js', 'test', '--config=' + config, '--retries=0', '--reporter=list,json', '--output=' + str(out / 'test-results')], cwd=ROOT / 'job-tracker-ui', env=env, timeout=1800) native = json.loads((out / 'linux-renderer.json').read_text(encoding='utf-8')) stats = native.get('stats', {}) expected_cases = 8 if args.include_template_library else 6 if stats.get('expected') != expected_cases or any(stats.get(key) != 0 for key in ('unexpected', 'flaky', 'skipped')) or native.get('errors'): raise ValueError(f'Expected {expected_cases} passing browser cases with no skips, retries or errors.') record['browserStats'] = stats if sql(migration_sql).splitlines() != record['migrations'] or sources(commands) != current_sources or tree(static) != record['frontend']: raise ValueError('Source/runtime/migration drift during rendering.') for folder in ('fidelity', 'letter-fidelity'): if not (out / folder / 'measurements.json').is_file(): raise ValueError('Native corpus measurements missing: ' + folder) cv = json.loads((out / 'fidelity/measurements.json').read_text(encoding='utf-8')) letter = json.loads((out / 'letter-fidelity/measurements.json').read_text(encoding='utf-8')) settings_files = list((out / 'settings-fidelity').glob('*-measurements.json')) if len(cv.get('fixtures', [])) != 16 or len(settings_files) != 4 or sum(len(json.loads(file.read_text(encoding='utf-8')).get('fixtures', [])) for file in settings_files) != 8: raise ValueError('The original CV16 + settings8 document membership is incomplete.') if {fixture.get('fixture') for fixture in letter.get('fixtures', [])} != {'light-en-a4', 'dark-nb-a4', 'light-nb-letter', 'dark-en-letter'}: raise ValueError('The independent letter4 document membership is incomplete.') if args.include_template_library: for folder, count in (('template-library', 36), ('letter-library', 18)): if len(json.loads((out / folder / 'measurements.json').read_text(encoding='utf-8')).get('fixtures', [])) != count: raise ValueError('Incomplete revised design corpus: ' + folder) record['exitCode'] = 0 except Exception as error: record['error'] = scrub(error) finally: if api_created: clean('apiLogsCaptured', lambda: commands.run(['docker', 'logs', api])) clean('apiRemoved', lambda: commands.run(['docker', 'rm', '--force', api])) if attached: clean('databaseDisconnected', lambda: commands.run(['docker', 'network', 'disconnect', network, db])) if network_created: clean('networkRemoved', lambda: commands.run(['docker', 'network', 'rm', network])) if created and (not api_created or record['cleanup'].get('apiRemoved')): def remove_schema(): sql(f'DROP DATABASE `{schema_name(schema)}`') assert sql(f"SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME='{schema}'") == '0' clean('schemaRemoved', remove_schema) elif created: record['cleanup']['schemaRemovedError'] = 'API removal unconfirmed; schema retained.' record['exitCode'] = 1 if db_attempted: clean('dbLogsCaptured', lambda: commands.run(compose + ['logs', '--no-color', 'mariadb-test'])) clean('databaseRemoved', lambda: commands.run(compose + ['rm', '--stop', '--force', 'mariadb-test'])) record['finishedUtc'] = utc() write(out / 'process.json', record) print(json.dumps({'exitCode': record['exitCode'], 'record': str(out / 'process.json')})) return record['exitCode'] def main(): parser = argparse.ArgumentParser(description=__doc__) modes = parser.add_subparsers(dest='mode', required=True) image = modes.add_parser('build') image.add_argument('--output', required=True) image.add_argument('--dotnet', default='C:/Users/Connor/.dotnet/dotnet.exe') renderer = modes.add_parser('run') renderer.add_argument('--output', required=True) renderer.add_argument('--build-record', required=True) renderer.add_argument('--image', required=True) renderer.add_argument('--include-template-library', action='store_true', help='Also render the separate PRE-P10 revised CV and matching-letter corpora.') args = parser.parse_args() return build(args) if args.mode == 'build' else run(args) if __name__ == '__main__': raise SystemExit(main())