mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 23:57:58 +09:00
388 lines
19 KiB
Python
388 lines
19 KiB
Python
"""Generic previews: real orchestration with fake external Git/Docker/nginx effects."""
|
|
import json
|
|
from types import SimpleNamespace
|
|
import pytest
|
|
from app import db
|
|
from app.models.application_preview import ApplicationPreview
|
|
from app.services.preview_service import PreviewService
|
|
from app.services.previews import runtime, source
|
|
from app.services.previews.plan import PreviewError, identity, workspace, validate
|
|
from test_previews import _mk_app
|
|
|
|
SHA = 'a' * 40
|
|
PR = {'pr_number': 7, 'branch': 'feature/preview', 'commit_sha': SHA}
|
|
|
|
|
|
@pytest.fixture
|
|
def host(app, monkeypatch, tmp_path):
|
|
from app.services.git_service import GitService
|
|
from app.services.build_service import BuildService
|
|
resources = {'containers': {}, 'images': set(), 'routes': {}, 'calls': []}
|
|
monkeypatch.setattr(runtime, 'ROOT', tmp_path / 'previews')
|
|
monkeypatch.setattr(BuildService, 'BUILD_LOG_DIR', str(tmp_path / 'logs'))
|
|
monkeypatch.setattr(GitService, 'get_app_config', lambda _: {'repo_url': 'https://example.test/repo.git'})
|
|
def prepare(parent, preview, path, url):
|
|
resources['calls'].append(('clone', str(path), url))
|
|
path.mkdir()
|
|
(path / 'Dockerfile').write_text('FROM scratch')
|
|
def build(parent, preview, path, image, **kwargs):
|
|
resources['calls'].append(('build', image))
|
|
resources['images'].add(image)
|
|
monkeypatch.setattr(source, 'prepare', prepare)
|
|
monkeypatch.setattr(source, 'build', build)
|
|
def run_container(**kwargs):
|
|
resources['calls'].append(('run', kwargs))
|
|
name = kwargs['name']
|
|
resources['containers'][name] = {'Id': name + '-id',
|
|
'Config': {'Labels': kwargs['labels']}, 'State': {'Running': True},
|
|
'NetworkSettings': {'Ports': {'8400/tcp': [{'HostIp': '127.0.0.1', 'HostPort': '39001'}]}}}
|
|
return {'success': True, 'container_id': name + '-id'}
|
|
def inspect(args, **kwargs):
|
|
kind, _, name = args
|
|
data = resources['containers'].get(name) if kind == 'container' else ({} if name in resources['images'] else None)
|
|
if data is None:
|
|
return {'success': False, 'error': 'No such object'}
|
|
return {'success': True, 'output': json.dumps([data])}
|
|
def remove(name, **kwargs):
|
|
resources['calls'].append(('remove', name, kwargs))
|
|
resources['containers'].pop(name, None)
|
|
return {'success': True}
|
|
def remove_image(name, **kwargs):
|
|
resources['images'].discard(name)
|
|
return {'success': True}
|
|
def publish(name, kind, domains, **kwargs):
|
|
resources['routes'][name] = domains
|
|
return {'success': True}
|
|
def delete(name):
|
|
resources['routes'].pop(name, None)
|
|
return {'success': True}
|
|
monkeypatch.setattr(runtime.DockerService, 'run_container', run_container)
|
|
monkeypatch.setattr(runtime.DockerService, 'run', inspect)
|
|
monkeypatch.setattr(runtime.DockerService, 'remove_container', remove)
|
|
monkeypatch.setattr(runtime.DockerService, 'remove_image', remove_image)
|
|
monkeypatch.setattr(runtime.NginxService, 'list_sites', lambda: [])
|
|
monkeypatch.setattr(runtime.NginxService, 'create_site', publish)
|
|
monkeypatch.setattr(runtime.NginxService, 'disable_site', lambda _: {'success': True})
|
|
monkeypatch.setattr(runtime.NginxService, 'delete_site', delete)
|
|
monkeypatch.setattr(runtime.SiteDomainService, 'covering_base', lambda _: None)
|
|
monkeypatch.setattr(runtime.health_gate, 'gate_for_app', lambda *a, **kw: {'status': 200})
|
|
with app.app_context():
|
|
parent = _mk_app(name='generic')
|
|
parent.container_id = 'production-container'
|
|
parent.docker_image = 'production:live'
|
|
db.session.commit()
|
|
PreviewService.enable_previews(parent.id, {'enabled': True, 'domain_template': 'pr-{pr_number}.test.example'})
|
|
yield parent, resources
|
|
|
|
|
|
def test_create_redeploy_destroy_are_isolated_and_idempotent(host):
|
|
parent, resources = host
|
|
first = PreviewService.create_preview(parent, PR)
|
|
assert first['status'] == 'running', first
|
|
assert first['url'] == 'http://pr-7.test.example'
|
|
old_ids = first['container_ids']
|
|
call = next(c[1] for c in resources['calls'] if c[0] == 'run')
|
|
assert call['ports'] == ['127.0.0.1::8400']
|
|
assert 'volumes' not in call and 'network' not in call
|
|
assert call['env'] == {'SERVERKIT_PREVIEW': 'true', 'SERVERKIT_PR_NUMBER': '7', 'SERVERKIT_PREVIEW_BRANCH': 'feature/preview'}
|
|
second = PreviewService.create_preview(parent, {**PR, 'commit_sha': 'b' * 40})
|
|
assert second['status'] == 'running'
|
|
assert second['id'] == first['id'] and second['container_ids'] != old_ids
|
|
assert len(resources['containers']) == len(resources['images']) == len(resources['routes']) == 1
|
|
result = PreviewService.destroy_preview(first['id'])
|
|
assert result['status'] == 'destroyed'
|
|
assert not resources['containers'] and not resources['images'] and not resources['routes']
|
|
assert not list(runtime.ROOT.iterdir())
|
|
assert PreviewService.destroy_preview(first['id'])['status'] == 'destroyed'
|
|
db.session.refresh(parent)
|
|
assert parent.container_id == 'production-container'
|
|
assert parent.docker_image == 'production:live' and parent.port == 8400
|
|
|
|
|
|
@pytest.mark.parametrize('failure', ['clone', 'build', 'start', 'health', 'route'])
|
|
def test_each_failure_compensates_owned_resources(host, monkeypatch, failure):
|
|
parent, resources = host
|
|
def fail(*args, **kwargs):
|
|
raise RuntimeError('secret-diagnostic-do-not-expose')
|
|
owner, attr = {'clone': (source, 'prepare'), 'build': (source, 'build'),
|
|
'start': (runtime, 'start'), 'health': (runtime.health_gate, 'gate_for_app'),
|
|
'route': (runtime, 'publish')}[failure]
|
|
monkeypatch.setattr(owner, attr, fail)
|
|
result = PreviewService.create_preview(parent, PR)
|
|
assert result['status'] == 'failed' and result['url'] is None
|
|
assert 'secret-diagnostic' not in str(result)
|
|
assert not resources['containers'] and not resources['images'] and not resources['routes']
|
|
row = db.session.get(ApplicationPreview, result['id'])
|
|
assert row.get_runtime_state() == {}
|
|
|
|
|
|
def test_cleanup_failure_preserves_journal_and_retry_succeeds(host, monkeypatch):
|
|
parent, resources = host
|
|
result = PreviewService.create_preview(parent, PR)
|
|
remove = runtime.DockerService.remove_container
|
|
monkeypatch.setattr(runtime.DockerService, 'remove_container', lambda *a, **kw: {'success': False})
|
|
failed = PreviewService.destroy_preview(result['id'])
|
|
assert failed.get('error')
|
|
row = db.session.get(ApplicationPreview, result['id'])
|
|
assert row.status == 'failed' and row.deleted_at is None
|
|
assert row.get_runtime_state()['container'] is True
|
|
assert resources['containers']
|
|
monkeypatch.setattr(runtime.DockerService, 'remove_container', remove)
|
|
assert PreviewService.destroy_preview(result['id'])['status'] == 'destroyed'
|
|
assert not resources['containers']
|
|
|
|
|
|
def test_domain_conflict_prevents_host_effects(host):
|
|
from app.models import Domain
|
|
parent, resources = host
|
|
db.session.add(Domain(name='pr-7.test.example', application_id=parent.id))
|
|
db.session.commit()
|
|
result = PreviewService.create_preview(parent, PR)
|
|
assert result['status'] == 'failed'
|
|
assert not resources['calls']
|
|
|
|
|
|
def test_unsupported_target_and_bad_ref_do_not_provision(host):
|
|
parent, resources = host
|
|
parent.compose_file = 'compose.yml'
|
|
db.session.commit()
|
|
assert PreviewService.create_preview(parent, PR)['status'] == 'failed'
|
|
parent.compose_file = None
|
|
db.session.commit()
|
|
assert PreviewService.create_preview(parent, {**PR, 'commit_sha': '--bad'})['status'] == 'failed'
|
|
assert not resources['calls']
|
|
|
|
|
|
def test_unknown_provider_does_not_destroy_existing_previews(host):
|
|
parent, resources = host
|
|
result = PreviewService.create_preview(parent, PR)
|
|
assert PreviewService.sync_previews(parent.id).get('error')
|
|
assert db.session.get(ApplicationPreview, result['id']).status == 'running'
|
|
assert resources['containers']
|
|
|
|
|
|
def test_cleanup_rejects_missing_ownership(host):
|
|
parent, resources = host
|
|
result = PreviewService.create_preview(parent, PR)
|
|
next(iter(resources['containers'].values()))['Config']['Labels'] = {}
|
|
assert PreviewService.destroy_preview(result['id']).get('error')
|
|
assert resources['containers']
|
|
|
|
|
|
def test_close_supersedes_queued_create_before_row_has_runtime(host, monkeypatch):
|
|
from app.services.previews.requests import enqueue
|
|
from app.jobs.service import JobService
|
|
parent, resources = host
|
|
jobs = []
|
|
def capture(kind, payload, **kwargs):
|
|
job = SimpleNamespace(kind=kind, get_payload=lambda: payload)
|
|
jobs.append(job)
|
|
return job
|
|
monkeypatch.setattr(JobService, 'enqueue', capture)
|
|
enqueue(parent.id, PR)
|
|
enqueue(parent.id, PR, action='destroy')
|
|
assert PreviewService._job_create(jobs[0])['reason'] == 'superseded'
|
|
assert PreviewService._job_destroy(jobs[1])['status'] == 'destroyed'
|
|
assert not resources['calls']
|
|
|
|
|
|
def test_close_during_build_cleans_up_without_publishing(host, monkeypatch):
|
|
from app.services.previews.requests import enqueue
|
|
from app.jobs.service import JobService
|
|
parent, resources = host
|
|
monkeypatch.setattr(JobService, 'enqueue', lambda *a, **kw: None)
|
|
original = source.build
|
|
def build(*args, **kwargs):
|
|
original(*args, **kwargs)
|
|
enqueue(parent.id, PR, action='destroy')
|
|
monkeypatch.setattr(source, 'build', build)
|
|
result = PreviewService.create_preview(parent, PR)
|
|
assert result['status'] == 'failed'
|
|
assert not resources['routes'] and not resources['containers']
|
|
|
|
|
|
def test_cleanup_paths_cannot_escape(tmp_path):
|
|
with pytest.raises(PreviewError):
|
|
workspace(tmp_path, 1, '../escape')
|
|
with pytest.raises(PreviewError):
|
|
identity(-1, 'a' * 32)
|
|
|
|
|
|
def test_failed_cleanup_blocks_purge_and_retention_sweep(host, monkeypatch):
|
|
from datetime import datetime, timedelta
|
|
from app.services import recycle_bin_service
|
|
parent, resources = host
|
|
result = PreviewService.create_preview(parent, PR)
|
|
parent.deleted_at = datetime.utcnow() - timedelta(days=90)
|
|
db.session.commit()
|
|
monkeypatch.setattr(runtime.DockerService, 'remove_container', lambda *a, **kw: {'success': False})
|
|
ok, reason = recycle_bin_service.purge('application', parent.id)
|
|
assert not ok and 'Preview cleanup' in reason
|
|
assert db.session.get(ApplicationPreview, result['id']).get_runtime_state()
|
|
counts = recycle_bin_service.purge_expired()
|
|
assert counts.get('application', 0) == 0
|
|
assert resources['containers']
|
|
|
|
|
|
def test_environment_inheritance_is_opt_in(host):
|
|
from app.services.env_service import EnvService
|
|
parent, resources = host
|
|
EnvService.set_env_var(parent.id, 'DATABASE_URL', 'production-credential', is_secret=True)
|
|
PreviewService.create_preview(parent, PR)
|
|
run = [call[1] for call in resources['calls'] if call[0] == 'run'][-1]
|
|
assert 'DATABASE_URL' not in run['env']
|
|
PreviewService.enable_previews(parent.id, {'inherit_environment': True})
|
|
PreviewService.create_preview(parent, PR)
|
|
run = [call[1] for call in resources['calls'] if call[0] == 'run'][-1]
|
|
assert run['env']['DATABASE_URL'] == 'production-credential'
|
|
|
|
|
|
def test_nginx_reload_failure_keeps_runtime_and_failed_status(host, monkeypatch):
|
|
parent, resources = host
|
|
result = PreviewService.create_preview(parent, PR)
|
|
monkeypatch.setattr(runtime.NginxService, 'disable_site', lambda _: {'success': False})
|
|
assert PreviewService.destroy_preview(result['id']).get('error')
|
|
row = db.session.get(ApplicationPreview, result['id'])
|
|
assert row.get_runtime_state()['vhost']
|
|
assert row.deleted_at is None and resources['containers']
|
|
|
|
|
|
def test_container_daemon_outage_is_not_treated_as_already_removed(host, monkeypatch):
|
|
parent, resources = host
|
|
result = PreviewService.create_preview(parent, PR)
|
|
monkeypatch.setattr(runtime.DockerService, 'run', lambda *a, **kw: {'success': False, 'error': 'daemon unavailable'})
|
|
assert PreviewService.destroy_preview(result['id']).get('error')
|
|
assert db.session.get(ApplicationPreview, result['id']).get_runtime_state()['container']
|
|
|
|
|
|
def test_api_queues_without_building_and_requires_app_write(host, client, monkeypatch):
|
|
from factories import headers_for, make_user
|
|
from app.models import User
|
|
from app.jobs.service import JobService
|
|
parent, resources = host
|
|
owner = db.session.get(User, parent.user_id)
|
|
queued = []
|
|
monkeypatch.setattr(JobService, 'enqueue', lambda *a, **kw: queued.append((a, kw)) or SimpleNamespace(id='job-id'))
|
|
url = f'/api/v1/apps/{parent.id}/previews'
|
|
result = client.post(url, headers=headers_for(owner), json=PR)
|
|
assert result.status_code == 202 and result.json['job_id'] == 'job-id'
|
|
assert not resources['calls'] and len(queued) == 1
|
|
assert client.post(url, headers=headers_for(owner), json={**PR, 'commit_sha': '-bad'}).status_code == 400
|
|
stranger = make_user(db, role='developer')
|
|
assert client.post(url, headers=headers_for(stranger), json=PR).status_code in (403, 404)
|
|
viewer = make_user(db, role='viewer')
|
|
assert client.post(url, headers=headers_for(viewer), json=PR).status_code == 403
|
|
assert len(queued) == 1
|
|
|
|
|
|
def test_source_checkout_is_pinned_and_removes_git_credentials(tmp_path, monkeypatch):
|
|
calls = []
|
|
target = tmp_path / 'checkout'
|
|
def clone(path, url, branch):
|
|
(target / '.git').mkdir(parents=True)
|
|
(target / '.git' / 'config').write_text('credential')
|
|
calls.append(('clone', path, url, branch))
|
|
return {'success': True}
|
|
monkeypatch.setattr(source.GitService, 'clone_repository', clone)
|
|
monkeypatch.setattr(source, 'run_checked', lambda argv, **kw: calls.append(argv) or {'success': True})
|
|
source.prepare(None, SimpleNamespace(**PR), target, 'https://example.test/repo.git')
|
|
assert any('--is-ancestor' in call and SHA in call for call in calls)
|
|
assert any('--detach' in call and SHA in call for call in calls)
|
|
assert not (target / '.git').exists()
|
|
|
|
|
|
def test_build_uses_isolated_namespace_and_existing_dispatcher(tmp_path, monkeypatch):
|
|
from app.services.build_service import BuildService
|
|
calls = []
|
|
config = {'app_path': '/production', 'build_method': 'dockerfile',
|
|
'env_vars': {'SECRET': 'private'}, 'build_args': {'TOKEN': 'private'}}
|
|
monkeypatch.setattr(BuildService, 'get_app_build_config', lambda _: config)
|
|
monkeypatch.setattr(BuildService, 'build_from_config', lambda *a, **kw: calls.append((a, kw)) or {'success': True})
|
|
source.build(SimpleNamespace(id=1), SimpleNamespace(id=2), tmp_path, 'isolated:tag')
|
|
args, kwargs = calls[0]
|
|
assert args[0] == 'preview-2'
|
|
assert args[1]['app_path'] == str(tmp_path)
|
|
assert args[1]['env_vars'] == args[1]['build_args'] == {}
|
|
assert config['app_path'] == '/production' and config['env_vars']['SECRET'] == 'private'
|
|
assert kwargs['image_tag'] == 'isolated:tag'
|
|
|
|
|
|
def test_preview_migration_round_trip_and_existing_schema():
|
|
import importlib.util
|
|
from pathlib import Path
|
|
import sqlalchemy as sa
|
|
from alembic.migration import MigrationContext
|
|
from alembic.operations import Operations
|
|
file = Path(__file__).parents[1] / 'migrations/versions/111_preview_runtime.py'
|
|
spec = importlib.util.spec_from_file_location('preview_migration', file)
|
|
migration = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(migration)
|
|
engine = sa.create_engine('sqlite://')
|
|
with engine.begin() as connection:
|
|
connection.exec_driver_sql('CREATE TABLE application_previews (id INTEGER PRIMARY KEY)')
|
|
connection.exec_driver_sql('CREATE TABLE application_preview_settings (application_id INTEGER PRIMARY KEY)')
|
|
connection.exec_driver_sql('INSERT INTO application_preview_settings VALUES (1)')
|
|
with Operations.context(MigrationContext.configure(connection)):
|
|
migration.upgrade()
|
|
migration.upgrade()
|
|
assert connection.exec_driver_sql('SELECT inherit_environment FROM application_preview_settings').scalar() == 0
|
|
assert 'runtime_state' in {c['name'] for c in sa.inspect(connection).get_columns('application_previews')}
|
|
migration.downgrade()
|
|
assert {c['name'] for c in sa.inspect(connection).get_columns('application_previews')} == {'id'}
|
|
|
|
|
|
def test_partial_docker_run_is_compensated(host, monkeypatch):
|
|
parent, resources = host
|
|
original = runtime.DockerService.run_container
|
|
def partial(**kwargs):
|
|
original(**kwargs)
|
|
return {'success': False, 'error': 'start failed after create'}
|
|
monkeypatch.setattr(runtime.DockerService, 'run_container', partial)
|
|
result = PreviewService.create_preview(parent, PR)
|
|
assert result['status'] == 'failed'
|
|
assert not resources['containers'] and not resources['images']
|
|
|
|
|
|
def test_partial_nginx_publish_is_compensated(host, monkeypatch):
|
|
parent, resources = host
|
|
original = runtime.NginxService.create_site
|
|
def partial(*args, **kwargs):
|
|
original(*args, **kwargs)
|
|
return {'success': False}
|
|
monkeypatch.setattr(runtime.NginxService, 'create_site', partial)
|
|
result = PreviewService.create_preview(parent, PR)
|
|
assert result['status'] == 'failed'
|
|
assert not resources['routes'] and not resources['containers']
|
|
|
|
|
|
def test_purge_cleans_preview_before_deleting_ownership_record(host, monkeypatch):
|
|
from datetime import datetime
|
|
from app.services import recycle_bin_service
|
|
parent, resources = host
|
|
result = PreviewService.create_preview(parent, PR)
|
|
parent.deleted_at = datetime.utcnow()
|
|
db.session.commit()
|
|
# Parent host lifecycle has its own suite; only previews touch Docker here.
|
|
monkeypatch.setattr(recycle_bin_service, '_run_purge_hook', lambda *a: None)
|
|
ok, warning = recycle_bin_service.purge('application', parent.id)
|
|
assert ok, warning
|
|
assert not resources['containers'] and not resources['images']
|
|
assert db.session.get(ApplicationPreview, result['id']) is None
|
|
|
|
|
|
def test_preview_uses_slot_container_port_without_mutating_production_slot(host):
|
|
from app.models.app_slot import AppSlot
|
|
parent, resources = host
|
|
parent.active_slot = 'a'
|
|
parent.slot_deploys_enabled = True
|
|
parent.port = 39005 # live host binding, not the image's listening port
|
|
slot = AppSlot(application_id=parent.id, slot='a', container_port=8400,
|
|
host_port=39005, container_name='production-slot', state='live')
|
|
db.session.add(slot)
|
|
db.session.commit()
|
|
assert PreviewService.create_preview(parent, PR)['status'] == 'running'
|
|
run = next(call[1] for call in resources['calls'] if call[0] == 'run')
|
|
assert run['ports'] == ['127.0.0.1::8400']
|
|
db.session.refresh(slot)
|
|
assert slot.host_port == 39005 and slot.container_name == 'production-slot'
|