Coverage for app/tasks.py: 84.34%

728 statements  

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

1import json 

2import os 

3import re 

4from typing import Any 

5 

6import git 

7 

8from .celery_app import celery_app 

9from .config import settings 

10from .services import ( 

11 OpenStackService, 

12 PackerBuildLock, 

13 PackerExecutor, 

14 PerTaskCloudsConfig, 

15 TerraformExecutor, 

16 git_service, 

17) 

18from .services.packer_discovery import PackerTemplateDiscoveryError, _discover_packer_templates, _PackerTemplate 

19from .utils.logger import LogCategory, get_logger 

20 

21logger = get_logger(__name__) 

22 

23 

24def _tfstate_schema_name(deployment_id: str) -> str: 

25 """Postgres schema name for one deployment's Terraform state. 

26 

27 UUIDs contain hyphens, which would force every reference to be 

28 double-quoted. Replacing hyphens with underscores keeps the schema 

29 a plain unquoted identifier and avoids escaping hazards in any 

30 backend-config plumbing. 

31 """ 

32 return f"deployment_{deployment_id.replace('-', '_')}" 

33 

34 

35class Failure(Exception): 

36 """Custom exception that carries deployment details for Celery. 

37 

38 The full failure payload is serialised once into ``args[0]`` as a JSON 

39 string. The backend's celery event listener parses that JSON back via 

40 a ``Failure\\('<json>'\\)`` regex over the traceback. 

41 

42 ``__reduce__`` is overridden so pickle reconstructs the exception via 

43 the ``_from_payload`` classmethod, which accepts the single JSON string 

44 directly. 

45 """ 

46 

47 def __init__( 

48 self, 

49 message: str, 

50 deployment_id: str, 

51 logs_dict: list[dict[str, Any]] | dict[str, Any], 

52 tf_state: str | None = None, 

53 commit_info: dict[str, Any] | None = None, 

54 terraform_outputs: dict[str, Any] | None = None, 

55 ): 

56 self.deployment_id = deployment_id 

57 self.logs_dict = logs_dict 

58 self.tf_state = tf_state 

59 self.commit_info = commit_info 

60 self.terraform_outputs = terraform_outputs 

61 

62 # Encode all data as JSON in the exception message 

63 data = { 

64 "error": message, 

65 "deployment_id": deployment_id, 

66 "logs": logs_dict, 

67 "tf_state": tf_state, 

68 "commit_info": commit_info, 

69 "terraform_outputs": terraform_outputs, 

70 } 

71 super().__init__(json.dumps(data)) 

72 

73 @classmethod 

74 def _from_payload(cls, payload: str) -> "Failure": 

75 """Reconstruct a Failure from its serialised JSON payload. 

76 

77 Used by ``__reduce__`` so pickle can round-trip the exception. 

78 """ 

79 data = json.loads(payload) 

80 instance = cls.__new__(cls) 

81 instance.deployment_id = data.get("deployment_id", "") 

82 instance.logs_dict = data.get("logs") 

83 instance.tf_state = data.get("tf_state") 

84 instance.commit_info = data.get("commit_info") 

85 instance.terraform_outputs = data.get("terraform_outputs") 

86 Exception.__init__(instance, payload) 

87 return instance 

88 

89 def __reduce__(self): 

90 # The single-arg constructor here is ``_from_payload``; args[0] is 

91 # the JSON string we built in __init__. 

92 return (Failure._from_payload, (self.args[0] if self.args else "{}",)) 

93 

94 def __repr__(self) -> str: 

95 # Pin the repr format that the backend's celery event listener 

96 # relies on (regex ``Failure\('(.+)'\)``). 

97 return f"Failure({self.args[0]!r})" if self.args else "Failure()" 

98 

99 def to_dict(self) -> dict[str, Any]: 

100 """Convert exception data to dict for serialization""" 

101 return json.loads(str(self)) 

102 

103 

104# --- Variable encoding for Packer/Terraform CLI ---------------------------- 

105 

106 

107def _looks_like_file_var_value(value: Any) -> bool: 

108 """True if ``value`` matches the file-upload shape produced by 

109 the backend's ``_attach_files_to_user_input``: a non-empty 

110 mapping whose entries each carry a ``content_b64`` field plus 

111 the metadata triplet (name, size, content_type) — i.e. exactly 

112 the ``map(object(...))`` HCL contract. 

113 

114 Used by :func:`_strip_file_vars` so destroy / cleanup-after- 

115 failure can drop ``@openstack:file:*``-marked variables before 

116 passing the var-set to ``terraform destroy``. Terraform 

117 validates *all* declared variables on every command — including 

118 destroy — so an apply-only file-var would otherwise block the 

119 cleanup with a schema error. 

120 

121 A slot must carry ``content_b64`` to qualify as a file-var; the 

122 strictness avoids dropping legitimate non-file map variables that 

123 happen to share the metadata keys. 

124 """ 

125 if not isinstance(value, dict) or not value: 

126 return False 

127 for slot in value.values(): 

128 if not isinstance(slot, dict): 

129 return False 

130 if "content_b64" not in slot: 

131 return False 

132 return True 

133 

134 

135def _strip_file_vars(terraform_vars: dict[str, Any]) -> dict[str, Any]: 

136 """Return a copy of ``terraform_vars`` with file-shape entries removed. 

137 

138 Pure function — never mutates the input. Used by destroy and the 

139 deploy cleanup-after-failure branches; deploy itself keeps the 

140 file vars because ``apply`` consumes them via cloud-init. 

141 """ 

142 return {k: v for k, v in terraform_vars.items() if not _looks_like_file_var_value(v)} 

143 

144 

145def _scrub_nested_nones(value: Any) -> Any: 

146 """Recursively drop ``None`` entries from nested dicts/lists. 

147 

148 A stray ``None`` inside a ``map(list(string))`` slot would surface as 

149 literal HCL ``null`` after the JSON round-trip and trip Terraform's 

150 type check. Dicts have their ``None``-valued keys removed, lists have 

151 their ``None`` entries filtered out, and both are walked recursively. 

152 Scalars (including bools) pass through untouched. 

153 """ 

154 if isinstance(value, dict): 

155 cleaned: dict[Any, Any] = {} 

156 for k, v in value.items(): 

157 if v is None: 

158 continue 

159 cleaned[k] = _scrub_nested_nones(v) 

160 return cleaned 

161 if isinstance(value, list): 

162 return [_scrub_nested_nones(item) for item in value if item is not None] 

163 return value 

164 

165 

166def encode_terraform_vars(d: dict[str, Any]) -> dict[str, str]: 

167 """Encode variables for ``terraform -var key=value`` CLI args. 

168 

169 Terraform reads complex types (objects, tuples) when the value is a 

170 valid JSON literal. We JSON-encode dicts/lists once and pass them 

171 through verbatim — no string normalisation that could damage escape 

172 sequences. 

173 

174 Nested ``None`` values are scrubbed recursively (see 

175 :func:`_scrub_nested_nones`) so a stray ``null`` deep inside a 

176 ``map(list(string))`` slot can't trip Terraform's type check. 

177 """ 

178 result: dict[str, str] = {} 

179 for k, v in d.items(): 

180 if v is None: 

181 continue 

182 if isinstance(v, bool): 

183 # HCL accepts lowercase only; ``str(True)`` would emit "True". 

184 result[k] = "true" if v else "false" 

185 elif isinstance(v, dict | list): 

186 result[k] = json.dumps(_scrub_nested_nones(v), ensure_ascii=False) 

187 else: 

188 result[k] = str(v) 

189 return result 

190 

191 

192def encode_packer_vars(d: dict[str, Any]) -> dict[str, str]: 

193 """Encode variables for ``packer -var key=value`` CLI args. 

194 

195 For HCL ``list(...)``-typed variables, we emit a JSON array literal 

196 (e.g. ``["NAT"]``) — that's the only form Packer accepts via ``-var`` 

197 for typed-list variables, since Packer parses each ``-var`` value as 

198 an HCL expression against the declared type. JSON arrays are valid 

199 HCL list literals, so a single representation covers both syntaxes. 

200 String values are passed through verbatim. 

201 """ 

202 result: dict[str, str] = {} 

203 for k, v in d.items(): 

204 if v is None: 

205 continue 

206 if isinstance(v, list): 

207 # JSON array works for ``list(string)``, ``list(number)`` etc. 

208 # ``ensure_ascii=False`` lets non-ASCII names pass through 

209 # unchanged (Packer's HCL parser is UTF-8 native). 

210 result[k] = json.dumps(v, ensure_ascii=False) 

211 elif isinstance(v, dict): 

212 # ``map(...)``-typed Packer vars take the same JSON literal 

213 # path. No Packer template in the project uses this today, 

214 # but the encoding is correct for when one shows up. 

215 result[k] = json.dumps(v, ensure_ascii=False) 

216 elif isinstance(v, bool): 

217 result[k] = "true" if v else "false" 

218 else: 

219 result[k] = str(v) 

220 return result 

221 

222 

223# --- Phase tracking ---------------------------------------------------------- 

224# 

225# Phases are pinned by name (a string the frontend renders as a stepper) and 

226# by index (1-based, used for the percent bar). The list is split in two so 

227# the worker can collapse the Packer block when a deployment doesn't need a 

228# Packer build — that decision is made after the git clone has finished and 

229# we can see whether ``packer/template.pkr.hcl`` exists. 

230 

231PHASE_STARTING = "STARTING" 

232PHASE_OPENSTACK_SETUP = "OPENSTACK_SETUP" 

233PHASE_GIT_CLONE = "GIT_CLONE" 

234PHASE_CREDS_MATERIALISE = "CREDS_MATERIALISE" 

235PHASE_PACKER_INIT = "PACKER_INIT" 

236PHASE_PACKER_VALIDATE = "PACKER_VALIDATE" 

237PHASE_PACKER_BUILD = "PACKER_BUILD" 

238PHASE_TERRAFORM_INIT = "TERRAFORM_INIT" 

239PHASE_TERRAFORM_PLAN = "TERRAFORM_PLAN" 

240PHASE_TERRAFORM_APPLY = "TERRAFORM_APPLY" 

241PHASE_OUTPUTS_AND_CLEANUP = "OUTPUTS_AND_CLEANUP" 

242PHASE_TERRAFORM_DESTROY = "TERRAFORM_DESTROY" 

243PHASE_CLEANUP = "CLEANUP" 

244# Pause/resume share the deploy/destroy preamble but their hot phase is 

245# a CLI-driven server stop/start, so they get distinct phase names rather 

246# than reusing TERRAFORM_DESTROY. 

247PHASE_SERVER_STOP = "SERVER_STOP" 

248PHASE_SERVER_START = "SERVER_START" 

249 

