Coverage for app/services/terraform_executor.py: 98.45%

193 statements  

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

1""" 

2Terraform execution utilities with comprehensive structured logging. 

3 

4Each long-running command (init, plan, apply, destroy) streams its 

5combined stdout/stderr line-by-line through an optional ``output_callback`` 

6so a higher-level consumer (the deploy task's per-deployment logger) can 

7forward each line onto the Celery event bus while the command is still 

8running. The full output is also kept in memory and returned at the end 

9in the same ``(success, stdout, stderr)`` tuple as before, so existing 

10callers keep working unchanged. 

11""" 

12 

13import contextlib 

14import json 

15import os 

16import subprocess 

17import threading 

18from collections.abc import Callable 

19from typing import Any 

20 

21from ..config import settings 

22from ..utils.logger import LogCategory, get_logger 

23 

24logger = get_logger(__name__) 

25 

26 

27# File written next to the cloned repo's terraform/ directory to force the 

28# `pg` backend regardless of what the upstream module declares. Terraform 

29# treats files ending in `_override.tf` as overrides and replaces the 

30# `terraform { backend ... }` block in base config with this one. 

31_PG_BACKEND_OVERRIDE_FILENAME = "pg_backend_override.tf" 

32 

33 

34def _pg_backend_override_hcl(schema_name: str) -> str: 

35 # schema_name is interpolated, not user-controlled (we generate it from 

36 # the deployment UUID). Quoted as an HCL string literal. 

37 safe = schema_name.replace('"', '\\"') 

38 return f'terraform {{\n backend "pg" {{\n schema_name = "{safe}"\n }}\n}}\n' 

39 

40 

41OutputCallback = Callable[[str, str], None] 

42"""Signature: ``callback(tool_name, line) -> None``. 

43 

44Invoked once per line read from the subprocess. ``tool_name`` lets the 

45callback distinguish ``terraform_init`` / ``terraform_plan`` / ... when one 

46callback is shared across operations. 

47""" 

48 

49 

50def _stream_subprocess( 

51 cmd: list[str], 

52 *, 

53 cwd: str, 

54 env: dict[str, str], 

55 timeout: int, 

56 tool_name: str, 

57 output_callback: OutputCallback | None, 

58) -> tuple[int, str, str]: 

59 """Run a subprocess and stream its output line-by-line. 

60 

61 stdout and stderr are merged onto stdout (``stderr=STDOUT``) so the 

62 caller and the live consumer see the lines in the order Terraform 

63 intended — Terraform interleaves progress and error messages across 

64 the two streams and separating them would scramble the chronology. 

65 The returned tuple still exposes the merged output as ``stdout`` and 

66 keeps ``stderr`` as an empty string for ABI compatibility. 

67 

68 A timeout is enforced via ``Popen.wait(timeout)``; on expiry the 

69 process group is killed (children inherit the same group via 

70 ``start_new_session``) so terraform's child providers don't survive 

71 as orphans. 

72 """ 

73 process = subprocess.Popen( 

74 cmd, 

75 cwd=cwd, 

76 stdout=subprocess.PIPE, 

77 stderr=subprocess.STDOUT, 

78 text=True, 

79 bufsize=1, # line-buffered — without this, output is held until 

80 # Terraform fills its 64 KiB pipe buffer 

81 env=env, 

82 start_new_session=True, 

83 ) 

84 output_lines: list[str] = [] 

85 

86 def _drain_stdout() -> None: 

87 # Read in a thread so the parent can enforce a wall-clock timeout 

88 # without blocking on readline. 

89 try: 

90 assert process.stdout is not None 

91 for raw in process.stdout: 

92 line = raw.rstrip("\n") 

93 output_lines.append(line) 

94 if output_callback is not None: 

95 with contextlib.suppress(Exception): 

96 # Never let a flaky live-stream callback break the 

97 # actual deployment; swallow and keep draining. 

98 output_callback(tool_name, line) 

