Coverage for cli / commands / deps_cmd.py: 100.00%

128 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1"""Dependency maintenance commands. 

2 

3``gco deps scan`` wraps the repository's dependency scanner 

4(``.github/scripts/dependency-scan.sh``) — the same script the monthly 

5``deps-scan`` workflow runs to build the rolling 

6"[Automated] Dependency updates available" issue — so an operator or agent 

7can generate the exact same update list on demand instead of waiting for 

8the schedule or hand-assembling the invocation. 

9 

10The scanner communicates through the GitHub Actions file-output protocol 

11(``$GITHUB_OUTPUT``); this wrapper points that at a private temp file and 

12reads back ``has_drift`` / ``scan_complete`` / ``report_path``, so the 

13script itself runs bit-for-bit the way CI runs it. 

14 

15Two operating modes: 

16 

17* Full scan (default) — every surface the workflow checks: Python/npm 

18 pins, Docker images, Helm charts, EKS add-ons, Dockerfile.dev ARGs, 

19 autopilot pins, pre-commit hooks, CI tooling, version consistency, 

20 suppression expiries, lockfile freshness, and the accelerator-catalog / 

21 Karpenter NodePool policy. Surfaces that need AWS credentials or tools 

22 the host is missing are skipped and reported as incomplete, exactly as 

23 in CI. 

24* ``--nodepools-only`` — just the accelerator-catalog / NodePool freshness 

25 check (``scripts/accelerator_catalog.py``): the deterministic offline 

26 validation always runs; the live EC2 catalog comparison runs when AWS 

27 credentials resolve and is reported as skipped otherwise. 

28 

29Honest side-effect warning: the full scan's Python surface runs 

30``pip install -e ".[<every extra>]"`` into the *active environment* (that 

31is how it asks pip for outdated direct pins), mirroring the throwaway CI 

32environment. Run it from the dev container or a dedicated venv if that 

33matters to you. 

34""" 

35 

36from __future__ import annotations 

37 

38import contextlib 

39import json 

40import os 

41import re 

42import shutil 

43import subprocess 

44import sys 

45import tempfile 

46from pathlib import Path 

47from typing import NoReturn, cast 

48 

49import click 

50 

51#: Tools the full scan shells out to, and the surfaces that go incomplete 

52#: without them. Missing entries are warnings, not errors — the scanner 

53#: records the gap and keeps going, exactly as it does in CI. 

54_OPTIONAL_TOOLS: tuple[tuple[str, str], ...] = ( 

55 ("jq", "Python-package report rendering"), 

56 ("curl", "npm / GitHub / endoflife.date lookups"), 

57 ("skopeo", "Docker image tag and digest checks"), 

58 ("helm", "Helm chart version checks"), 

59 ("aws", "EKS / Aurora / EMR / Bedrock / online accelerator checks"), 

60) 

61 

62_SCAN_SCRIPT = Path(".github") / "scripts" / "dependency-scan.sh" 

63_CATALOG_SCRIPT = Path("scripts") / "accelerator_catalog.py" 

64 

65 

66def _fail(message: str) -> NoReturn: 

67 raise click.ClickException(message) 

68 

69 

70def _repo_root() -> Path: 

71 """Resolve the checkout root; the scanner only exists in a git checkout.""" 

72 result = subprocess.run( 

73 ["git", "rev-parse", "--show-toplevel"], 

74 capture_output=True, 

75 text=True, 

76 check=False, 

77 ) 

78 if result.returncode != 0: 

79 _fail( 

80 "gco deps scan must run from inside a GCO checkout " 

81 "(the dependency scanner lives under .github/scripts/)" 

82 ) 

83 root = Path(result.stdout.strip()) 

84 if not (root / _SCAN_SCRIPT).is_file(): 

85 _fail(f"{root} has no {_SCAN_SCRIPT} — not a GCO checkout?") 

86 return root 

87 

88 

89def _parse_github_output(path: Path) -> dict[str, str]: 

90 """Parse the ``key=value`` lines the scanner writes to $GITHUB_OUTPUT.""" 

91 outputs: dict[str, str] = {} 

92 try: 

93 text = path.read_text(encoding="utf-8") 

94 except OSError: 

95 return outputs 

96 for line in text.splitlines(): 

97 key, sep, value = line.partition("=") 

98 if sep: 

99 outputs[key.strip()] = value.strip() 

100 return outputs 

101 

102 

103def _sts_identity_available() -> bool: 

104 """Mirror the scanner's credential preflight for the nodepools fast path.""" 

105 if shutil.which("aws") is None: 

106 return False 

107 probe = subprocess.run( 

108 ["aws", "sts", "get-caller-identity"], 

109 capture_output=True, 

110 text=True, 

111 check=False, 

112 ) 

113 return probe.returncode == 0 