250_PHASES_WITH_PACKER = ( 

251 PHASE_STARTING, 

252 PHASE_OPENSTACK_SETUP, 

253 PHASE_GIT_CLONE, 

254 PHASE_CREDS_MATERIALISE, 

255 PHASE_PACKER_INIT, 

256 PHASE_PACKER_VALIDATE, 

257 PHASE_PACKER_BUILD, 

258 PHASE_TERRAFORM_INIT, 

259 PHASE_TERRAFORM_PLAN, 

260 PHASE_TERRAFORM_APPLY, 

261 PHASE_OUTPUTS_AND_CLEANUP, 

262) 

263_PHASES_WITHOUT_PACKER = ( 

264 PHASE_STARTING, 

265 PHASE_OPENSTACK_SETUP, 

266 PHASE_GIT_CLONE, 

267 PHASE_CREDS_MATERIALISE, 

268 PHASE_TERRAFORM_INIT, 

269 PHASE_TERRAFORM_PLAN, 

270 PHASE_TERRAFORM_APPLY, 

271 PHASE_OUTPUTS_AND_CLEANUP, 

272) 

273 

274 

275def _phases_for_templates(templates: list[_PackerTemplate]) -> tuple[str, ...]: 

276 """Build the phase tuple based on the discovered Packer templates. 

277 

278 * No templates → ``_PHASES_WITHOUT_PACKER`` (clone, then straight 

279 to terraform). 

280 * One template with key ``"default"`` (legacy layout) → 

281 ``_PHASES_WITH_PACKER`` verbatim. The phase names stay 

282 ``PACKER_INIT`` / ``PACKER_VALIDATE`` / ``PACKER_BUILD`` with no 

283 key suffix. 

284 * Multi (any other shape) → one 

285 ``PACKER_INIT:<key>`` / ``PACKER_VALIDATE:<key>`` / 

286 ``PACKER_BUILD:<key>`` trio per template, inserted where the Packer 

287 phases sit in ``_PHASES_WITH_PACKER``. Templates are emitted in the 

288 order passed in (discovery returns them sorted by key, so the 

289 stepper order is deterministic). 

290 """ 

291 if not templates: 

292 return _PHASES_WITHOUT_PACKER 

293 if len(templates) == 1 and templates[0].key == "default": 

294 return _PHASES_WITH_PACKER 

295 

296 idx = next( 

297 (i for i, p in enumerate(_PHASES_WITH_PACKER) if p == PHASE_PACKER_INIT), 

298 0, 

299 ) 

300 prefix = tuple(_PHASES_WITH_PACKER[:idx]) 

301 suffix = tuple( 

302 p for p in _PHASES_WITH_PACKER[idx:] if p not in (PHASE_PACKER_INIT, PHASE_PACKER_VALIDATE, PHASE_PACKER_BUILD) 

303 ) 

304 packer_phases: list[str] = [] 

305 for t in templates: 

306 packer_phases.extend( 

307 [ 

308 f"{PHASE_PACKER_INIT}:{t.key}", 

309 f"{PHASE_PACKER_VALIDATE}:{t.key}", 

310 f"{PHASE_PACKER_BUILD}:{t.key}", 

311 ] 

312 ) 

313 return prefix + tuple(packer_phases) + suffix 

314 

315 

316# Destroy uses a shorter pipeline — no Packer (we don't need a fresh 

317# image to tear things down) and no plan (terraform destroy has its own 

318# planning step internally that we don't surface as its own progress 

319# phase). 

320_PHASES_DESTROY = ( 

321 PHASE_STARTING, 

322 PHASE_OPENSTACK_SETUP, 

323 PHASE_GIT_CLONE, 

324 PHASE_CREDS_MATERIALISE, 

325 PHASE_TERRAFORM_INIT, 

326 PHASE_TERRAFORM_DESTROY, 

327 PHASE_CLEANUP, 

328) 

329# Per-VM redeploy reuses the destroy preamble (git clone at the same 

330# release tag, materialise clouds.yaml, terraform init) and then runs 

331# ``terraform apply -replace=<addr> -target=<addr>`` instead of 

332# ``destroy``. The shape mirrors destroy exactly so the SSE phase bar 

333# in the UI stays familiar. 

334_PHASES_REDEPLOY = ( 

335 PHASE_STARTING, 

336 PHASE_OPENSTACK_SETUP, 

337 PHASE_GIT_CLONE, 

338 PHASE_CREDS_MATERIALISE, 

339 PHASE_TERRAFORM_INIT, 

340 PHASE_TERRAFORM_APPLY, 

341 PHASE_CLEANUP, 

342) 

343# Pause / resume share the destroy preamble — git clone at the same 

344# release tag, materialise clouds.yaml, terraform init so we can pull 

345# the canonical state from the pg backend. The hot phase is the 

346# server stop / start loop. CLEANUP runs the repo shred and (for the 

347# log) a final state pull, mirroring destroy's tail. 

348_PHASES_PAUSE = ( 

349 PHASE_STARTING, 

350 PHASE_OPENSTACK_SETUP, 

351 PHASE_GIT_CLONE, 

352 PHASE_CREDS_MATERIALISE, 

353 PHASE_TERRAFORM_INIT, 

354 PHASE_SERVER_STOP, 

355 PHASE_CLEANUP, 

356) 

357_PHASES_RESUME = ( 

358 PHASE_STARTING, 

359 PHASE_OPENSTACK_SETUP, 

360 PHASE_GIT_CLONE, 

361 PHASE_CREDS_MATERIALISE, 

362 PHASE_TERRAFORM_INIT, 

363 PHASE_SERVER_START, 

364 PHASE_CLEANUP, 

365) 

366 

367 

368class _PhaseTracker: 

369 """Drives ``StructuredLogger.progress`` calls. 

370 

371 The set of phases is fixed at construction time so the percent bar 

372 monotonically advances; ``mark()`` looks up the index of the named 

373 phase and sends a progress event with the correct ``idx/total``. 

374 """ 

375 

376 def __init__(self, logger: Any, phases: tuple[str, ...]): 

377 self._logger = logger 

378 self._phases = phases 

379 self._index_by_name = {name: i for i, name in enumerate(phases, start=1)} 

380 

381 @property 

382 def total(self) -> int: 

383 return len(self._phases) 

384 

385 def mark(self, phase_name: str, message: str = "") -> None: 

386 idx = self._index_by_name.get(phase_name) 

387 if idx is None: 

388 # Unknown phase — emit a transcript marker but no progress 

389 # update so the bar doesn't reset. 

390 self._logger.phase(phase_name) 

391 return 

392 # Buffer the readable phase header and emit the live progress event. 

393 # The full phase-name sequence rides on every event so the UI can 

394 # render each stepper slot with its real label immediately. 

395 self._logger.phase(phase_name) 

396 self._logger.progress( 

397 phase_name, 

398 idx, 

399 self.total, 

400 message, 

401 phase_names=self._phases, 

402 ) 

403 

404 

405def _terraform_executor(terraform_dir, openstack_env, tfstate_conn_str, tfstate_schema) -> TerraformExecutor: 

406 """Build a TerraformExecutor bound to the deployment's pg-backend schema.""" 

407 return TerraformExecutor( 

408 terraform_dir, 

409 env_vars=openstack_env, 

410 backend_conn_str=tfstate_conn_str, 

411 backend_schema_name=tfstate_schema, 

412 ) 

413 

414 

415def collect_terraform_state_helper( 

416 terraform_dir, openstack_env, tfstate_conn_str, tfstate_schema, task_logger, *, local_fallback=False 

417): 

418 """Snapshot the terraform state for the task row (best-effort). 

419 

420 With the pg backend the canonical state lives in Postgres; this 

421 snapshot is used for debugging only. When ``local_fallback`` is set 

422 (deploy path), a missing/empty pull falls back to reading the local 

423 ``terraform.tfstate`` file for legacy/test modes that don't configure 

424 a remote backend. Returns ``None`` when nothing could be read. 

425 """ 

426 if not (terraform_dir and os.path.exists(terraform_dir)): 

427 return None 

428 try: 

429 pulled = _terraform_executor(terraform_dir, openstack_env, tfstate_conn_str, tfstate_schema).state_pull() 

430 if pulled or not local_fallback: 

431 return pulled 

432 except Exception as e: 

433 task_logger.warning(f"Could not pull terraform state: {e}", category=LogCategory.WARNING) 

434 if not local_fallback: 

435 return None 

436 

437 # Legacy fallback — only relevant when no pg backend is configured. 

438 tfstate_path = os.path.join(terraform_dir, "terraform.tfstate") 

439 if os.path.exists(tfstate_path): 

440 try: 

441 with open(tfstate_path) as f: 

442 return f.read() 

443 except Exception as e: 

444 task_logger.warning(f"Could not read terraform state: {e}", category=LogCategory.WARNING) 

445 return None 

446 

447 

448def collect_terraform_outputs_helper(terraform_dir, openstack_env, tfstate_conn_str, tfstate_schema, task_logger): 

449 """Collect terraform outputs even on partial success. ``None`` on failure.""" 

450 if terraform_dir and os.path.exists(terraform_dir): 

451 try: 

452 return _terraform_executor(terraform_dir, openstack_env, tfstate_conn_str, tfstate_schema).output() 

453 except Exception as e: 

454 task_logger.warning(f"Could not read terraform outputs: {e}", category=LogCategory.WARNING) 

455 return None 

456 

457 

458def _extract_commit_info(repo_path: str) -> dict[str, Any]: 

459 """Read the checked-out commit's metadata from a cloned repo. 

460 

461 Returns the dict shape persisted into the task result and the 

462 ``Failure`` payload. Callers wrap this in their own try/except so a 

463 repo without a readable HEAD degrades to a warning, not a hard fail. 

464 """ 

465 repo = git.Repo(repo_path) 

466 commit = repo.head.commit 

467 return { 

468 "hash": commit.hexsha, 

469 "message": commit.message.strip(), 

470 "author": str(commit.author), 

471 "date": commit.committed_datetime.isoformat(), 

472 } 

473 

474 

475def _build_image_names(templates: list[_PackerTemplate], app_id: str, image_tag: str) -> dict[str, str]: 

476 """Reconstruct the per-template Glance image-name map. 

477 

478 Legacy single-template apps (or apps with no Packer at all) keep the 

479 flat ``{"default": "<app_id>-<tag>"}`` shape; multi-image apps get one 

480 ``<app_id>-<key>-<tag>`` entry per template. Used by destroy/redeploy, 

481 which must name the same images the original deploy built so 

482 Terraform's variable validation matches the pg-backend state. 

483 """ 

484 if not templates or (len(templates) == 1 and templates[0].key == "default"): 

485 return {"default": f"{app_id}-{image_tag}"} 

486 return {t.key: f"{app_id}-{t.key}-{image_tag}" for t in templates} 

487 

488 

489def _apply_image_name_vars(target: dict[str, Any], image_names: dict[str, str], *, legacy: bool) -> None: 

490 """Inject the image-name variable(s) into a Terraform var-set. 

491 

492 Legacy layout gets a single flat ``image_name``; multi-image apps get 

493 one ``image_name_<key>`` per template. ``legacy`` is decided by the 

494 caller so this stays a pure mapping of the existing branch bodies. 

495 """ 