99 except Exception: 

100 pass 

101 

102 reader = threading.Thread(target=_drain_stdout, name=f"{tool_name}-reader", daemon=True) 

103 reader.start() 

104 

105 try: 

106 returncode = process.wait(timeout=timeout) 

107 except subprocess.TimeoutExpired: 

108 # Kill the whole process group — terraform spawns provider plugins 

109 # as children and a plain process.kill() would orphan them. 

110 with contextlib.suppress(OSError, ProcessLookupError): 

111 os.killpg(process.pid, 9) 

112 reader.join(timeout=2) 

113 return 124, "\n".join(output_lines), "Timeout" 

114 

115 reader.join(timeout=5) 

116 return returncode, "\n".join(output_lines), "" 

117 

118 

119class TerraformExecutor: 

120 """Executor for Terraform operations with detailed logging. 

121 

122 When ``backend_conn_str`` and ``backend_schema_name`` are provided the 

123 executor configures Terraform's ``pg`` backend so state is persisted 

124 in a remote Postgres. The conn string lives only in the per-process 

125 env (``PG_CONN_STR``), never on the command line. 

126 

127 ``output_callback`` is optional; if set, each line of subprocess output 

128 is fed to it as it arrives. The deploy task uses this to forward lines 

129 onto its per-deployment logger which then ships them to the backend 

130 via Celery custom events. 

131 """ 

132 

133 def __init__( 

134 self, 

135 working_dir: str, 

136 env_vars: dict[str, str] | None = None, 

137 backend_conn_str: str | None = None, 

138 backend_schema_name: str | None = None, 

139 output_callback: OutputCallback | None = None, 

140 ): 

141 self.working_dir = working_dir 

142 self.terraform_path = settings.TERRAFORM_PATH 

143 self.env_vars = env_vars or {} 

144 self.backend_conn_str = backend_conn_str 

145 self.backend_schema_name = backend_schema_name 

146 self.output_callback = output_callback 

147 

148 def _get_env(self, extra_env: dict[str, str] | None = None) -> dict[str, str]: 

149 """Get environment variables including OpenStack credentials and Terraform debug logging.""" 

150 env = os.environ.copy() 

151 env.update(self.env_vars) 

152 if extra_env: 

153 env.update(extra_env) 

154 # ``TF_LOG`` is honoured by the terraform CLI and emits an enormous 

155 # amount of provider/RPC trace to stderr — useful when debugging 

156 # the worker itself, but it drowns the human-readable error block 

157 # we forward to the user. Default to off; opt back in via 

158 # ``WORKER_TF_LOG`` if you really want it. 

159 tf_log = os.environ.get("WORKER_TF_LOG", "") 

160 if tf_log: 

161 env["TF_LOG"] = tf_log 

162 else: 

163 env.pop("TF_LOG", None) 

164 # PG_CONN_STR is read by Terraform's pg backend. Putting it in env 

165 # (not -backend-config="conn_str=...") keeps the password out of 

166 # the process listing and command logs. 

167 if self.backend_conn_str: 

168 env["PG_CONN_STR"] = self.backend_conn_str 

169 return env 

170 

171 def _write_pg_backend_override(self) -> None: 

172 """Write ``pg_backend_override.tf`` so init configures the pg backend. 

173 

174 No-op if no schema is configured (legacy local-state mode). 

175 """ 

176 if not self.backend_schema_name: 

177 return 

178 override_path = os.path.join(self.working_dir, _PG_BACKEND_OVERRIDE_FILENAME) 

179 with open(override_path, "w") as f: 

180 f.write(_pg_backend_override_hcl(self.backend_schema_name)) 

181 logger.debug(f"[TF] Wrote pg backend override at {override_path} (schema_name={self.backend_schema_name})") 

182 

183 # ------------------------------------------------------------------ 

184 # Long-running operations (streamed) 

185 # ------------------------------------------------------------------ 