114 

115 

116def _run_nodepools_check(repo_root: Path) -> dict[str, object]: 

117 """Run the accelerator-catalog / NodePool freshness checks. 

118 

119 Returns a JSON-friendly envelope with an ``offline`` section (always 

120 runs; deterministic) and an ``online`` section (runs when AWS 

121 credentials resolve, mirrors the scanner's STS preflight). 

122 """ 

123 offline_report = subprocess.run( 

124 [sys.executable, str(_CATALOG_SCRIPT), "validate", "--format", "markdown"], 

125 cwd=repo_root, 

126 capture_output=True, 

127 text=True, 

128 check=False, 

129 ) 

130 if offline_report.returncode not in (0, 1): 

131 _fail( 

132 "accelerator catalog validation failed operationally: " 

133 + (offline_report.stderr.strip() or f"exit {offline_report.returncode}") 

134 ) 

135 finding_count = len(re.findall(r"^### ", offline_report.stdout, re.M)) 

136 offline: dict[str, object] = { 

137 "status": "pass" if offline_report.returncode == 0 else "findings", 

138 "finding_count": finding_count, 

139 "report_markdown": offline_report.stdout, 

140 } 

141 

142 online: dict[str, object] 

143 if not _sts_identity_available(): 

144 online = { 

145 "status": "skipped", 

146 "skip_reason": ( 

147 "No AWS credentials available for the online EC2 catalog check " 

148 "(needs ec2:DescribeRegions and ec2:DescribeInstanceTypes); " 

149 "offline policy validation still ran." 

150 ), 

151 } 

152 else: 

153 with tempfile.TemporaryDirectory(prefix="gco-deps-") as tmp: 

154 online_report_path = Path(tmp) / "online.md" 

155 online_run = subprocess.run( 

156 [ 

157 sys.executable, 

158 str(_CATALOG_SCRIPT), 

159 "check-online", 

160 "--report", 

161 str(online_report_path), 

162 "--json-summary", 

163 ], 

164 cwd=repo_root, 

165 capture_output=True, 

166 text=True, 

167 check=False, 

168 ) 

169 if online_run.returncode not in (0, 1): 

170 _fail( 

171 "online accelerator catalog check failed operationally: " 

172 + (online_run.stderr.strip() or f"exit {online_run.returncode}") 

173 ) 

174 try: 

175 summary = json.loads(online_run.stdout) 

176 except json.JSONDecodeError: 

177 _fail("online accelerator catalog check emitted a malformed JSON summary") 

178 online = { 

179 "status": summary.get("status", "error"), 

180 "drift_count": summary.get("drift_count"), 

181 "regions_checked": summary.get("regions_checked"), 

182 } 

183 with contextlib.suppress(OSError): 

184 online["report_markdown"] = online_report_path.read_text(encoding="utf-8") 

185 

186 has_drift = offline["status"] != "pass" or online.get("status") == "drift" 

187 return { 

188 "nodepools_only": True, 

189 "has_drift": has_drift, 

190 "scan_complete": online.get("status") != "skipped", 

191 "offline": offline, 

192 "online": online, 

193 } 

194 

195 

196def _run_full_scan(repo_root: Path, *, stream: bool) -> dict[str, object]: 

197 """Run the full dependency scanner and return its parsed envelope.""" 

198 missing = [ 

199 f"{tool} ({surfaces})" for tool, surfaces in _OPTIONAL_TOOLS if shutil.which(tool) is None 

200 ] 

201 if missing: 

202 click.echo( 

203 "warning: missing tools — these surfaces will be reported as " 

204 "incomplete: " + "; ".join(missing), 

205 err=True, 

206 ) 

207 

208 with tempfile.TemporaryDirectory(prefix="gco-deps-") as tmp: 

209 github_output = Path(tmp) / "github-output" 

210 github_output.touch() 

211 env = dict(os.environ) 

212 env["GITHUB_OUTPUT"] = str(github_output) 

213 # Never leak into a real Actions job summary if the caller's 

214 # environment happens to carry one. 

215 env.pop("GITHUB_STEP_SUMMARY", None) 

216 env.setdefault("WORKFLOWS_DIR", ".github/workflows") 

217 

218 result = subprocess.run( # fixed argv, repo-owned script 

219 ["bash", str(_SCAN_SCRIPT)], 

220 cwd=repo_root, 

221 env=env, 

222 check=False, 

223 capture_output=not stream, 

224 text=True, 

225 ) 

226 if result.returncode != 0: 

227 detail = "" if stream else f"\n{(result.stderr or '')[-2000:]}" 

228 _fail(f"dependency scanner exited with status {result.returncode}{detail}") 

229 

230 outputs = _parse_github_output(github_output) 

231 has_drift = outputs.get("has_drift") == "true" 

