mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-03 02:26:43 +09:00
130 lines
6.0 KiB
Python
130 lines
6.0 KiB
Python
"""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, {})
|