186 

187 def _run_streamed( 

188 self, 

189 cmd: list[str], 

190 *, 

191 tool_name: str, 

192 timeout: int, 

193 ) -> tuple[bool, str, str]: 

194 logger.debug(f"[TF] Running command: {' '.join(cmd)}") 

195 returncode, stdout, stderr = _stream_subprocess( 

196 cmd, 

197 cwd=self.working_dir, 

198 env=self._get_env(), 

199 timeout=timeout, 

200 tool_name=tool_name, 

201 output_callback=self.output_callback, 

202 ) 

203 return returncode == 0, stdout, stderr 

204 

205 def init(self) -> tuple[bool, str, str]: 

206 """Initialize Terraform in the working directory. 

207 

208 Returns: 

209 tuple: (success, stdout, stderr) — stderr merged into stdout 

210 """ 

211 logger.operation_start("terraform_init", working_dir=self.working_dir) 

212 try: 

213 # Write the backend override BEFORE init runs. If we leave it 

214 # to a later step Terraform will already have committed to 

215 # whatever backend the upstream module declares (or to ``local``). 

216 self._write_pg_backend_override() 

217 

218 cmd = [self.terraform_path, "init", "-input=false"] 

219 # ``-reconfigure`` makes init idempotent across deploy/update/destroy 

220 # runs on the same dir — the schema_name comes from the override 

221 # file we just wrote, not from a stale ``.terraform/terraform.tfstate``. 

222 if self.backend_schema_name: 

223 cmd.append("-reconfigure") 

224 

225 success, stdout, stderr = self._run_streamed(cmd, tool_name="terraform_init", timeout=300) 

226 

227 if stdout: 

228 logger.command_output("terraform_init", stdout, 0 if success else 1) 

229 

230 if not success: 

231 logger.error( 

232 "Terraform init failed", 

233 category=LogCategory.ERROR, 

234 stderr=stderr[:1000] if stderr else None, 

235 ) 

236 else: 

237 logger.success("Terraform init completed", category=LogCategory.STATUS) 

238 

239 logger.operation_end("terraform_init", success) 

240 return success, stdout, stderr 

241 except Exception as e: 

242 logger.exception("Terraform init failed with exception", exception=e) 

243 logger.operation_end("terraform_init", success=False) 

244 return False, "", str(e) 

245 

246 def plan(self, var_file: str | None = None, variables: dict[str, Any] | None = None) -> tuple[bool, str, str]: 

247 """Run terraform plan.""" 

248 logger.operation_start("terraform_plan", var_file=var_file, var_count=len(variables or {})) 

249 try: 

250 # ``-lock=false``: each deployment has its own pg schema (see 

251 # ``_tfstate_schema_name`` in the worker), so state is isolated 

252 # per deployment. A shared state lock would serialize unrelated 

253 # deployments; real concurrency on the same state is already 

254 # prevented by the backend. 

255 cmd = [self.terraform_path, "plan", "-input=false", "-lock=false"] 

256 if var_file: 

257 cmd.extend(["-var-file", var_file]) 

258 if variables: 

259 for key, value in variables.items(): 

260 cmd.extend(["-var", f"{key}={value}"]) 

261 

262 logger.info("Analyzing Terraform configuration...", category=LogCategory.STATUS) 

263 logger.debug("plan variable keys", category=LogCategory.OPERATION, keys=list((variables or {}).keys())) 

264 

265 success, stdout, stderr = self._run_streamed(cmd, tool_name="terraform_plan", timeout=300) 

266 

267 if stdout: 

268 logger.command_output("terraform_plan", stdout, 0 if success else 1) 

269 

270 if not success: 

271 logger.error("Terraform plan failed", category=LogCategory.ERROR) 

272 else: 

273 logger.success("Terraform plan completed", category=LogCategory.STATUS) 

274 

275 logger.operation_end("terraform_plan", success) 

276 return success, stdout, stderr 