496 if legacy: 

497 target["image_name"] = image_names["default"] 

498 else: 

499 for key, name in image_names.items(): 

500 target[f"image_name_{key}"] = name 

501 

502 

503def _cleanup_task_resources(clouds_config: PerTaskCloudsConfig | None, repo_path: str | None, task_logger: Any) -> None: 

504 """Best-effort teardown shared by every task's ``finally`` block. 

505 

506 Shreds the per-task clouds.yaml first (so the credential file is gone 

507 even if the repo cleanup below fails or hangs), then removes the 

508 cloned repo. Both steps swallow their own errors as warnings so the 

509 task's real result/exception is never masked by cleanup noise. 

510 """ 

511 if clouds_config is not None: 

512 try: 

513 clouds_config.__exit__(None, None, None) 

514 except Exception as e: 

515 task_logger.warning( 

516 f"Per-task clouds.yaml cleanup failed: {e}", 

517 category=LogCategory.WARNING, 

518 ) 

519 if repo_path: 

520 try: 

521 git_service.cleanup_repository(repo_path) 

522 task_logger.success("Repository cleanup completed", category=LogCategory.SYSTEM) 

523 except Exception as e: 

524 task_logger.warning(f"Repository cleanup failed: {e}", category=LogCategory.WARNING) 

525 

526 

527def _build_one_packer_image( 

528 tmpl, 

529 *, 

530 image_name, 

531 is_legacy, 

532 openstack_service, 

533 project_id, 

534 repo_path, 

535 openstack_env, 

536 stream_line, 

537 user_vars, 

538 phase_tracker, 

539 task_logger, 

540): 

541 """Build (or reuse) the Packer image for a single template. 

542 

543 Skips the build when the image already exists in Glance, and 

544 coordinates concurrent workers via ``PackerBuildLock`` (only one 

545 worker builds a given image; the others wait and reuse it). Raises 

546 ``Exception("Packer error: ...")`` on any failure. 

547 """ 

548 log_prefix = "" if is_legacy else f"[{tmpl.key}] " 

549 

550 # Phase names: legacy stays unsuffixed; multi-template apps get one 

551 # ``PHASE:<key>`` trio per template. 

552 init_phase = PHASE_PACKER_INIT if is_legacy else f"{PHASE_PACKER_INIT}:{tmpl.key}" 

553 validate_phase = PHASE_PACKER_VALIDATE if is_legacy else f"{PHASE_PACKER_VALIDATE}:{tmpl.key}" 

554 build_phase = PHASE_PACKER_BUILD if is_legacy else f"{PHASE_PACKER_BUILD}:{tmpl.key}" 

555 

556 build_lock = PackerBuildLock(project_id, image_name) 

557 wait_announced = False 

558 try: 

559 while True: 

560 # If the image already exists, skip the build and the lock. 

561 exists, image_id = openstack_service.check_image_exists(image_name) 

562 if exists: 

563 task_logger.success( 

564 f"{log_prefix}Image '{image_name}' already exists (ID: {image_id}). Skipping Packer build.", 

565 category=LogCategory.STATUS, 

566 ) 

567 break 

568 

569 held = build_lock.acquire_or_wait() 

570 if not held: 

571 # Another worker is still building the same image. Surface 

572 # this in the per-deployment log once so the frontend's 

573 # live tail shows *something* during the 5-second poll 

574 # cycles — without it the browser sees no events and looks 

575 # frozen. 

576 if not wait_announced: 

577 task_logger.info( 

578 f"{log_prefix}Another worker is currently building image '{image_name}'. Waiting…", 

579 category=LogCategory.STATUS, 

580 ) 

581 wait_announced = True 

582 # We slept inside acquire_or_wait; re-check Glance. 

583 continue 

584 

585 # Re-check after acquiring: another worker may have finished its 

586 # build between our last check and our lock acquisition. 

587 exists, image_id = openstack_service.check_image_exists(image_name) 

588 if exists: 

589 task_logger.success( 

590 f"{log_prefix}Image '{image_name}' built by another worker (ID: {image_id}). Skipping.", 

591 category=LogCategory.STATUS, 

592 ) 

593 break 

594 

595 task_logger.info( 

596 f"{log_prefix}Image '{image_name}' does not exist. Building...", 

597 category=LogCategory.OPERATION, 

598 ) 

599 

600 # Pick the right packer working directory: legacy uses 

601 # ``packer/`` directly; multi uses ``packer/<key>/``. Template 

602 # file name is always ``template.pkr.hcl`` relative to that 

603 # directory. 

604 packer_dir = os.path.join(repo_path, "packer") if is_legacy else os.path.join(repo_path, "packer", tmpl.key) 

605 packer = PackerExecutor( 

606 packer_dir, 

607 env_vars=openstack_env, 

608 output_callback=stream_line, 

609 ) 

610 

611 # Per-template Packer variables. Legacy shape is the flat 

612 # ``user_vars["packer"][var_name]``; multi shape is nested 

613 # ``user_vars["packer"][template_key][var_name]``. 

614 if is_legacy: 

615 user_packer = user_vars.get("packer", {}) 

616 else: 

617 user_packer = (user_vars.get("packer") or {}).get(tmpl.key, {}) or {} 

618 packer_vars = {**user_packer} 

619 packer_vars["image_name"] = image_name 

620 packer_vars = encode_packer_vars(packer_vars) 

621 

622 task_logger.info( 

623 f"{log_prefix}Packer variable keys", 

624 category=LogCategory.OPERATION, 

625 keys=list(packer_vars.keys()), 

626 template=tmpl.key, 

627 image_name=image_name, 

628 ) 

629 

630 phase_tracker.mark(init_phase, f"{log_prefix}Initializing Packer plugins") 

631 success, stdout, stderr = packer.init() 

632 if not success: 

633 if stdout: 

634 task_logger.command_output("packer_init_stdout", stdout, returncode=1) 

635 if stderr: 

636 task_logger.command_output("packer_init_stderr", stderr, returncode=1) 

637 raise Exception(f"{log_prefix}Packer init failed") 

638 

639 phase_tracker.mark(validate_phase, f"{log_prefix}Validating Packer template") 

640 success, stdout, stderr = packer.validate("template.pkr.hcl", packer_vars) 

641 if not success: 

642 raise Exception(f"{log_prefix}Packer validation failed: {stderr}") 

643 

644 phase_tracker.mark( 

645 build_phase, 

646 f"{log_prefix}Building image '{image_name}' (this may take minutes)", 

647 ) 

648 success, output = packer.build("template.pkr.hcl", packer_vars) 

649 if not success: 

650 raise Exception(f"{log_prefix}Packer build failed: {output}") 

651 

652 task_logger.success( 

653 f"{log_prefix}Image '{image_name}' built successfully", 

654 category=LogCategory.STATUS, 

655 ) 

656 break 

657 except Exception as e: 

658 raise Exception(f"Packer error: {str(e)}") 

659 finally: 

660 build_lock.release() 

661 

662 

663@celery_app.task(bind=True, name="tasks.deploy_application") 

664def deploy_application( 

665 self, 

666 deployment_id: str, 

667 app_id: str, 

668 app_git_link: str, 

669 release: str, 

670 user_vars: dict[str, Any], 

671 teams: dict[str, list] = None, 

672 openstack_envelope: dict[str, Any] | None = None, 

673): 

674 """ 

675 Deploy an application using Terraform and Packer 

676 

677 Args: 

678 deployment_id: UUID of the deployment 

679 app_git_link: Git repo URL 

680 release: Tag/Release to checkout 

681 user_vars: User variables for Packer/Terraform 

682 teams: Teams with user emails {"team_name": [{"email": "user@example.com"}]} 

683 openstack_envelope: Encrypted per-user OpenStack credential envelope 

684 shipped from the backend. Required for new deploys; the optional 

685 default exists only so older queued messages don't crash the 

686 worker on rollout (we raise immediately if it's missing). 

687 

688 Returns: 

689 dict: status, logs, tf_state, commit_info, terraform_outputs 

690 """ 

691 task_logger = get_logger(f"deploy:{deployment_id}", correlation_id=deployment_id) 

692 

693 # Wire the per-deployment logger to Celery's event bus. Every buffered 

694 # log entry now becomes a ``task-log`` event, and ``task_logger.progress`` 

695 # emits ``task-progress``. The backend's listener picks both up and 

696 # forwards them via the in-process pubsub to any open SSE subscriber. 

697 bound_task = self 

698 

699 def _emit(event_name: str, payload: dict[str, Any]) -> None: 

700 # ``deployment_id`` is duplicated into every event so the backend 

701 # listener doesn't need a DB lookup to figure out which deployment 

702 # the event belongs to. 

703 bound_task.send_event(event_name, deployment_id=deployment_id, **payload) 

704 

705 task_logger.set_event_emitter(_emit) 

706 

707 # Pessimistic phase set — assumes Packer. Demoted after git clone if 

708 # the cloned repo turns out to have no Packer template. 

709 phase_tracker = _PhaseTracker(task_logger, _PHASES_WITH_PACKER) 

710 

711 repo_path = None 

712 tf_state = None 

713 outputs = None 

714 commit_info = None 

715 terraform_dir = None 

716 openstack_env: dict[str, str] = {} 

717 clouds_config: PerTaskCloudsConfig | None = None 

718 

719 # Terraform's pg backend lives in a worker-only Postgres. Configured 

720 # at deploy/destroy time by writing a `pg_backend_override.tf` next 

721 # to the cloned repo's terraform/ directory. One schema per 

722 # deployment isolates state and locks. 

723 tfstate_conn_str = settings.TFSTATE_DATABASE_URL or None 

724 tfstate_schema = _tfstate_schema_name(deployment_id) 

725 

726 # Default teams to empty dict if not provided 

727 if teams is None: 

728 teams = {} 

729 

730 def collect_terraform_state(): 

731 return collect_terraform_state_helper( 

732 terraform_dir, openstack_env, tfstate_conn_str, tfstate_schema, task_logger, local_fallback=True 

733 ) 

734 

735 def collect_terraform_outputs(): 

736 return collect_terraform_outputs_helper( 

737 terraform_dir, openstack_env, tfstate_conn_str, tfstate_schema, task_logger 

738 ) 

739 

740 try: 

741 phase_tracker.mark(PHASE_STARTING, "Starting deployment") 

742 task_logger.resource_info( 

743 "deployment", 

744 deployment_id, 

745 app_id=app_id, 

746 git_url=app_git_link, 

747 release=release, 

748 user_vars_keys=list(user_vars.keys()), 

749 teams_keys=list(teams.keys()), 

750 ) 

751 

752 # Phase 1: OpenStack credentials (envelope only — materialised after 

753 # the git clone so the per-task clouds.yaml lives inside repo_path). 

754 phase_tracker.mark(PHASE_OPENSTACK_SETUP, "Validating OpenStack credentials") 