232 scan_complete = outputs.get("scan_complete") == "true" 

233 

234 report_markdown: str 

235 if has_drift: 

236 report_path = outputs.get("report_path", "") 

237 try: 

238 report_markdown = Path(report_path).read_text(encoding="utf-8") 

239 except OSError: 

240 _fail("the scanner reported drift but its report file is unreadable") 

241 else: 

242 report_markdown = "# Dependency Update Report\n\n" + ( 

243 "All dependencies are up to date.\n" 

244 if scan_complete 

245 else "No drift was found in completed checks, but the scan is " 

246 "incomplete — zero-count surfaces are provisional. See the " 

247 "scan log for skipped checks.\n" 

248 ) 

249 

250 # The scanner writes the same findings as a JSON document next to the 

251 # report, on every run (``findings_path``). It rides along in the 

252 # envelope so ``-o json`` callers and the MCP tool get the rows 

253 # without parsing the Markdown. Absent or unreadable is ``None`` 

254 # rather than an error: the report is still the primary output. 

255 findings: object = None 

256 findings_path = outputs.get("findings_path", "") 

257 if findings_path: 

258 with contextlib.suppress(OSError, json.JSONDecodeError): 

259 findings = json.loads(Path(findings_path).read_text(encoding="utf-8")) 

260 

261 envelope: dict[str, object] = { 

262 "has_drift": has_drift, 

263 "scan_complete": scan_complete, 

264 "report_markdown": report_markdown, 

265 "findings": findings, 

266 } 

267 if not stream: 

268 envelope["log_tail"] = (result.stdout or "").splitlines()[-40:] 

269 return envelope 

270 

271 

272@click.group() 

273def deps() -> None: 

274 """Dependency maintenance (update scans, NodePool registry freshness).""" 

275 

276 

277@deps.command("scan") 

278@click.option( 

279 "--nodepools-only", 

280 is_flag=True, 

281 default=False, 

282 help=( 

283 "Run only the accelerator-catalog / Karpenter NodePool freshness " 

284 "check instead of the full scan." 

285 ), 

286) 

287@click.option( 

288 "--report", 

289 "report_file", 

290 type=click.Path(dir_okay=False, writable=True, path_type=Path), 

291 default=None, 

292 help="Write the Markdown report to this file instead of stdout.", 

293) 

294@click.pass_context 

295def scan(ctx: click.Context, nodepools_only: bool, report_file: Path | None) -> None: 

296 """Generate the dependency update list the monthly deps-scan produces. 

297 

298 Runs the same scanner as the ``deps-scan`` GitHub Actions workflow and 

299 prints its Markdown report, so the update list in the rolling 

300 "[Automated] Dependency updates available" issue can be reproduced on 

301 demand. Surfaces that need AWS credentials or missing host tools are 

302 skipped and flagged as incomplete rather than failing the run. 

303 

304 With ``--nodepools-only``, runs just the accelerator catalog / 

305 NodePool policy checks (offline always; live EC2 comparison when AWS 

306 credentials resolve). 

307 

308 With the global ``-o json``, prints a machine-readable envelope 

309 (``has_drift``, ``scan_complete``, ``report_markdown``) instead of 

310 the bare report — this is the shape the MCP ``deps_scan`` tool 

311 returns. 

312 """ 

313 repo_root = _repo_root() 

314 json_output = bool(ctx.obj) and getattr(ctx.obj, "output_format", "table") == "json" 

315 

316 if nodepools_only: 

317 envelope = _run_nodepools_check(repo_root) 

318 else: 

319 envelope = _run_full_scan(repo_root, stream=not json_output) 

320 

321 if json_output: 

322 from ..output import emit_structured_document 

323 

324 emit_structured_document( 

325 envelope, 

326 output_format="json", 

327 rendered=json.dumps(envelope, indent=2), 

328 ) 

329 return 

330 

331 if nodepools_only: 

332 offline = cast("dict[str, object]", envelope["offline"]) 

333 online = cast("dict[str, object]", envelope["online"]) 

334 report_markdown = str(offline.get("report_markdown", "")) 

335 if online.get("report_markdown"): 

336 report_markdown += "\n" + str(online["report_markdown"]) 

337 click.echo(f"offline policy check: {offline['status']}", err=True) 

338 click.echo(f"online EC2 catalog: {online['status']}", err=True) 

339 else: 

340 report_markdown = str(envelope["report_markdown"]) 

341 click.echo(f"has_drift: {envelope['has_drift']}", err=True) 

342 click.echo(f"scan_complete: {envelope['scan_complete']}", err=True) 

343 

344 if report_file is not None: 

345 report_file.write_text(report_markdown, encoding="utf-8") 

346 click.echo(f"report: {report_file}", err=True) 

347 else: 

348 click.echo(report_markdown)