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
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-25 16:05 +0000
1"""
2OpenStack service for image management
3"""
5import json
6import logging
7import os
8import subprocess
10logger = logging.getLogger(__name__)
13class OpenStackService:
14 """Service for OpenStack image and compute-instance operations.
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.
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 """
28 def __init__(self, env_vars: dict[str, str] | None = None):
29 """
30 Initialize OpenStack service
32 Args:
33 env_vars: OpenStack environment variables (OS_AUTH_URL, OS_USERNAME, etc.)
34 """
35 self.env_vars = env_vars or {}
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.
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}")
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
67 Args:
68 image_name: Name of the image to check
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)
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)
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)
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)
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.).
110 def server_show(self, server_id: str) -> dict | None:
111 """Return the parsed ``openstack server show <id> -f json`` payload.
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
131 def server_stop(self, server_id: str) -> tuple[bool, str | None]:
132 """Stop an OpenStack compute instance.
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")
146 def server_start(self, server_id: str) -> tuple[bool, str | None]:
147 """Start an OpenStack compute instance.
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")