755 if not openstack_envelope: 

756 raise Exception("OpenStack credential envelope missing — user must upload credentials before deploying") 

757 task_logger.success( 

758 "OpenStack credential envelope received", 

759 category=LogCategory.STATUS, 

760 ) 

761 

762 # Phase 2: Git clone 

763 phase_tracker.mark(PHASE_GIT_CLONE, "Cloning repository") 

764 task_logger.info(f"Cloning repository: {app_git_link}", category=LogCategory.OPERATION) 

765 try: 

766 repo_path = git_service.clone_release(git_url=app_git_link, deployment_id=deployment_id, tag=release) 

767 

768 # Get commit info 

769 try: 

770 commit_info = _extract_commit_info(repo_path) 

771 task_logger.resource_info( 

772 "git_commit", 

773 commit_info["hash"][:8], 

774 hash=commit_info["hash"], 

775 message=commit_info["message"], 

776 author=commit_info["author"], 

777 ) 

778 task_logger.success( 

779 f"Repository cloned at commit {commit_info['hash'][:8]}", category=LogCategory.STATUS 

780 ) 

781 except Exception as e: 

782 task_logger.warning(f"Could not extract commit info: {e}", category=LogCategory.WARNING) 

783 

784 except Exception as e: 

785 raise Exception(f"Git clone failed: {str(e)}") 

786 

787 # Materialise the per-task clouds.yaml inside repo_path with mode 0600. 

788 # Lives only for the duration of this task; shredded by __exit__. 

789 phase_tracker.mark(PHASE_CREDS_MATERIALISE, "Writing per-task clouds.yaml") 

790 task_logger.operation_start("openstack_credentials_materialise") 

791 clouds_config = PerTaskCloudsConfig(openstack_envelope, work_dir=repo_path) 

792 openstack_env = clouds_config.__enter__() 

793 task_logger.operation_end("openstack_credentials_materialise", success=True) 

794 task_logger.success( 

795 "Per-task clouds.yaml written", 

796 category=LogCategory.STATUS, 

797 ) 

798 

799 # Cache the built image by commit SHA, not by release tag: 

800 # `release` is often a moving ref (e.g. "main"), so the 

801 # content-addressed short SHA ensures a new commit misses the cache. 

802 # Multi-image apps name each template's image ``<app_id>-<key>-<tag>``; 

803 # legacy single-template apps keep the flat ``<app_id>-<tag>`` shape. 

804 try: 

805 templates = _discover_packer_templates(repo_path) 

806 except PackerTemplateDiscoveryError as e: 

807 raise Exception(f"Packer template discovery failed: {e}") 

808 

809 image_tag = commit_info["hash"][:8] if commit_info and commit_info.get("hash") else release 

810 if len(templates) == 1 and templates[0].key == "default": 

811 image_names = {"default": f"{app_id}-{image_tag}"} 

812 else: 

813 image_names = {t.key: f"{app_id}-{t.key}-{image_tag}" for t in templates} 

814 

815 # Decide once whether this deployment needs a Packer build, and 

816 # adapt the phase total accordingly so the percent bar is honest. 

817 # The pessimistic default (assume Packer) was set at task start; 

818 # if the cloned repo has no Packer template we drop those three 

819 # phases now so the next progress event lands on the right index. 

820 # For multi-image apps, ``_phases_for_templates`` expands the 

821 # Packer phases per template instead. 

822 phase_tracker = _PhaseTracker(task_logger, _phases_for_templates(templates)) 

823 

824 # The output callback feeds each line of subprocess output into the 

825 # task logger as a streaming entry, which then ships it via the 

826 # event emitter as a ``task-log`` event. Same callback for Packer 

827 # and Terraform — the tool name distinguishes them on the receiver. 

828 def _stream_line(tool: str, line: str) -> None: 

829 task_logger.tool_output_line(tool, line) 

830 

831 # Phase 3: Packer (optional) — guarded by a Redis lock keyed on 

832 # (project_id, image_name) so two parallel workers can't both kick 

833 # off a build for the same image and end up with duplicate Glance 

834 # entries plus wasted compute. For multi-image apps each template 

835 # has its own lock + image-exists check, so two workers can build 

836 # different images of the same app in parallel. 

837 if not templates: 

838 task_logger.info("No Packer template found, skipping image build", category=LogCategory.SYSTEM) 

839 else: 

840 project_id = openstack_envelope.get("project_id") or openstack_envelope.get("project_name") or "default" 

841 openstack_service = OpenStackService(env_vars=openstack_env) 

842 is_legacy = len(templates) == 1 and templates[0].key == "default" 

843 

844 for tmpl in templates: 

845 _build_one_packer_image( 

846 tmpl, 

847 image_name=image_names[tmpl.key], 

848 is_legacy=is_legacy, 

849 openstack_service=openstack_service, 

850 project_id=project_id, 

851 repo_path=repo_path, 

852 openstack_env=openstack_env, 

853 stream_line=_stream_line, 

854 user_vars=user_vars, 

855 phase_tracker=phase_tracker, 

856 task_logger=task_logger, 

857 ) 

858 

859 # Phase 4: Terraform 

860 terraform_dir = os.path.join(repo_path, "terraform") 

861 if not os.path.exists(terraform_dir): 

862 raise Exception(f"Terraform directory not found at {terraform_dir}") 

863 

864 terraform = None 

865 terraform_vars: dict[str, Any] = {} 

866 try: 

867 terraform = TerraformExecutor( 

868 terraform_dir, 

869 env_vars=openstack_env, 

870 backend_conn_str=tfstate_conn_str, 

871 backend_schema_name=tfstate_schema, 

872 output_callback=_stream_line, 

873 ) 

874 

875 phase_tracker.mark(PHASE_TERRAFORM_INIT, "Initializing Terraform") 

876 success, stdout, stderr = terraform.init() 

877 if not success: 

878 # Surface the real reason in the per-deployment log; the 

879 # module-level logger only writes to worker stdout, which the 

880 # frontend never sees. 

881 if stdout: 

882 task_logger.command_output("terraform_init_stdout", stdout, returncode=1) 

883 if stderr: 

884 task_logger.command_output("terraform_init_stderr", stderr, returncode=1) 

885 task_logger.error("Terraform init failed", category=LogCategory.ERROR) 

886 raise Exception("Terraform init failed") 

887 task_logger.success("Terraform initialization completed", category=LogCategory.STATUS) 

888 

889 # Merge user_vars with teams for Terraform. Nested structures 

890 # pass through encode_terraform_vars unchanged. 

891 terraform_vars = {**user_vars["terraform"]} if "terraform" in user_vars else {} 

892 # Per-template image-name injection. Legacy single-template 

893 # apps see a flat ``image_name``; multi-image apps declare one 

894 # ``image_name_<key>`` per template, filled here. 

895 _apply_image_name_vars( 

896 terraform_vars, 

897 image_names, 

898 legacy=len(templates) == 1 and templates[0].key == "default", 

899 ) 

900 if teams: 

901 terraform_vars["users"] = teams 

902 terraform_vars = encode_terraform_vars(terraform_vars) 

903 

904 # File-upload variables can balloon a single -var to hundreds 

905 # of KB. The Nova metadata service caps cloud-init user_data at 

906 # ~64 KB compressed, so warn per-variable above 120 KB to land 

907 # the heads-up in the worker log before a boot failure. 

908 _log_bytes_per_var_warn = 120 * 1024 

909 for _vname, _vstr in terraform_vars.items(): 

910 if isinstance(_vstr, str) and len(_vstr) > _log_bytes_per_var_warn: 

911 task_logger.warning( 

912 f"Terraform variable '{_vname}' is " 

913 f"{len(_vstr) // 1024} KB encoded — close to the " 

914 "cloud-init user_data limit; the VM may fail to " 

915 "boot if the template inlines the full value.", 

916 category=LogCategory.WARNING, 

917 ) 

918 

919 task_logger.info( 

920 "Terraform variable keys", 

921 category=LogCategory.OPERATION, 

922 keys=list(terraform_vars.keys()), 

923 ) 

924 

925 phase_tracker.mark(PHASE_TERRAFORM_PLAN, "Planning Terraform deployment") 

926 success, stdout, stderr = terraform.plan(variables=terraform_vars) 

927 if not success: 

928 if stdout: 

929 task_logger.command_output("terraform_plan_stdout", stdout, returncode=1) 

930 if stderr: 

931 task_logger.command_output("terraform_plan_stderr", stderr, returncode=1) 

932 task_logger.error("Terraform plan failed", category=LogCategory.ERROR) 

933 raise Exception("Terraform plan failed") 

934 task_logger.success("Terraform plan completed successfully", category=LogCategory.STATUS) 

935 

936 phase_tracker.mark(PHASE_TERRAFORM_APPLY, "Applying configuration (this may take minutes)") 

937 success, stdout, stderr = terraform.apply(variables=terraform_vars) 

938 if not success: 

939 if stdout: 

940 task_logger.command_output("terraform_apply_stdout", stdout, returncode=1) 

941 if stderr: 

942 task_logger.command_output("terraform_apply_stderr", stderr, returncode=1) 

943 task_logger.error("Terraform apply failed", category=LogCategory.ERROR) 

944 raise Exception("Terraform apply failed") 

945 task_logger.success("Terraform resources created", category=LogCategory.STATUS) 

946 

947 # Collect outputs and state 

948 phase_tracker.mark(PHASE_OUTPUTS_AND_CLEANUP, "Collecting outputs") 

949 outputs = collect_terraform_outputs() 

950 tf_state = collect_terraform_state() 

951 

952 if outputs: 

953 task_logger.info( 

954 "Terraform deployment outputs collected", category=LogCategory.OPERATION, output_count=len(outputs) 

955 ) 

956 

957 except Exception as e: 

958 # Try to collect partial results even on failure 

959 tf_state = collect_terraform_state() 

960 outputs = collect_terraform_outputs() 

961 

962 # Best-effort cleanup: a half-finished `terraform apply` typically 

963 # leaves orphaned OpenStack resources (networks, ports, volumes) 

964 # that quietly eat the project's quota. Run destroy with the same 

965 # variables so the apply graph can be reversed; ignore failures 

966 # here — we're already in the error path and re-raising below. 

967 if terraform is not None and terraform_dir and os.path.exists(terraform_dir): 

968 try: 

969 task_logger.info( 

970 "Running terraform destroy to clean up partially-applied resources", 

971 category=LogCategory.OPERATION, 

972 ) 

973 # Rebuild the var-set from the raw user_vars without 

974 # the file payloads. Destroy doesn't need the 

975 # cloud-init bytes, but Terraform validates every 

976 # declared var on every run, so an apply-only file-var 

977 # would otherwise reject the cleanup with a schema error. 

978 cleanup_tf_vars = _strip_file_vars(user_vars.get("terraform") or {}) 

