Coverage for app/services/lifecycle.py: 95.83%
24 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
1"""Deployment lifecycle gating — single source of truth for which
2actions are allowed in which state.
4Centralises the state matrix so the API and the UI consult one
5canonical mapping.
7Status values follow ``crud_deployments.get_deployment_status``:
9* ``pending`` / ``running`` — a deploy task is in flight
10* ``success`` — deploy finished, resources live in OpenStack
11* ``failed`` — last task ended in error (deploy / destroy / pause / resume)
12* ``cancelled`` — last task was revoked
13* ``destroying`` — a destroy task is in flight (synthetic)
14* ``destroyed`` — a destroy task finished successfully (synthetic)
15* ``pausing`` — a pause task is in flight (synthetic)
16* ``paused`` — a pause task finished successfully (synthetic)
17* ``resuming`` — a resume task is in flight (synthetic)
18"""
20from __future__ import annotations
22from enum import Enum
24from fastapi import HTTPException, status
26from app.crud import deployments as crud_deployments
29class DeploymentAction(str, Enum):
30 """Lifecycle actions a user can request."""
32 DESTROY = "destroy"
33 DELETE = "delete"
34 # Pause halts compute (``openstack server stop`` for every server);
35 # volumes and networks stay so resume restores the same instances.
36 PAUSE = "pause"
37 # Resume reverses pause. Only valid in the synthetic ``paused``
38 # state so the matrix stays unambiguous.
39 RESUME = "resume"
42# Synthetic statuses where a worker task is currently in flight. Every
43# action endpoint must refuse while in any of these to avoid a parallel
44# destroy mid-deploy or pause mid-resume. Single source of truth so the
45# routes and the matrix below stay in sync.
46IN_FLIGHT_STATUSES: frozenset[str] = frozenset({
47 "pending",
48 "running",
49 "destroying",
50 "pausing",
51 "resuming",
52})
55# Status → set of allowed actions. Anything not listed gets the empty
56# set (safe default: an unrecognised status allows no destructive action).
57_ALLOWED: dict[str, set[DeploymentAction]] = {
58 # Deployed and running — tear down or pause compute to free quota.
59 "success": {DeploymentAction.DESTROY, DeploymentAction.PAUSE},
60 # A failed deploy may have created some resources, so Destroy is
61 # offered to reconcile; Delete is available when there's nothing to
62 # clean up. Both end at "row hidden from UI".
63 "failed": {DeploymentAction.DESTROY, DeploymentAction.DELETE},
64 # No ``destroyed`` entry: a successful destroy auto-soft-deletes the
65 # deployment, so that status only exists transiently (sub-second).
66 "cancelled": {DeploymentAction.DELETE},
67 # Paused — Resume is the obvious action; Destroy stays available so
68 # the user needn't resume first (terraform-destroy works on SHUTOFF).
69 "paused": {DeploymentAction.RESUME, DeploymentAction.DESTROY},
70 # Pause failed: the deployment is still running. Allow PAUSE retry,
71 # RESUME (harmless), and DESTROY.
72 "pause_failed": {
73 DeploymentAction.PAUSE,
74 DeploymentAction.RESUME,
75 DeploymentAction.DESTROY,
76 },
77 # Resume failed: instances are SHUTOFF. Allow RESUME retry, PAUSE
78 # (idempotent), and DESTROY.
79 "resume_failed": {
80 DeploymentAction.RESUME,
81 DeploymentAction.PAUSE,
82 DeploymentAction.DESTROY,
83 },
84 # pending / running / destroying / pausing / resuming — no action
85 # allowed; a DB partial-unique index on in-flight tasks enforces
86 # this at insert time too.
87}
89# Human-readable explanation for the 409 we throw when an action isn't
90# allowed. Keys match the action; the message lists the statuses where
91# the action is valid.
92_REQUIRED_STATES: dict[DeploymentAction, str] = {
93 DeploymentAction.DESTROY: "success, failed, paused, pause_failed or resume_failed",
94 DeploymentAction.DELETE: "failed or cancelled",
95 DeploymentAction.PAUSE: "success, pause_failed or resume_failed",
96 DeploymentAction.RESUME: "paused, pause_failed or resume_failed",
97}
100def allowed_actions(db, deployment) -> set[DeploymentAction]:
101 """Return the set of actions allowed for the given deployment.
103 ``deployment`` can be a Deployment ORM instance or just its id —
104 we only need the id to look up its status. We pass the ORM instance
105 in routers because they already have it loaded.
106 """
107 deployment_id = getattr(deployment, "deploymentId", deployment)
108 current = crud_deployments.get_deployment_status(db, deployment_id)
109 if current is None:
110 return set()
111 return _ALLOWED.get(current, set())
114def ensure_action_allowed(db, deployment, action: DeploymentAction) -> None:
115 """Raise ``HTTPException(409)`` if ``action`` isn't allowed right now.
117 Produces a friendly status-mismatch message on top of the DB-level
118 partial unique index (whose own error is opaque).
119 """
120 if action in allowed_actions(db, deployment):
121 return
122 deployment_id = getattr(deployment, "deploymentId", deployment)
123 current = crud_deployments.get_deployment_status(db, deployment_id) or "unknown"
124 raise HTTPException(
125 status_code=status.HTTP_409_CONFLICT,
126 detail=(
127 f"Cannot {action.value} a deployment in status '{current}'. "
128 f"Required status: {_REQUIRED_STATES[action]}."
129 ),
130 )