mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
Point compose app actions at the live slot project
Plan 87 phase E, compose part. For a compose app on slots, the live release is a slot project, not the project its own compose file names. Status maps to the live project only (a stopped standby never reads as a degraded app), start/stop/restart and sleep/wake act on it, container listings show the live and standby projects, and an image update runs as a queued slot deploy instead of recreating the stack in place.
This commit is contained in:
1 parent
d0b11714e7
commit
9eafe6709e
7 files changed
+114
-2
No files matched your search
@@ -1431,6 +1431,19 @@ def apply_image_update(app_id):
|
||||
if not _can_edit_app(user, app):
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
|
||||
# A slot app never recreates its live stack in place: the newest images
|
||||
# go through a slot deploy (pulled in its preflight, gated, switched).
|
||||
from app.services.slot_deploy_service import SlotDeployService
|
||||
if SlotDeployService.is_compose_slot_app(app):
|
||||
from app.services.deployment_job_service import DeploymentJobService
|
||||
queued = DeploymentJobService.enqueue_app_deploy(app, user_id=current_user_id,
|
||||
trigger='image_update')
|
||||
if not queued.get('success'):
|
||||
from app.exceptions import ConflictError
|
||||
raise ConflictError(queued.get('error') or 'Failed to queue the update')
|
||||
return jsonify({'message': 'Image update queued as a slot deploy',
|
||||
'deploy_job_id': queued['job_id'], 'app': app.to_dict()}), 202
|
||||
|
||||
# Auto-apply is only safe for compose-managed apps; recreating a standalone
|
||||
# container would need its full run spec, which we don't store.
|
||||
if app.app_type != 'docker' or not app.root_path or not app.compose_file:
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.services.unit_compose_service import UnitComposeService
|
||||
from app.services.worker_process_service import WorkerProcessService
|
||||
from app.services.app_port_service import AppPortService
|
||||
from app.services import container_status_service
|
||||
from app.services.slot_deploy_service import SlotDeployService
|
||||
|
||||
|
||||
class ApplicationLifecycleError(Exception):
|
||||
@@ -81,6 +82,13 @@ def _is_single_container_app(app):
|
||||
)
|
||||
|
||||
|
||||
def _is_slot_compose_app(app):
|
||||
"""A compose app on A/B slots (plan 87): its live release is the slot
|
||||
project, not the project the app's own compose file would name."""
|
||||
from app.services.slot_deploy_service import SlotDeployService
|
||||
return SlotDeployService.is_compose_slot_app(app)
|
||||
|
||||
|
||||
def _app_container_name(app):
|
||||
"""The single container serving this app.
|
||||
|
||||
@@ -151,6 +159,8 @@ def start_application(app, *, user_id=None):
|
||||
detach=True,
|
||||
user_id=user_id
|
||||
)
|
||||
elif _is_slot_compose_app(app):
|
||||
result = SlotDeployService.live_action(app, 'start')
|
||||
elif _is_single_container_app(app):
|
||||
# Build-pack app: one container, created by the deploy.
|
||||
container = _app_container_name(app)
|
||||
@@ -195,6 +205,8 @@ def stop_application(app, *, user_id=None):
|
||||
_compose_target(app),
|
||||
user_id=user_id
|
||||
)
|
||||
elif _is_slot_compose_app(app):
|
||||
result = SlotDeployService.live_action(app, 'stop')
|
||||
elif _is_single_container_app(app):
|
||||
# A container that no longer exists is already stopped -- reporting
|
||||
# that as a failure would strand the app in `running` forever.
|
||||
@@ -231,6 +243,8 @@ def restart_application(app, *, user_id=None):
|
||||
_compose_target(app),
|
||||
user_id=user_id
|
||||
)
|
||||
elif _is_slot_compose_app(app):
|
||||
result = SlotDeployService.live_action(app, 'restart')
|
||||
elif _is_single_container_app(app):
|
||||
container = _app_container_name(app)
|
||||
if not DockerService.get_container(container):
|
||||
|
||||
@@ -48,6 +48,9 @@ class ContainerSleepService:
|
||||
def _stop(cls, app):
|
||||
if app.server_id:
|
||||
return {'success': False, 'error': 'Sleep is not yet supported for apps on remote servers'}
|
||||
from app.services.slot_deploy_service import SlotDeployService
|
||||
if SlotDeployService.is_compose_slot_app(app):
|
||||
return SlotDeployService.live_action(app, 'stop')
|
||||
if app.app_type == 'docker' and app.root_path and app.compose_file:
|
||||
return DockerService.compose_down(app.root_path, compose_file=app.compose_file)
|
||||
if app.container_id:
|
||||
@@ -58,6 +61,9 @@ class ContainerSleepService:
|
||||
def _start(cls, app):
|
||||
if app.server_id:
|
||||
return {'success': False, 'error': 'Wake is not yet supported for apps on remote servers'}
|
||||
from app.services.slot_deploy_service import SlotDeployService
|
||||
if SlotDeployService.is_compose_slot_app(app):
|
||||
return SlotDeployService.live_action(app, 'start')
|
||||
if app.app_type == 'docker' and app.root_path and app.compose_file:
|
||||
return DockerService.compose_up(app.root_path, detach=True, compose_file=app.compose_file)
|
||||
if app.container_id:
|
||||
|
||||
@@ -361,6 +361,12 @@ class _ContainerIndex:
|
||||
|
||||
def for_app(self, app) -> List[Dict[str, Any]]:
|
||||
"""Containers belonging to ``app``, in the trust order documented above."""
|
||||
slot_project = _live_slot_project(app)
|
||||
if slot_project:
|
||||
# A compose app on A/B slots (plan 87): only the live slot's
|
||||
# project counts. The standby is not rolled into the status, so a
|
||||
# stopped standby never reads as a degraded app.
|
||||
return self.by_project.get(slot_project, [])
|
||||
root_path = getattr(app, 'root_path', None)
|
||||
for key in (_norm_path(root_path), _real_path(root_path)):
|
||||
if key and key in self.by_dir:
|
||||
@@ -377,6 +383,18 @@ class _ContainerIndex:
|
||||
return []
|
||||
|
||||
|
||||
def _live_slot_project(app) -> Optional[str]:
|
||||
if not (getattr(app, 'slot_deploys_enabled', False) and getattr(app, 'active_slot', None)
|
||||
and getattr(app, 'compose_file', None)):
|
||||
return None
|
||||
try:
|
||||
from app.models.app_slot import AppSlot
|
||||
row = AppSlot.query.filter_by(application_id=app.id, slot=app.active_slot).first()
|
||||
except Exception: # noqa: BLE001 - no app context / table: fall back to the usual mapping
|
||||
return None
|
||||
return (row.project_name or '').lower() or None if row else None
|
||||
|
||||
|
||||
def _collect_container_index() -> _ContainerIndex:
|
||||
"""Run the ONE `docker ps` and index it. Never raises."""
|
||||
from app.services.docker_service import DockerService
|
||||
|
||||
@@ -516,9 +516,10 @@ class DockerService:
|
||||
return []
|
||||
|
||||
app_id = cls._app_attr(app, 'id')
|
||||
if (app_id and not compose_file and not isinstance(app, dict)
|
||||
if (app_id and not isinstance(app, dict)
|
||||
and cls._app_attr(app, 'slot_deploys_enabled') and cls._app_attr(app, 'active_slot')):
|
||||
# A slot app (plan 87): the live slot, its standby, its workers.
|
||||
# A slot app (plan 87): the live slot, its standby, its workers
|
||||
# (for compose, the live and standby projects).
|
||||
from app.services.slot_deploy_service import SlotDeployService
|
||||
return SlotDeployService.containers(app)
|
||||
containers = cls.compose_ps(root_path, compose_file=compose_file)
|
||||
|
||||
@@ -906,6 +906,25 @@ class SlotDeployService:
|
||||
slot.standby_until = None
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def is_compose_slot_app(app) -> bool:
|
||||
"""A local compose app whose live release is a slot project."""
|
||||
return bool(getattr(app, 'slot_deploys_enabled', False) and getattr(app, 'active_slot', None)
|
||||
and getattr(app, 'compose_file', None) and not getattr(app, 'server_id', None))
|
||||
|
||||
@classmethod
|
||||
def live_action(cls, app, action: str) -> Dict:
|
||||
"""start / stop / restart the live slot's compose project. Stopping
|
||||
the app also stops its warm standby."""
|
||||
from app.services import slot_compose_service as sc
|
||||
live = AppSlot.query.filter_by(application_id=app.id, slot=app.active_slot).first()
|
||||
if live is None or not live.project_name:
|
||||
return {'success': False, 'error': 'The live slot has no compose project.'}
|
||||
result = sc.compose(live.project_name, _compose_files(live), action, cwd=app.root_path)
|
||||
if action == 'stop':
|
||||
cls.stop_standby(app)
|
||||
return {'success': bool(result.get('success')), 'error': result.get('error')}
|
||||
|
||||
@classmethod
|
||||
def stop_standby(cls, app) -> None:
|
||||
"""Stop the app's warm standby now (the app itself is being stopped)."""
|
||||
@@ -923,6 +942,18 @@ class SlotDeployService:
|
||||
import json
|
||||
from app.services.docker_service import DockerService
|
||||
from app.services.worker_process_service import container_prefix
|
||||
rows = AppSlot.query.filter_by(application_id=app.id).all()
|
||||
if any(r.project_name for r in rows):
|
||||
from app.services import slot_compose_service as sc
|
||||
listed = []
|
||||
for row in sorted(rows, key=lambda r: r.slot != app.active_slot):
|
||||
if not row.project_name:
|
||||
continue
|
||||
for c in sc.project_containers(row.project_name):
|
||||
if row.slot != app.active_slot:
|
||||
c = {**c, 'service': f"standby-{c['service']}"}
|
||||
listed.append(c)
|
||||
return listed
|
||||
live = live_container_name(app)
|
||||
rows = AppSlot.query.filter_by(application_id=app.id).all()
|
||||
slot_names = {r.container_name for r in rows if r.container_name}
|
||||
|
||||
@@ -348,3 +348,32 @@ def test_a_template_update_on_slots_never_stops_the_live_stack(app, compose_worl
|
||||
assert (w.root / 'docker-compose.yml').read_text() == 'services: {web: {image: new}}'
|
||||
assert 'IN-PLACE down' not in [e for e, _ in w.timeline] and w.outage() == []
|
||||
|
||||
|
||||
def test_lifecycle_and_status_follow_the_live_project(app, compose_world):
|
||||
from app.services import application_lifecycle_service as lifecycle
|
||||
from app.services.container_status_service import _ContainerIndex
|
||||
w = compose_world
|
||||
row = _compose_app(w)
|
||||
SlotDeployService.set_enabled(row, True)
|
||||
assert DeploymentService.deploy(row.id)['success']
|
||||
|
||||
lifecycle.restart_application(row)
|
||||
assert w.compose_calls[-1] == ('shop-b', 'restart')
|
||||
|
||||
index = _ContainerIndex([
|
||||
{'id': '1', 'name': 'shop-b-web-1', 'project': 'shop-b', 'state': 'running'},
|
||||
{'id': '2', 'name': 'x-web-1', 'project': sc.original_project(row), 'state': 'exited'},
|
||||
])
|
||||
assert [c['name'] for c in index.for_app(row)] == ['shop-b-web-1'], 'the standby is not counted'
|
||||
|
||||
|
||||
def test_image_update_on_a_slot_app_is_a_queued_slot_deploy(app, compose_world, client,
|
||||
auth_headers, monkeypatch):
|
||||
from app.services.deployment_job_service import DeploymentJobService
|
||||
w = compose_world
|
||||
row = _compose_app(w)
|
||||
SlotDeployService.set_enabled(row, True)
|
||||
monkeypatch.setattr(DeploymentJobService, '_enqueue_app_deploy', classmethod(lambda cls, job: None))
|
||||
resp = client.post(f'/api/v1/apps/{row.id}/image-update/apply', headers=auth_headers)
|
||||
assert resp.status_code == 202, resp.get_json()
|
||||
assert 'IN-PLACE up' not in [e for e, _ in w.timeline]
|
||||
Reference in new issue
Block a user