979 _apply_image_name_vars( 

980 cleanup_tf_vars, 

981 image_names, 

982 legacy=len(templates) == 1 and templates[0].key == "default", 

983 ) 

984 if teams: 

985 cleanup_tf_vars["users"] = teams 

986 terraform.destroy(variables=encode_terraform_vars(cleanup_tf_vars)) 

987 # Refresh state after destroy so the persisted record reflects cleanup. 

988 tf_state = collect_terraform_state() 

989 except Exception as cleanup_error: 

990 task_logger.warning( 

991 f"Terraform cleanup failed: {cleanup_error}", 

992 category=LogCategory.WARNING, 

993 ) 

994 

995 raise Exception(f"Terraform error: {str(e)}") 

996 

997 # The OUTPUTS_AND_CLEANUP progress event was already emitted above; 

998 # a second mark would land on the same index, so just log success. 

999 task_logger.success(f"Deployment {deployment_id} completed successfully", category=LogCategory.STATUS) 

1000 

1001 # Log summary 

1002 summary = task_logger.get_summary() 

1003 task_logger.info("Deployment summary", category=LogCategory.SYSTEM, **summary) 

1004 

1005 if outputs: 

1006 task_logger.info("Terraform deployment output", category=LogCategory.SYSTEM, **outputs) 

1007 

1008 result = { 

1009 "status": "success", 

1010 "deployment_id": deployment_id, 

1011 "logs": task_logger.get_logs_dict(), 

1012 "tf_state": tf_state, 

1013 "commit_info": commit_info, 

1014 "terraform_outputs": outputs, 

1015 } 

1016 

1017 # Return result (sent via task-succeeded event) 

1018 return result 

1019 

1020 except Exception as e: 

1021 task_logger.exception(f"Deployment failed: {str(e)}", exception=e, deployment_id=deployment_id) 

1022 

1023 # Try to collect any available state/outputs even on failure 

1024 if not tf_state: 

1025 tf_state = collect_terraform_state() 

1026 if not outputs: 

1027 outputs = collect_terraform_outputs() 

1028 

1029 # Raise custom exception with all details 

1030 raise Failure( 

1031 message=str(e), 

1032 deployment_id=deployment_id, 

1033 logs_dict=task_logger.get_logs_dict(), 

1034 tf_state=tf_state, 

1035 commit_info=commit_info, 

1036 terraform_outputs=outputs, 

1037 ) 

1038 

1039 finally: 

1040 # Shred the per-task clouds.yaml first so the credential file is gone 

1041 # even if the repository cleanup below fails or hangs. 

1042 _cleanup_task_resources(clouds_config, repo_path, task_logger) 

1043 

1044 

1045@celery_app.task(bind=True, name="tasks.destroy_deployment") 

1046def destroy_deployment( 

1047 self, 

1048 deployment_id: str, 

1049 app_id: str, 

1050 app_git_link: str, 

1051 release: str, 

1052 user_vars: dict[str, Any], 

1053 teams: dict[str, list] = None, 

1054 openstack_envelope: dict[str, Any] | None = None, 

1055): 

1056 """Tear down a deployment via ``terraform destroy``. 

1057 

1058 Mirrors ``deploy_application``'s setup (git clone at the same release 

1059 tag, materialise the per-task clouds.yaml, configure the same pg 

1060 backend schema) so Terraform sees the exact same state it built. 

1061 Then runs ``terraform destroy -auto-approve`` instead of 

1062 ``plan + apply``. Same Packer image is left in Glance so a future 

1063 deploy of the same commit doesn't have to rebuild it. 

1064 

1065 All progress and log events flow through the same ``StructuredLogger`` 

1066 + Celery custom-event pipeline as deploy, so the frontend's live 

1067 SSE stream renders the destroy run identically to a deploy. 

1068 

1069 Args mirror ``deploy_application`` so the backend can re-dispatch 

1070 the same persisted values without translation. 

1071 """ 

1072 task_logger = get_logger(f"destroy:{deployment_id}", correlation_id=deployment_id) 

1073 

1074 bound_task = self 

1075 

1076 def _emit(event_name: str, payload: dict[str, Any]) -> None: 

1077 bound_task.send_event(event_name, deployment_id=deployment_id, **payload) 

1078 

1079 task_logger.set_event_emitter(_emit) 

1080 phase_tracker = _PhaseTracker(task_logger, _PHASES_DESTROY) 

1081 

1082 repo_path = None 

1083 tf_state: str | None = None 

1084 commit_info: dict[str, Any] | None = None 

1085 terraform_dir: str | None = None 

1086 openstack_env: dict[str, str] = {} 

1087 clouds_config: PerTaskCloudsConfig | None = None 

1088 

1089 tfstate_conn_str = settings.TFSTATE_DATABASE_URL or None 

1090 tfstate_schema = _tfstate_schema_name(deployment_id) 

1091 

1092 if teams is None: 

1093 teams = {} 

1094 

1095 def _stream_line(tool: str, line: str) -> None: 

1096 task_logger.tool_output_line(tool, line) 

1097 

1098 def collect_terraform_state(): 

1099 return collect_terraform_state_helper( 

1100 terraform_dir, openstack_env, tfstate_conn_str, tfstate_schema, task_logger 

1101 ) 

1102 

1103 try: 

1104 phase_tracker.mark(PHASE_STARTING, "Starting destroy") 

1105 task_logger.resource_info( 

1106 "deployment", 

1107 deployment_id, 

1108 app_id=app_id, 

1109 git_url=app_git_link, 

1110 release=release, 

1111 user_vars_keys=list(user_vars.keys()), 

1112 teams_keys=list(teams.keys()), 

1113 action="destroy", 

1114 ) 

1115 

1116 phase_tracker.mark(PHASE_OPENSTACK_SETUP, "Validating OpenStack credentials") 

1117 if not openstack_envelope: 

1118 raise Exception("OpenStack credential envelope missing — cannot destroy without credentials") 

1119 task_logger.success("OpenStack credential envelope received", category=LogCategory.STATUS) 

1120 

1121 phase_tracker.mark(PHASE_GIT_CLONE, "Cloning repository at original release tag") 

1122 task_logger.info( 

1123 f"Cloning {app_git_link} at {release} (same ref as the original deploy " 

1124 "so terraform code matches the pg-backend state)", 

1125 category=LogCategory.OPERATION, 

1126 ) 

1127 try: 

1128 repo_path = git_service.clone_release(git_url=app_git_link, deployment_id=deployment_id, tag=release) 

1129 try: 

1130 commit_info = _extract_commit_info(repo_path) 

1131 task_logger.resource_info( 

1132 "git_commit", 

1133 commit_info["hash"][:8], 

1134 hash=commit_info["hash"], 

1135 message=commit_info["message"], 

1136 author=commit_info["author"], 

1137 ) 

1138 task_logger.success( 

1139 f"Repository cloned at commit {commit_info['hash'][:8]}", category=LogCategory.STATUS 

1140 ) 

1141 except Exception as e: 

1142 task_logger.warning(f"Could not extract commit info: {e}", category=LogCategory.WARNING) 

1143 except Exception as e: 

1144 raise Exception(f"Git clone failed: {str(e)}") 

1145 

1146 phase_tracker.mark(PHASE_CREDS_MATERIALISE, "Writing per-task clouds.yaml") 

1147 clouds_config = PerTaskCloudsConfig(openstack_envelope, work_dir=repo_path) 

1148 openstack_env = clouds_config.__enter__() 

1149 task_logger.success("Per-task clouds.yaml written", category=LogCategory.STATUS) 

1150 

1151 # Reconstruct the same image_name map the deploy task used so 

1152 # the variables match what terraform's state expects to 

1153 # validate. Glance still has the image(s), even if we won't be 

1154 # using them; the variable just has to be a non-empty string 

1155 # that satisfies the HCL declaration. For multi-image apps we 

1156 # discover templates here too so the right ``image_name_<key>`` 

1157 # suffix is injected per template. 

1158 try: 

1159 templates = _discover_packer_templates(repo_path) 

1160 except PackerTemplateDiscoveryError as e: 

1161 raise Exception(f"Packer template discovery failed: {e}") 

1162 

1163 image_tag = commit_info["hash"][:8] if commit_info and commit_info.get("hash") else release 

1164 image_names = _build_image_names(templates, app_id, image_tag) 

1165 

1166 terraform_dir = os.path.join(repo_path, "terraform") 

1167 if not os.path.exists(terraform_dir): 

1168 raise Exception(f"Terraform directory not found at {terraform_dir}") 

1169 

1170 # Drop ``@openstack:file:*`` variable values before passing 

1171 # the var-set to terraform destroy. Files are only consumed 

1172 # at apply-time (cloud-init write_files); destroy doesn't 

1173 # need them, but Terraform validates every declared var on 

1174 # every run. 

1175 terraform_vars = {**user_vars["terraform"]} if "terraform" in user_vars else {} 

1176 terraform_vars = _strip_file_vars(terraform_vars) 

1177 # Inject the per-template image-name variables. Legacy single 

1178 # template (or no Packer at all) keeps the flat ``image_name``; 

1179 # multi-template apps get one ``image_name_<key>`` per template. 

1180 _apply_image_name_vars( 

1181 terraform_vars, 

1182 image_names, 

1183 legacy=not templates or (len(templates) == 1 and templates[0].key == "default"), 

1184 ) 

1185 if teams: 

1186 terraform_vars["users"] = teams 

1187 terraform_vars = encode_terraform_vars(terraform_vars) 

1188 

1189 terraform = TerraformExecutor( 

1190 terraform_dir, 

1191 env_vars=openstack_env, 

1192 backend_conn_str=tfstate_conn_str, 

1193 backend_schema_name=tfstate_schema, 

1194 output_callback=_stream_line, 

1195 ) 

1196 

1197 phase_tracker.mark(PHASE_TERRAFORM_INIT, "Initializing Terraform") 

1198 success, stdout, stderr = terraform.init() 

1199 if not success: 

1200 if stdout: 

1201 task_logger.command_output("terraform_init_stdout", stdout, returncode=1) 

1202 if stderr: 

1203 task_logger.command_output("terraform_init_stderr", stderr, returncode=1) 

1204 raise Exception("Terraform init failed") 

1205 task_logger.success("Terraform initialization completed", category=LogCategory.STATUS) 

1206 

1207 phase_tracker.mark(PHASE_TERRAFORM_DESTROY, "Destroying resources") 

1208 success, stdout, stderr = terraform.destroy(variables=terraform_vars) 

1209 # A data source (e.g. the Glance image lookup) is re-read on every 

1210 # destroy refresh. If that image/network was deleted out-of-band, 

1211 # the refresh fails with "Your query returned no results" before any 

1212 # managed resource is touched. Retry once with -refresh=false so the 

1213 # teardown proceeds purely from state. Scoped to this exact error so 

1214 # genuine destroy failures still surface. 

1215 if not success and "Your query returned no results" in f"{stdout or ''}{stderr or ''}": 