277 except Exception as e: 

278 logger.exception("Terraform plan failed with exception", exception=e) 

279 logger.operation_end("terraform_plan", success=False) 

280 return False, "", str(e) 

281 

282 def apply( 

283 self, 

284 var_file: str | None = None, 

285 variables: dict[str, Any] | None = None, 

286 targets: list[str] | None = None, 

287 replace: list[str] | None = None, 

288 ) -> tuple[bool, str, str]: 

289 """Run terraform apply. 

290 

291 Args: 

292 var_file: Optional ``-var-file`` value. 

293 variables: Optional ``-var KEY=VALUE`` map. 

294 targets: Optional list of resource addresses to pass via 

295 ``-target=…``. Used by the per-VM redeploy task to scope 

296 an apply to ONE compute instance — leaves the rest of 

297 the deployment untouched. Each entry MUST be a single 

298 terraform state address (``type.name[index]``); passing 

299 a malformed value would expand into a different CLI 

300 flag, so the worker is the line of defense (callers in 

301 the backend additionally whitelist against the state). 

302 replace: Optional list of resource addresses to pass via 

303 ``-replace=…``. Combined with ``targets`` for the 

304 redeploy contract: the targeted resource gets 

305 explicitly tainted so terraform destroys + recreates 

306 it instead of detecting "no changes" and short- 

307 circuiting. Same validation rules as ``targets``. 

308 """ 

309 logger.operation_start( 

310 "terraform_apply", 

311 var_file=var_file, 

312 var_count=len(variables or {}), 

313 target_count=len(targets or []), 

314 replace_count=len(replace or []), 

315 ) 

316 try: 

317 # ``-lock=false``: see the rationale in ``plan()`` — the 

318 # per-deployment schema isolates state. 

319 cmd = [self.terraform_path, "apply", "-auto-approve", "-input=false", "-lock=false"] 

320 if var_file: 

321 cmd.extend(["-var-file", var_file]) 

322 if variables: 

323 for key, value in variables.items(): 

324 cmd.extend(["-var", f"{key}={value}"]) 

325 # Targets / replaces must be a plain list of addresses; we 

326 # don't validate the shape here (the caller has already 

327 # whitelisted against the cached TF state). Passing them as 

328 # separate ``[flag, value]`` pairs to subprocess avoids 

329 # shell quoting concerns — double-quotes inside the address 

330 # (``team_ide["Team-A"]``) reach Terraform unmodified. 

331 for tgt in targets or []: 

332 cmd.extend(["-target", tgt]) 

333 for repl in replace or []: 

334 cmd.extend(["-replace", repl]) 

335 

336 logger.info("Applying Terraform configuration (this may take minutes)...", category=LogCategory.STATUS) 

337 

338 success, stdout, stderr = self._run_streamed(cmd, tool_name="terraform_apply", timeout=1800) 

339 

340 if stdout: 

341 logger.command_output("terraform_apply", stdout, 0 if success else 1) 

342 

343 if not success: 

344 logger.error("Terraform apply failed", category=LogCategory.ERROR) 

345 else: 

346 logger.success("Terraform apply completed successfully", category=LogCategory.STATUS) 

347 

348 logger.operation_end("terraform_apply", success) 

349 return success, stdout, stderr 

350 except Exception as e: 

351 logger.exception("Terraform apply failed with exception", exception=e) 

352 logger.operation_end("terraform_apply", success=False) 

353 return False, "", str(e) 

354 

355 def destroy( 

356 self, 

357 var_file: str | None = None, 

358 variables: dict[str, Any] | None = None, 

359 refresh: bool = True, 

360 ) -> tuple[bool, str, str]: 

361 """Run terraform destroy. 

362 

363 ``refresh=False`` adds ``-refresh=false`` so Terraform tears down 

364 purely from state without re-reading data sources. Only used as a 

365 targeted fallback by the worker when a stale data source (e.g. a 

366 Glance image deleted out-of-band) blocks the refresh-based destroy. 

367 """ 

