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:
Juan Denis committed 2026-09-28 22:27:13 -04:00
1 parent d0b11714e7
commit 9eafe6709e
7 files changed
+114 -2

No files matched your search

+13
View File
@@ -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
+3 -2
View File
@@ -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]