1216 task_logger.warning( 

1217 "Destroy blocked by a stale data source (image/network deleted " 

1218 "out-of-band). Retrying with -refresh=false — resources are torn " 

1219 "down from state.", 

1220 category=LogCategory.WARNING, 

1221 ) 

1222 success, stdout, stderr = terraform.destroy(variables=terraform_vars, refresh=False) 

1223 if not success: 

1224 if stdout: 

1225 task_logger.command_output("terraform_destroy_stdout", stdout, returncode=1) 

1226 if stderr: 

1227 task_logger.command_output("terraform_destroy_stderr", stderr, returncode=1) 

1228 raise Exception("Terraform destroy failed") 

1229 task_logger.success("Terraform resources destroyed", category=LogCategory.STATUS) 

1230 

1231 phase_tracker.mark(PHASE_CLEANUP, "Pulling final state") 

1232 tf_state = collect_terraform_state() 

1233 

1234 task_logger.success(f"Deployment {deployment_id} destroyed successfully", category=LogCategory.STATUS) 

1235 

1236 summary = task_logger.get_summary() 

1237 task_logger.info("Destroy summary", category=LogCategory.SYSTEM, **summary) 

1238 

1239 return { 

1240 "status": "success", 

1241 "deployment_id": deployment_id, 

1242 "logs": task_logger.get_logs_dict(), 

1243 "tf_state": tf_state, 

1244 "commit_info": commit_info, 

1245 # No outputs — destroy doesn't produce any. Field is kept for 

1246 # event-listener parity with deploy_application's payload. 

1247 "terraform_outputs": {}, 

1248 } 

1249 

1250 except Exception as e: 

1251 task_logger.exception(f"Destroy failed: {str(e)}", exception=e, deployment_id=deployment_id) 

1252 if not tf_state: 

1253 tf_state = collect_terraform_state() 

1254 raise Failure( 

1255 message=str(e), 

1256 deployment_id=deployment_id, 

1257 logs_dict=task_logger.get_logs_dict(), 

1258 tf_state=tf_state, 

1259 commit_info=commit_info, 

1260 terraform_outputs={}, 

1261 ) 

1262 

1263 finally: 

1264 _cleanup_task_resources(clouds_config, repo_path, task_logger) 

1265 

1266 

1267# ---------------------------------------------------------------- 

1268# PAUSE / RESUME — compute-instance-only lifecycle 

1269# ---------------------------------------------------------------- 

1270# 

1271# Both tasks share the destroy preamble (git clone at the same release 

1272# tag → per-task clouds.yaml → terraform init pointed at the pg backend) 

1273# so we can pull the canonical terraform state and read back which 

1274# compute instances belong to this deployment. The hot phase is a 

1275# CLI-driven stop/start loop; terraform state is left untouched. Server 

1276# discovery goes through the state (not tags) so no app template needs 

1277# to opt in, and CLI idempotency lets the loop re-run safely on retry. 

1278 

1279 

1280def _extract_compute_instance_ids(state_json: str | None) -> list[str]: 

1281 """Return server IDs from a terraform pg-backend state dump. 

1282 

1283 Terraform's serialised state shape is 

1284 ``{"resources": [{"type": "...", "instances": [{"attributes": {"id": "..."}}]}]}``. 

1285 Filtered to ``openstack_compute_instance_v2`` so we only stop/start 

1286 Nova servers, not volumes / networks / security groups. 

1287 

1288 Returns an empty list on any parsing trouble — the caller can then 

1289 decide whether "no servers found" is a hard error (deploy never 

1290 actually ran) or a no-op success (everything already torn down). 

1291 """ 

1292 if not state_json: 

1293 return [] 

1294 try: 

1295 state = json.loads(state_json) if isinstance(state_json, str) else state_json 

1296 except (TypeError, json.JSONDecodeError): 

1297 return [] 

1298 

1299 ids: list[str] = [] 

1300 for resource in state.get("resources", []): 

1301 if resource.get("type") != "openstack_compute_instance_v2": 

1302 continue 

1303 for instance in resource.get("instances", []): 

1304 attrs = instance.get("attributes") or {} 

1305 sid = attrs.get("id") 

1306 if sid: 

1307 ids.append(sid) 

1308 return ids 

1309 

1310 

1311def _run_compute_lifecycle( 

1312 self, 

1313 deployment_id: str, 

1314 app_id: str, 

1315 app_git_link: str, 

1316 release: str, 

1317 user_vars: dict[str, Any], 

1318 teams: dict[str, list] | None, 

1319 openstack_envelope: dict[str, Any] | None, 

1320 *, 

1321 action: str, # "pause" | "resume" — only for log/error labels 

1322 phases: tuple[str, ...], 

1323 server_phase: str, 

1324 server_op: str, # "stop" | "start" 

1325): 

1326 """Shared body for ``pause_deployment`` / ``resume_deployment``. 

1327 

1328 Mirrors :func:`destroy_deployment`'s preamble exactly so the two 

1329 paths stay easy to reason about. Diverges only at the hot phase: 

1330 instead of ``terraform destroy``, we pull the state, extract 

1331 every compute instance's ID, and shell out to 

1332 ``openstack server stop|start`` for each. 

1333 

1334 Failures during the per-server loop are accumulated and re-raised 

1335 once with a list of which servers failed — so the user sees 

1336 "stopped 4/5; failed: web-1: locked task" instead of just "pause 

1337 failed" without any pointer to which instance is stuck. 

1338 """ 

1339 label = f"{action}:{deployment_id}" 

1340 task_logger = get_logger(label, correlation_id=deployment_id) 

1341 

1342 bound_task = self 

1343 

1344 def _emit(event_name: str, payload: dict[str, Any]) -> None: 

1345 bound_task.send_event(event_name, deployment_id=deployment_id, **payload) 

1346 

1347 task_logger.set_event_emitter(_emit) 

1348 phase_tracker = _PhaseTracker(task_logger, phases) 

1349 

1350 repo_path = None 

1351 terraform_dir: str | None = None 

1352 openstack_env: dict[str, str] = {} 

1353 clouds_config: PerTaskCloudsConfig | None = None 

1354 

1355 tfstate_conn_str = settings.TFSTATE_DATABASE_URL or None 

1356 tfstate_schema = _tfstate_schema_name(deployment_id) 

1357 

1358 if teams is None: 

1359 teams = {} 

1360 

1361 def _stream_line(tool: str, line: str) -> None: 

1362 task_logger.tool_output_line(tool, line) 

1363 

1364 try: 

1365 phase_tracker.mark(PHASE_STARTING, f"Starting {action}") 

1366 task_logger.resource_info( 

1367 "deployment", 

1368 deployment_id, 

1369 app_id=app_id, 

1370 git_url=app_git_link, 

1371 release=release, 

1372 action=action, 

1373 ) 

1374 

1375 phase_tracker.mark(PHASE_OPENSTACK_SETUP, "Validating OpenStack credentials") 

1376 if not openstack_envelope: 

1377 raise Exception(f"OpenStack credential envelope missing — cannot {action} without credentials") 

1378 task_logger.success("OpenStack credential envelope received", category=LogCategory.STATUS) 

1379 

1380 phase_tracker.mark(PHASE_GIT_CLONE, "Cloning repository at original release tag") 

1381 try: 

1382 repo_path = git_service.clone_release( 

1383 git_url=app_git_link, 

1384 deployment_id=deployment_id, 

1385 tag=release, 

1386 ) 

1387 task_logger.success("Repository cloned", category=LogCategory.STATUS) 

1388 except Exception as e: 

1389 raise Exception(f"Git clone failed: {str(e)}") 

1390 

1391 phase_tracker.mark(PHASE_CREDS_MATERIALISE, "Writing per-task clouds.yaml") 

1392 clouds_config = PerTaskCloudsConfig(openstack_envelope, work_dir=repo_path) 

1393 openstack_env = clouds_config.__enter__() 

1394 task_logger.success("Per-task clouds.yaml written", category=LogCategory.STATUS) 

1395 

1396 terraform_dir = os.path.join(repo_path, "terraform") 

1397 if not os.path.exists(terraform_dir): 

1398 raise Exception(f"Terraform directory not found at {terraform_dir}") 

1399 

1400 terraform = TerraformExecutor( 

1401 terraform_dir, 

1402 env_vars=openstack_env, 

1403 backend_conn_str=tfstate_conn_str, 

1404 backend_schema_name=tfstate_schema, 

1405 output_callback=_stream_line, 

1406 ) 

1407 

1408 phase_tracker.mark(PHASE_TERRAFORM_INIT, "Initializing Terraform") 

1409 success, stdout, stderr = terraform.init() 

1410 if not success: 

1411 if stdout: 

1412 task_logger.command_output("terraform_init_stdout", stdout, returncode=1) 

1413 if stderr: 

1414 task_logger.command_output("terraform_init_stderr", stderr, returncode=1) 

1415 raise Exception("Terraform init failed") 

1416 task_logger.success("Terraform initialization completed", category=LogCategory.STATUS) 

1417 

1418 # Pull the canonical state from the pg backend, then walk it 

1419 # to find every compute instance attached to this deployment. 

1420 # An empty list usually means the deployment never reached a 

1421 # successful apply — surface that as a hard error rather than 

1422 # a silent no-op so the user doesn't think pause "worked" on 

1423 # an empty deployment. 

1424 state_dump = terraform.state_pull() 

1425 server_ids = _extract_compute_instance_ids(state_dump) 

1426 if not server_ids: 

1427 raise Exception( 

1428 "No compute instances found in terraform state — " 

1429 f"nothing to {action}. The deployment may have been " 

1430 "torn down already or never reached a successful apply." 

1431 ) 

1432 task_logger.info( 

1433 f"{len(server_ids)} compute instance(s) found", 

1434 category=LogCategory.OPERATION, 

1435 server_ids=server_ids, 

1436 ) 

1437 

1438 phase_tracker.mark( 

1439 server_phase, 

1440 f"{'Stopping' if server_op == 'stop' else 'Starting'} {len(server_ids)} server(s)", 

1441 ) 

1442 

1443 openstack_service = OpenStackService(env_vars=openstack_env) 

1444 op_method = openstack_service.server_stop if server_op == "stop" else openstack_service.server_start 

1445 

1446 failures: list[tuple[str, str]] = [] 

1447 for sid in server_ids: 

1448 # Optional pre-flight: log the human name + power state so 

1449 # the per-deployment log is readable. We never fail on 

1450 # show() — it's purely cosmetic. 

1451 info = openstack_service.server_show(sid) 

1452 label_str = f"{info.get('name', sid)}" if info else sid 

1453 current = info.get("status") if info else None 

1454 task_logger.info( 

1455 f"{server_op} {label_str} (status: {current or 'unknown'})", 

1456 category=LogCategory.OPERATION, 

1457 server_id=sid, 

1458 ) 

1459 

1460 ok, err = op_method(sid) 

1461 if ok: 

