Files
6e096fa286 Add real preview deployments and strengthen core boundaries (#184)
* fix(security): honour the failed-login time window (#180)

check_failed_logins computed a cutoff from since_hours and never used
it, so the Security -> Events "Failed login attempts (24h)" card and
its alert counted every failure still in /var/log/auth.log or
/var/log/secure: up to a week of SSH attempts under the usual weekly
log rotation.

Each matching line is now dated from its stamp, either RFC 3339
(rsyslog's high-precision format) or the traditional BSD syslog stamp
(no year, so a December line read in January is placed in the previous
year), and lines older than the cutoff are skipped. A line whose stamp
is not recognised is still counted rather than hidden. The log paths
move to SecurityService.AUTH_LOGS so the tests can point at a temp
file.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: separate backend startup phases and guard ordering

* refactor: route file operations through capability-checked adapters

* chore: bump version to 1.11.25 [skip ci]

* refactor: split File Manager state and views with regression guards

* test: enforce core boundaries and document extension rules

* test: cover File Manager tree races and listing failures

* fix: preserve File Manager deep links on initial mount

* refactor: keep File Manager download operations in the action hook

* fix: start agent maintenance only in the guarded runtime phase

* test: capture nginx rendering contract before extraction

* refactor: separate nginx configuration rendering from host operations

* test: enforce pure nginx configuration boundaries

* fix: require provisioned runtime before advertising previews

* feat: expose value-free effective environment provenance through app access boundary

* feat: add effective environment inspector with browser regression checks

* feat: provision isolated generic previews with retryable resource cleanup

* feat: queue preview automation and preserve cleanup ownership through app deletion

* feat: add pinned preview deployment controls and queued lifecycle feedback

* fix: preserve container port semantics for previews of slot deployments

* fix: preserve search focus and ignore stale list responses

* feat: run approved assistant deployments through persistent recipe operations

* test: run search and assistant deployment regressions in CI

* fix: refresh preview API and controller boundary inventories

* docs: refresh controller migration count after preview extraction

* fix: bind file editor previews to their storage target

* chore: bump version to 1.11.26 [skip ci]

---------

Co-authored-by: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-24 15:31:39 -04:00

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, {})