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

1"""Deployment lifecycle gating — single source of truth for which 

2actions are allowed in which state. 

3 

4Centralises the state matrix so the API and the UI consult one 

5canonical mapping. 

6 

7Status values follow ``crud_deployments.get_deployment_status``: 

8 

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""" 

19 

20from __future__ import annotations 

21 

22from enum import Enum 

23 

24from fastapi import HTTPException, status 

25 

26from app.crud import deployments as crud_deployments 

27 

28 

29class DeploymentAction(str, Enum): 

30 """Lifecycle actions a user can request.""" 

31 

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" 

40 

41 

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

53 

54 

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} 

88 

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} 

98 

99 

100def allowed_actions(db, deployment) -> set[DeploymentAction]: 

101 """Return the set of actions allowed for the given deployment. 

102 

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()) 

112 

113 

114def ensure_action_allowed(db, deployment, action: DeploymentAction) -> None: 

115 """Raise ``HTTPException(409)`` if ``action`` isn't allowed right now. 

116 

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 )