Coverage for app/services/openstack_service.py: 100.00%

58 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-25 16:05 +0000

1""" 

2OpenStack service for image management 

3""" 

4 

5import json 

6import logging 

7import os 

8import subprocess 

9 

10logger = logging.getLogger(__name__) 

11 

12 

13class OpenStackService: 

14 """Service for OpenStack image and compute-instance operations. 

15 

16 All methods shell out to the ``openstack`` CLI (python-openstackclient) 

17 rather than using the SDK in-process, mirroring how the rest of the 

18 worker invokes external tooling (Packer, Terraform). Exit-code zero 

19 is treated as success; stderr is captured and returned to the 

20 caller for surfacing in the per-deployment log. 

21 

22 The CLI is configured via env vars (OS_AUTH_URL etc.) supplied by 

23 :class:`PerTaskCloudsConfig`. ``OS_CLIENT_CONFIG_FILE`` and 

24 ``OS_CLOUD`` make the CLI prefer the per-task ``clouds.yaml`` over 

25 any ambient configuration on the worker host. 

26 """ 

27 

28 def __init__(self, env_vars: dict[str, str] | None = None): 

29 """ 

30 Initialize OpenStack service 

31 

32 Args: 

33 env_vars: OpenStack environment variables (OS_AUTH_URL, OS_USERNAME, etc.) 

34 """ 

35 self.env_vars = env_vars or {} 

36 

37 def _run(self, args: list[str], timeout: int = 60) -> tuple[int, str, str]: 

38 """Run an ``openstack`` CLI command with the configured env vars. 

39 

40 Centralised so every call carries the per-task credentials and 

41 the same error handling. Returns the raw triple 

42 ``(returncode, stdout, stderr)`` so callers can decide how to 

43 report; structured-output methods JSON-decode stdout themselves. 

44 """ 

45 env = os.environ.copy() 

46 env.update(self.env_vars) 

47 try: 

48 result = subprocess.run( 

49 args, 

50 capture_output=True, 

51 text=True, 

52 timeout=timeout, 

53 env=env, 

54 ) 

55 return (result.returncode, result.stdout or "", result.stderr or "") 

56 except subprocess.TimeoutExpired: 

57 return (-1, "", f"Timeout after {timeout}s running: {' '.join(args)}") 

58 except FileNotFoundError: 

59 return (-1, "", "OpenStack CLI not found — install python-openstackclient") 

60 except Exception as e: # noqa: BLE001 — surfaced to user 

61 return (-1, "", f"Error running openstack CLI: {e}") 

62 

63 def check_image_exists(self, image_name: str) -> tuple[bool, str | None]: 

64 """ 

65 Check if an image with the given name already exists in OpenStack 

66 

67 Args: 

68 image_name: Name of the image to check 

69 

70 Returns: 

71 tuple: (exists: bool, image_id: str | None) 

72 """ 

73 if not self.env_vars.get("OS_AUTH_URL"): 

74 logger.warning("No OpenStack credentials available for image check") 

75 return (False, None) 

76 

77 rc, stdout, stderr = self._run( 

78 ["openstack", "image", "list", "--name", image_name, "-f", "json"], 

79 timeout=30, 

80 ) 

81 if rc != 0: 

82 logger.error(f"Failed to check image existence: {stderr}") 

83 return (False, None) 

84 

85 try: 

86 images = json.loads(stdout) 

87 except json.JSONDecodeError as e: 

88 logger.error(f"Failed to parse OpenStack CLI output: {e}") 

89 return (False, None) 

90 

91 if images: 

92 image_id = images[0].get("ID") 

93 logger.info(f"Image '{image_name}' already exists with ID: {image_id}") 

94 return (True, image_id) 

95 logger.info(f"Image '{image_name}' does not exist") 

96 return (False, None) 

97 

98 # ------------------------------------------------------------------ 

99 # Compute instance lifecycle (used by pause / resume) 

100 # ------------------------------------------------------------------ 

101 # 

102 # ``server stop`` / ``server start`` are idempotent at the CLI 

103 # level: stopping an already-SHUTOFF instance returns exit code 0 

104 # with no error, and the same applies to starting an already-ACTIVE 

105 # instance. This makes pause/resume safe to retry without extra 

106 # state checks. We surface the CLI's stderr verbatim so the user 

107 # can see the precise reason on the rare hard failure (locked task, 

108 # auth glitch, etc.). 

109 

110 def server_show(self, server_id: str) -> dict | None: 

111 """Return the parsed ``openstack server show <id> -f json`` payload. 

112 

113 Used to log the human-readable name and current power state 

114 before issuing a stop/start, so the per-deployment log shows 

115 ``"web-1 (ACTIVE) → stopping"`` rather than just a UUID. 

116 Returns ``None`` if the server can't be fetched (deleted, 

117 permission, transient failure). 

118 """ 

119 rc, stdout, stderr = self._run( 

120 ["openstack", "server", "show", server_id, "-f", "json"], 

121 timeout=30, 

122 ) 

123 if rc != 0: 

124 logger.warning(f"server show failed for {server_id}: {stderr.strip()}") 

125 return None 

126 try: 

127 return json.loads(stdout) 

128 except json.JSONDecodeError: 

129 return None 

130 

131 def server_stop(self, server_id: str) -> tuple[bool, str | None]: 

132 """Stop an OpenStack compute instance. 

133 

134 Returns ``(True, None)`` on success, ``(False, stderr)`` on 

135 failure. The CLI is idempotent on already-SHUTOFF instances — 

136 no extra status check needed. 

137 """ 

138 rc, _stdout, stderr = self._run( 

139 ["openstack", "server", "stop", server_id], 

140 timeout=120, 

141 ) 

142 if rc == 0: 

143 return (True, None) 

144 return (False, stderr.strip() or "openstack server stop failed") 

145 

146 def server_start(self, server_id: str) -> tuple[bool, str | None]: 

147 """Start an OpenStack compute instance. 

148 

149 Returns ``(True, None)`` on success, ``(False, stderr)`` on 

150 failure. Idempotent on already-ACTIVE instances. 

151 """ 

152 rc, _stdout, stderr = self._run( 

153 ["openstack", "server", "start", server_id], 

154 timeout=120, 

155 ) 

156 if rc == 0: 

157 return (True, None) 

158 return (False, stderr.strip() or "openstack server start failed")