368 logger.operation_start("terraform_destroy", var_file=var_file, var_count=len(variables or {})) 

369 try: 

370 # ``-lock=false``: see the rationale in ``plan()`` — the 

371 # per-deployment schema isolates state. 

372 cmd = [self.terraform_path, "destroy", "-auto-approve", "-input=false", "-lock=false"] 

373 if not refresh: 

374 cmd.append("-refresh=false") 

375 if var_file: 

376 cmd.extend(["-var-file", var_file]) 

377 if variables: 

378 for key, value in variables.items(): 

379 cmd.extend(["-var", f"{key}={value}"]) 

380 

381 logger.info("Destroying Terraform resources (this may take minutes)...", category=LogCategory.STATUS) 

382 

383 success, stdout, stderr = self._run_streamed(cmd, tool_name="terraform_destroy", timeout=1800) 

384 

385 if stdout: 

386 logger.command_output("terraform_destroy", stdout, 0 if success else 1) 

387 

388 if not success: 

389 logger.error("Terraform destroy failed", category=LogCategory.ERROR) 

390 else: 

391 logger.success("Terraform destroy completed successfully", category=LogCategory.STATUS) 

392 

393 logger.operation_end("terraform_destroy", success) 

394 return success, stdout, stderr 

395 except Exception as e: 

396 logger.exception("Terraform destroy failed with exception", exception=e) 

397 logger.operation_end("terraform_destroy", success=False) 

398 return False, "", str(e) 

399 

400 # ------------------------------------------------------------------ 

401 # Short read-only operations (still buffered — fast, no live consumer 

402 # waits on these) 

403 # ------------------------------------------------------------------ 

404 

405 def output(self) -> dict[str, Any] | None: 

406 """Get terraform outputs as JSON.""" 

407 logger.operation_start("terraform_output") 

408 try: 

409 cmd = [self.terraform_path, "output", "-json"] 

410 logger.debug(f"[TF] Running command: {' '.join(cmd)}") 

411 result = subprocess.run( 

412 cmd, cwd=self.working_dir, capture_output=True, text=True, timeout=60, env=self._get_env() 

413 ) 

414 if result.returncode != 0: 

415 logger.warning( 

416 "Terraform output retrieval failed", category=LogCategory.OPERATION, returncode=result.returncode 

417 ) 

418 logger.operation_end("terraform_output", success=False) 

419 return None 

420 

421 outputs = json.loads(result.stdout) 

422 logger.success(f"Terraform outputs retrieved ({len(outputs)} outputs)", category=LogCategory.STATUS) 

423 logger.operation_end("terraform_output", success=True) 

424 return outputs 

425 except Exception as e: 

426 logger.exception("Terraform output extraction failed with exception", exception=e) 

427 logger.operation_end("terraform_output", success=False) 

428 return None 

429 

430 def state_pull(self) -> str | None: 

431 """Return the current state JSON via ``terraform state pull``. 

432 

433 Works for both the local backend (reads ``terraform.tfstate``) and 

434 the pg backend (reads from Postgres). Used by the worker to 

435 snapshot state onto the task row for debugging — the canonical 

436 copy lives in the pg backend. 

437 """ 

438 try: 

439 cmd = [self.terraform_path, "state", "pull"] 

440 result = subprocess.run( 

441 cmd, 

442 cwd=self.working_dir, 

443 capture_output=True, 

444 text=True, 

445 timeout=60, 

446 env=self._get_env(), 

447 ) 

448 if result.returncode != 0: 

449 logger.warning( 

450 "Terraform state pull failed", 

451 category=LogCategory.OPERATION, 

452 returncode=result.returncode, 

453 ) 

454 return None 

455 return result.stdout 

456 except Exception as e: 

457 logger.warning(f"Terraform state pull raised: {e}", category=LogCategory.OPERATION) 

458 return None