"""Host effects for one journalled preview generation; never operate on parent resources.""" import json import shutil from pathlib import Path from app import db, paths from app.services.docker_service import DockerService from app.services.nginx_service import NginxService from app.services.site_domain_service import SiteDomainService from app.services import health_gate from .plan import PreviewError, identity, workspace ROOT = Path(paths.SERVERKIT_DIR) / 'previews' LABEL = 'serverkit.preview' def save(preview, state): preview.set_runtime_state(state) db.session.commit() def inspect(kind, name): result = DockerService.run([kind, 'inspect', name], timeout=30) if result.get('success'): return json.loads(result['output'])[0] error = (result.get('error') or '').lower() if 'no such' in error: return None raise PreviewError('Cannot inspect preview resources; cleanup must be retried.') def start(app, preview, state, inherit_environment=False): name = identity(preview.id, state['token']) container_port = app.port if app.active_slot: from app.models.app_slot import AppSlot slot = AppSlot.query.filter_by(application_id=app.id, slot=app.active_slot).first() if not slot or not slot.container_port: raise PreviewError('The active deployment slot has no recorded container port.') container_port = slot.container_port state['container'] = True save(preview, state) # intent before docker run, even if it partially fails env = {'SERVERKIT_PREVIEW': 'true', 'SERVERKIT_PR_NUMBER': str(preview.pr_number), 'SERVERKIT_PREVIEW_BRANCH': preview.branch} if inherit_environment: from app.services.env_service import EnvService entries, complete = EnvService._effective_entries(app) if not complete: raise PreviewError('Inherited environment is incomplete; preview deployment stopped.') env = {**{key: value['value'] for key, value in entries.items()}, **env} result = DockerService.run_container( image=name + ':preview', name=name, ports=[f'127.0.0.1::{container_port}'], env=env, restart_policy='unless-stopped', labels={LABEL: str(preview.id)}, ) if not result.get('success'): raise PreviewError('Could not start the preview container.') info = inspect('container', name) if not info or info.get('Config', {}).get('Labels', {}).get(LABEL) != str(preview.id): raise PreviewError('Preview container ownership could not be verified.') bindings = info.get('NetworkSettings', {}).get('Ports', {}).get(f'{container_port}/tcp') or [] binding = next((b for b in bindings if b.get('HostIp') == '127.0.0.1'), None) if not binding: raise PreviewError('Preview container has no loopback HTTP port.') state['port'] = int(binding['HostPort']) state['container_id'] = info['Id'] save(preview, state) health_gate.gate_for_app(app, port=state['port'], container=name) info = inspect('container', name) if not info or not info.get('State', {}).get('Running'): raise PreviewError('Preview container stopped during its health check.') def publish(preview, state): name = identity(preview.id, state['token']) cert = key = None base = SiteDomainService.covering_base(preview.domain) # A wildcard certificate covers exactly one label, not pr.app.base. if base and preview.domain.endswith('.' + base) and preview.domain.count('.') == base.count('.') + 1: if SiteDomainService.https_enabled(base): cert, key = SiteDomainService.wildcard_cert_paths(base) state['scheme'] = 'https' if cert and key else 'http' state['vhost'] = True save(preview, state) result = NginxService.create_site(name, 'docker', [preview.domain], port=state['port'], ssl_cert=cert, ssl_key=key) if not result.get('success'): raise PreviewError('Preview routing could not be published.') def cleanup(preview): """Idempotent, fail closed. Retain each intent until its removal succeeds.""" state = preview.get_runtime_state() if not state: if preview.runtime_state and preview.runtime_state != '{}': raise PreviewError('Preview resource ledger is invalid; refusing untracked cleanup.') return name = identity(preview.id, state.get('token', '')) target = workspace(ROOT, preview.id, state['token']) if state.get('vhost'): # delete_site historically ignores disable failures. Check disable first # so nginx cannot continue serving a resource reported as destroyed. if not NginxService.disable_site(name).get('success'): raise PreviewError('Preview routing cleanup failed; retry deletion.') if not NginxService.delete_site(name).get('success'): raise PreviewError('Preview vhost cleanup failed; retry deletion.') state['vhost'] = False save(preview, state) if state.get('container'): info = inspect('container', name) if info: if info.get('Config', {}).get('Labels', {}).get(LABEL) != str(preview.id): raise PreviewError('Refusing to delete a container without preview ownership.') if not DockerService.remove_container(name, force=True, volumes=True).get('success'): raise PreviewError('Preview container cleanup failed; retry deletion.') state['container'] = False preview.set_container_ids([]) save(preview, state) if state.get('image'): if inspect('image', name + ':preview') is not None: if not DockerService.remove_image(name + ':preview', force=False).get('success'): raise PreviewError('Preview image cleanup failed; retry deletion.') state['image'] = False save(preview, state) if state.get('source'): if target.exists(): shutil.rmtree(target) state['source'] = False save(preview, state) preview.set_container_ids([]) save(preview, {})