1462 task_logger.success( 

1463 f"{label_str}: {server_op} OK", 

1464 category=LogCategory.STATUS, 

1465 ) 

1466 else: 

1467 task_logger.error( 

1468 f"{label_str}: {server_op} failed: {err}", 

1469 category=LogCategory.ERROR, 

1470 server_id=sid, 

1471 ) 

1472 failures.append((sid, err or "unknown error")) 

1473 

1474 if failures: 

1475 joined = "; ".join(f"{sid}: {err}" for sid, err in failures) 

1476 raise Exception(f"{action} failed for {len(failures)}/{len(server_ids)} server(s): {joined}") 

1477 

1478 phase_tracker.mark(PHASE_CLEANUP, "Pulling final state snapshot") 

1479 # State doesn't change for pause/resume (the resources still 

1480 # exist, just in a different power state), but we pull it 

1481 # again so the task row gets a fresh snapshot for debugging. 

1482 try: 

1483 tf_state_post = terraform.state_pull() 

1484 except Exception as e: 

1485 task_logger.warning( 

1486 f"Could not pull terraform state post-{action}: {e}", 

1487 category=LogCategory.WARNING, 

1488 ) 

1489 tf_state_post = state_dump 

1490 

1491 task_logger.success( 

1492 f"Deployment {deployment_id} {action}d successfully", 

1493 category=LogCategory.STATUS, 

1494 ) 

1495 

1496 return { 

1497 "status": "success", 

1498 "deployment_id": deployment_id, 

1499 "logs": task_logger.get_logs_dict(), 

1500 "tf_state": tf_state_post, 

1501 "commit_info": None, 

1502 # Pause/resume don't generate or change terraform outputs — 

1503 # field is kept for event-listener parity with the deploy 

1504 # / destroy payload shape. 

1505 "terraform_outputs": {}, 

1506 } 

1507 

1508 except Exception as e: 

1509 task_logger.exception(f"{action} failed: {str(e)}", exception=e, deployment_id=deployment_id) 

1510 raise Failure( 

1511 message=str(e), 

1512 deployment_id=deployment_id, 

1513 logs_dict=task_logger.get_logs_dict(), 

1514 tf_state=None, 

1515 commit_info=None, 

1516 terraform_outputs={}, 

1517 ) 

1518 

1519 finally: 

1520 _cleanup_task_resources(clouds_config, repo_path, task_logger) 

1521 

1522 

1523@celery_app.task(bind=True, name="tasks.pause_deployment") 

1524def pause_deployment( 

1525 self, 

1526 deployment_id: str, 

1527 app_id: str, 

1528 app_git_link: str, 

1529 release: str, 

1530 user_vars: dict[str, Any], 

1531 teams: dict[str, list] = None, 

1532 openstack_envelope: dict[str, Any] | None = None, 

1533): 

1534 """Halt a deployment by stopping all of its compute instances. 

1535 

1536 Volumes and networks are untouched, so resume restores the same 

1537 instances byte-for-byte. The terraform state is also untouched, 

1538 so a subsequent destroy proceeds normally (terraform destroy is 

1539 happy to tear down SHUTOFF instances). 

1540 """ 

1541 return _run_compute_lifecycle( 

1542 self, 

1543 deployment_id, 

1544 app_id, 

1545 app_git_link, 

1546 release, 

1547 user_vars, 

1548 teams, 

1549 openstack_envelope, 

1550 action="pause", 

1551 phases=_PHASES_PAUSE, 

1552 server_phase=PHASE_SERVER_STOP, 

1553 server_op="stop", 

1554 ) 

1555 

1556 

1557@celery_app.task(bind=True, name="tasks.resume_deployment") 

1558def resume_deployment( 

1559 self, 

1560 deployment_id: str, 

1561 app_id: str, 

1562 app_git_link: str, 

1563 release: str, 

1564 user_vars: dict[str, Any], 

1565 teams: dict[str, list] = None, 

1566 openstack_envelope: dict[str, Any] | None = None, 

1567): 

1568 """Resume a paused deployment by starting all of its compute instances. 

1569 

1570 Mirrors :func:`pause_deployment`'s preamble exactly so the two 

1571 code paths stay symmetric and easy to compare side-by-side. 

1572 """ 

1573 return _run_compute_lifecycle( 

1574 self, 

1575 deployment_id, 

1576 app_id, 

1577 app_git_link, 

1578 release, 

1579 user_vars, 

1580 teams, 

1581 openstack_envelope, 

1582 action="resume", 

1583 phases=_PHASES_RESUME, 

1584 server_phase=PHASE_SERVER_START, 

1585 server_op="start", 

1586 ) 

1587 

1588 

1589# ---------------------------------------------------------------- 

1590# REDEPLOY ONE RESOURCE 

1591# ---------------------------------------------------------------- 

1592# 

1593# Replace exactly one compute instance via 

1594# ``terraform apply -replace=<addr> -target=<addr>``. Everything else in 

1595# the deployment stays untouched. The backend whitelists the address 

1596# against the cached TF state before dispatch; we re-check the address 

1597# shape here as defense in depth. Same per-task clouds.yaml + pg backend 

1598# schema as deploy/destroy, so the apply sees the same state file. 

1599 

1600_REDEPLOY_ADDRESS_RE = re.compile( 

1601 r"""^ 

1602 [A-Za-z_][A-Za-z0-9_]* 

1603 \.[A-Za-z_][A-Za-z0-9_-]* 

1604 (?:\[(?:\d+|"[^"\\]+")\])? 

1605 $""", 

1606 re.VERBOSE, 

1607) 

1608 

1609 

1610def _build_current_roster(teams: dict[str, list]) -> tuple[set[str], set[str]]: 

1611 """Compute the legal slot-key sets for the current roster. 

1612 

1613 Returns a ``(team_keys, user_keys)`` tuple: 

1614 

1615 * ``team_keys`` — every team name currently present. These are the 

1616 valid slot keys for ``var_scope=team``. 

1617 * ``user_keys`` — composite ``"<team>-<email>"`` keys for every 

1618 user currently rostered to a team. These are the valid slot keys 

1619 for ``var_scope=user``. 

1620 

1621 Roster entries can either be plain strings (email addresses) or 

1622 dicts with an ``email`` key — matches the shape the backend ships 

1623 in ``teams`` (see ``_attach_files_to_user_input``). Anything else is 

1624 skipped defensively. 

1625 """ 

1626 team_keys: set[str] = set() 

1627 user_keys: set[str] = set() 

1628 for team_name, members in (teams or {}).items(): 

1629 if not team_name: 

1630 continue 

1631 team_keys.add(team_name) 

1632 if not isinstance(members, list): 

1633 continue 

1634 for member in members: 

1635 email = member if isinstance(member, str) else (member.get("email") if isinstance(member, dict) else None) 

1636 if not email: 

1637 continue 

1638 user_keys.add(f"{team_name}-{email}") 

1639 return team_keys, user_keys 

1640 

1641 

1642def _reconcile_scoped_vars_to_roster( 

1643 terraform_vars: dict[str, Any], 

1644 teams: dict[str, list], 

1645 task_logger: Any, 

1646) -> dict[str, Any]: 

1647 """Drop scoped-map entries whose slot keys no longer match the roster. 

1648 

1649 Redeploy replays the originally-persisted ``user_vars["terraform"]`` 

1650 blob, but the team/user roster may have shifted since the initial 

1651 deploy (members added or removed, teams renamed). A scoped variable 

1652 keyed on the old roster would then ship Terraform a map containing 

1653 orphan keys — at best a noisy diff, at worst a type/required 

1654 failure that blocks the replace. 

1655 

1656 Heuristic: a value is considered scoped when it's a non-empty 

1657 ``dict`` whose keys form a subset of either the team-name roster 

1658 (``var_scope=team``) or the ``<team>-<user>`` composite roster 

1659 (``var_scope=user``). On match we intersect the value's keys with 

1660 the current roster and drop the orphans. Maps that don't match the 

1661 heuristic — e.g. file-shape vars or the ``users`` injection — are 

1662 left untouched. Every drop is announced in the task log so the 

1663 operator sees which slots were retired. 

1664 

1665 A value that becomes empty after intersection is dropped from the 

1666 var-set entirely; Terraform validation handles the 

1667 missing-required case from there (it can pick up a declared 

1668 default or surface the required-but-missing error properly). 

1669 """ 

1670 if not terraform_vars: 

1671 return terraform_vars 

1672 

1673 team_keys, user_keys = _build_current_roster(teams) 

1674 if not team_keys and not user_keys: 

1675 # Nothing rostered — can't reconcile, leave the var-set alone. 

1676 return terraform_vars 

1677 

1678 reconciled: dict[str, Any] = {} 

1679 for name, value in terraform_vars.items(): 

1680 # Only dict-shaped, non-empty values can be scoped maps. Skip 

1681 # the ``users`` injection — we set that ourselves from ``teams`` 

1682 # right after this and it's not an app-defined scoped var. 

1683 if name == "users" or not isinstance(value, dict) or not value: 

1684 reconciled[name] = value 

1685 continue 

1686 # File-shape values (see ``_looks_like_file_var_value``) are 

1687 # already keyed by slot but use a different content contract; 

1688 # let the regular file-strip handle them. 

1689 if _looks_like_file_var_value(value): 

1690 reconciled[name] = value 

1691 continue 

1692 

1693 slot_keys = set(value.keys()) 

1694 # Pick the roster axis whose universe best matches the slot 

1695 # keys. Subset wins outright; otherwise pick the axis with the 

1696 # larger overlap so a partially-stale map still gets cleaned. 

1697 team_overlap = slot_keys & team_keys 

1698 user_overlap = slot_keys & user_keys 

1699 if slot_keys <= team_keys and team_keys: 

1700 allowed = team_keys 

1701 elif slot_keys <= user_keys and user_keys or len(user_overlap) >= len(team_overlap) and user_overlap: 

1702 allowed = user_keys 

1703 elif team_overlap: 

1704 allowed = team_keys 

1705 else: 

1706 # No overlap with either roster axis — leave the value 

1707 # alone. Probably a non-scoped map(string,...) variable 

1708 # the user explicitly populated. 

1709 reconciled[name] = value 

1710 continue 

1711 

1712 kept = {k: v for k, v in value.items() if k in allowed} 

1713 dropped = sorted(slot_keys - allowed) 

1714 if dropped: 

1715 task_logger.warning( 

1716 f"Redeploy roster reconciliation: dropped {len(dropped)} " 

1717 f"orphan slot(s) from variable '{name}': {dropped}", 

1718 category=LogCategory.WARNING, 

1719 variable=name, 

1720 dropped_slots=dropped, 

1721 ) 

1722 if kept: 

1723 reconciled[name] = kept 

1724 else: 

1725 # All slots orphaned — drop the var entirely so terraform 

1726 # validation can fall back to the declared default (if any) 

1727 # or surface a proper required-but-missing error. 

1728 task_logger.warning( 

1729 f"Redeploy roster reconciliation: variable '{name}' has " 

1730 "no surviving slots after roster intersection — falling " 

1731 "back to its declared default (or required-but-missing).", 

1732 category=LogCategory.WARNING, 

1733 variable=name, 

1734 ) 

1735 return reconciled 

1736 

1737 

1738@celery_app.task(bind=True, name="tasks.redeploy_resource") 

1739def redeploy_resource( 

1740 self, 

1741 deployment_id: str, 

1742 app_id: str, 

1743 app_git_link: str, 

1744 release: str, 

1745 user_vars: dict[str, Any], 

1746 teams: dict[str, list] = None, 

1747 openstack_envelope: dict[str, Any] | None = None, 

1748 resource_address: str | None = None, 

1749): 

1750 """Replace ONE compute instance via ``-target`` + ``-replace``. 

1751 

1752 Args mirror ``deploy_application`` so the backend's 

1753 ``_dispatch_lifecycle_task`` can ship the same persisted state. 

1754 The extra ``resource_address`` carries the Terraform state address 

1755 (e.g. ``openstack_compute_instance_v2.team_ide["Team-A"]``) the 

1756 user clicked. 

1757 

1758 Returns the same payload shape as deploy/destroy so the celery 

1759 event listener stays generic. 

1760 """ 

1761 task_logger = get_logger(f"redeploy:{deployment_id}", correlation_id=deployment_id) 

1762 

1763 bound_task = self 

1764 

1765 def _emit(event_name: str, payload: dict[str, Any]) -> None: 

1766 bound_task.send_event(event_name, deployment_id=deployment_id, **payload) 

1767 

1768 task_logger.set_event_emitter(_emit) 

1769 phase_tracker = _PhaseTracker(task_logger, _PHASES_REDEPLOY) 

1770 

1771 repo_path: str | None = None 

1772 tf_state: str | None = None 

1773 outputs: dict[str, Any] | None = None 

1774 commit_info: dict[str, Any] | None = None 

1775 terraform_dir: str | None = None 

1776 openstack_env: dict[str, str] = {} 

1777 clouds_config: PerTaskCloudsConfig | None = None 

1778 

1779 tfstate_conn_str = settings.TFSTATE_DATABASE_URL or None 

1780 tfstate_schema = _tfstate_schema_name(deployment_id) 

1781 

1782 if teams is None: 

1783 teams = {} 

1784 

1785 def _stream_line(tool: str, line: str) -> None: 

1786 task_logger.tool_output_line(tool, line) 

1787 

1788 def collect_terraform_state(): 

1789 return collect_terraform_state_helper( 

1790 terraform_dir, openstack_env, tfstate_conn_str, tfstate_schema, task_logger 

1791 ) 

1792 

1793 def collect_terraform_outputs(): 

1794 return collect_terraform_outputs_helper( 

1795 terraform_dir, openstack_env, tfstate_conn_str, tfstate_schema, task_logger 

1796 ) 

1797 

1798 try: 

1799 # Validate the address shape before we do any work. Backend 

1800 # already whitelisted the address against the cached state, but 

1801 # we double-check the shape so an empty / malformed string from 

1802 # a misconfigured caller doesn't reach the CLI. 

1803 if not resource_address or not _REDEPLOY_ADDRESS_RE.match(resource_address): 

1804 raise Exception(f"redeploy_resource called with invalid resource_address: " f"{resource_address!r}") 

1805 

1806 phase_tracker.mark(PHASE_STARTING, f"Starting redeploy of {resource_address}") 

1807 task_logger.resource_info( 

1808 "deployment", 

1809 deployment_id, 

1810 app_id=app_id, 

1811 git_url=app_git_link, 

1812 release=release, 

1813 user_vars_keys=list(user_vars.keys()), 

1814 teams_keys=list(teams.keys()), 

1815 action="redeploy", 

1816 resource_address=resource_address, 

1817 ) 

1818 

1819 phase_tracker.mark(PHASE_OPENSTACK_SETUP, "Validating OpenStack credentials") 

1820 if not openstack_envelope: 

1821 raise Exception("OpenStack credential envelope missing — cannot redeploy without credentials") 

1822 task_logger.success("OpenStack credential envelope received", category=LogCategory.STATUS) 

1823 

1824 phase_tracker.mark(PHASE_GIT_CLONE, "Cloning repository at original release tag") 

1825 task_logger.info( 

1826 f"Cloning {app_git_link} at {release} (same ref as the original deploy " 

1827 "so terraform code matches the pg-backend state)", 

1828 category=LogCategory.OPERATION, 

1829 ) 

1830 try: 

1831 repo_path = git_service.clone_release(git_url=app_git_link, deployment_id=deployment_id, tag=release) 

1832 try: 

1833 commit_info = _extract_commit_info(repo_path) 

1834 task_logger.success( 

1835 f"Repository cloned at commit {commit_info['hash'][:8]}", 

1836 category=LogCategory.STATUS, 

1837 ) 

1838 except Exception as e: 

1839 task_logger.warning(f"Could not extract commit info: {e}", category=LogCategory.WARNING) 

1840 except Exception as e: 

1841 raise Exception(f"Git clone failed: {str(e)}") 

1842 

1843 phase_tracker.mark(PHASE_CREDS_MATERIALISE, "Writing per-task clouds.yaml") 

1844 clouds_config = PerTaskCloudsConfig(openstack_envelope, work_dir=repo_path) 

1845 openstack_env = clouds_config.__enter__() 

1846 task_logger.success("Per-task clouds.yaml written", category=LogCategory.STATUS) 

1847 

1848 # Reconstruct the same image_name map the original deploy 

1849 # used so the apply's variable validation matches. 

1850 # ``image_name`` (or ``image_name_<key>`` per template for 

1851 # multi-image apps) is a HCL contract variable; a mismatch 

1852 # would surface as a noisy "var changed" diff that wouldn't 

1853 # actually apply anything. 

1854 try: 

1855 templates = _discover_packer_templates(repo_path) 

1856 except PackerTemplateDiscoveryError as e: 

1857 raise Exception(f"Packer template discovery failed: {e}") 

1858 

1859 image_tag = commit_info["hash"][:8] if commit_info and commit_info.get("hash") else release 

1860 image_names = _build_image_names(templates, app_id, image_tag) 

1861 

1862 terraform_dir = os.path.join(repo_path, "terraform") 

1863 if not os.path.exists(terraform_dir): 

1864 raise Exception(f"Terraform directory not found at {terraform_dir}") 

1865 

1866 # Build the terraform var-set like the original deploy. We KEEP 

1867 # file variables here: ``terraform apply -replace`` recreates the 

1868 # targeted VM, so cloud-init runs fresh and needs the original 

1869 # ``write_files`` payload. The backend pre-filters these vars for 

1870 # non-recreating lifecycles (destroy/pause/resume) — see 

1871 # ``_dispatch_lifecycle_task`` in backend/app/routers/deployments.py. 

1872 terraform_vars = {**user_vars["terraform"]} if "terraform" in user_vars else {} 

1873 # The persisted ``user_vars`` were keyed on the roster at deploy 

1874 # time. Membership may have shifted since (team renames, members 

1875 # added/removed); ship the apply only the slots that still match 

1876 # the current roster so terraform doesn't choke on orphan keys. 

1877 terraform_vars = _reconcile_scoped_vars_to_roster(terraform_vars, teams, task_logger) 

1878 # Inject the per-template image-name variables (legacy: flat 

1879 # ``image_name``; multi: one ``image_name_<key>`` per template). 

1880 _apply_image_name_vars( 

1881 terraform_vars, 

1882 image_names, 

1883 legacy=not templates or (len(templates) == 1 and templates[0].key == "default"), 

1884 ) 

1885 if teams: 

1886 terraform_vars["users"] = teams 

1887 terraform_vars = encode_terraform_vars(terraform_vars) 

1888 

1889 terraform = TerraformExecutor( 

1890 terraform_dir, 

1891 env_vars=openstack_env, 

1892 backend_conn_str=tfstate_conn_str, 

1893 backend_schema_name=tfstate_schema, 

1894 output_callback=_stream_line, 

1895 ) 

1896 

1897 phase_tracker.mark(PHASE_TERRAFORM_INIT, "Initializing Terraform") 

1898 success, stdout, stderr = terraform.init() 

1899 if not success: 

1900 if stdout: 

1901 task_logger.command_output("terraform_init_stdout", stdout, returncode=1) 

1902 if stderr: 

1903 task_logger.command_output("terraform_init_stderr", stderr, returncode=1) 

1904 raise Exception("Terraform init failed") 

1905 task_logger.success("Terraform initialization completed", category=LogCategory.STATUS) 

1906 

1907 phase_tracker.mark( 

1908 PHASE_TERRAFORM_APPLY, 

1909 f"Applying replace for {resource_address}", 

1910 ) 

1911 # ``-replace`` taints the single resource so terraform plans a 

1912 # destroy+create on it; ``-target`` scopes the apply to that 

1913 # resource (and its dependencies) so the rest of the graph is 

1914 # untouched. 

1915 success, stdout, stderr = terraform.apply( 

1916 variables=terraform_vars, 

1917 targets=[resource_address], 

1918 replace=[resource_address], 

1919 ) 

1920 if not success: 

1921 if stdout: 

1922 task_logger.command_output("terraform_apply_stdout", stdout, returncode=1) 

1923 if stderr: 

1924 task_logger.command_output("terraform_apply_stderr", stderr, returncode=1) 

1925 raise Exception("Terraform apply (replace) failed") 

1926 task_logger.success( 

1927 f"Resource {resource_address} replaced", 

1928 category=LogCategory.STATUS, 

1929 ) 

1930 

1931 phase_tracker.mark(PHASE_CLEANUP, "Pulling final state") 

1932 tf_state = collect_terraform_state() 

1933 outputs = collect_terraform_outputs() 

1934 

1935 task_logger.success( 

1936 f"Deployment {deployment_id} redeploy of {resource_address} completed", 

1937 category=LogCategory.STATUS, 

1938 ) 

1939 

1940 return { 

1941 "status": "success", 

1942 "deployment_id": deployment_id, 

1943 "logs": task_logger.get_logs_dict(), 

1944 "tf_state": tf_state, 

1945 "commit_info": commit_info, 

1946 "terraform_outputs": outputs or {}, 

1947 } 

1948 

1949 except Exception as e: 

1950 task_logger.exception(f"Redeploy failed: {str(e)}", exception=e, deployment_id=deployment_id) 

1951 if not tf_state: 

1952 tf_state = collect_terraform_state() 

1953 raise Failure( 

1954 message=str(e), 

1955 deployment_id=deployment_id, 

1956 logs_dict=task_logger.get_logs_dict(), 

1957 tf_state=tf_state, 

1958 commit_info=commit_info, 

1959 terraform_outputs=outputs or {}, 

1960 ) 

1961 

1962 finally: 

1963 _cleanup_task_resources(clouds_config, repo_path, task_logger)