Coverage for gco_mcp / tools / images.py: 100.00%

134 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1"""Container image registry MCP tools. 

2 

3All tools wrap ``cli/images.py::ImageManager`` so the MCP layer never 

4re-implements the underlying ECR/runtime logic. Read-only and 

5administrative tools are unconditional. Build/push tools register only 

6when ``GCO_ENABLE_IMAGE_PUBLISH`` is set; destructive tools register 

7only when ``GCO_ENABLE_DESTRUCTIVE_OPERATIONS`` is set. 

8""" 

9 

10from __future__ import annotations 

11 

12import asyncio 

13import json 

14from typing import Any 

15 

16from audit import audit_logged 

17 

18# FastMCP's Progress / Context dependencies inject real instances per 

19# call inside an MCP request; unit tests supply caller-provided fakes. 

20from fastmcp.server.dependencies import CurrentContext, Progress 

21 

22# TaskConfig opts the gated build/push tools into the MCP tasks extension 

23# (SEP-2663, registered in gco_mcp/server.py) so clients that speak the 

24# task protocol can run them as background tasks; everyone else runs them 

25# inline with streamed progress. 

26from fastmcp.utilities.tasks import TaskConfig 

27from feature_flags import ( 

28 FLAG_DESTRUCTIVE_OPERATIONS, 

29 FLAG_IMAGE_PUBLISH, 

30 is_enabled, 

31) 

32from server import mcp 

33 

34from tools._long_task import _run_long_task 

35 

36_TASK_CONFIG_OPTIONAL = TaskConfig(mode="optional") 

37 

38 

39def _get_manager() -> Any: 

40 """Lazy-import ``cli.images.get_image_manager`` so MCP server 

41 import doesn't pull boto3 prematurely. 

42 """ 

43 from cli.images import get_image_manager 

44 

45 return get_image_manager() 

46 

47 

48def _get_image_mirror() -> Any: 

49 """Lazy-import the ``cli._image_mirror`` core so MCP server import stays light. 

50 

51 The mirror tools wrap this general "copy third-party images into gco/* ECR" 

52 module (the same core the deploy auto-mirror and ``gco images mirror`` 

53 use) rather than ``ImageManager`` — see ``docs/IMAGE_MIRROR.md``. 

54 """ 

55 from cli import _image_mirror 

56 

57 return _image_mirror 

58 

59 

60# ============================================================================= 

61# Read-only tools — Risk_Tier "safe" 

62# ============================================================================= 

63 

64 

65@mcp.tool(tags={"safe", "images"}) 

66@audit_logged 

67async def images_list() -> str: 

68 """`gco images list` — list every gco/* repository in ECR.""" 

69 return await asyncio.to_thread(lambda: json.dumps(_get_manager().list_repos())) 

70 

71 

72@mcp.tool(tags={"safe", "images"}) 

73@audit_logged 

74async def images_tags(name: str) -> str: 

75 """`gco images tags` — list tags within a repository. 

76 

77 Args: 

78 name: Repository name (without the ``gco/`` prefix). 

79 """ 

80 return await asyncio.to_thread(lambda: json.dumps(_get_manager().list_tags(name))) 

81 

82 

83@mcp.tool(tags={"safe", "images"}) 

84@audit_logged 

85async def images_describe(name: str, tag: str) -> str: 

86 """`gco images describe` — full ECR details for a single image tag. 

87 

88 Args: 

89 name: Repository name (without the ``gco/`` prefix). 

90 tag: Image tag. 

91 """ 

92 return await asyncio.to_thread(lambda: json.dumps(_get_manager().describe(name, tag))) 

93 

94 

95@mcp.tool(tags={"safe", "images"}) 

96@audit_logged 

97async def images_uri(name: str, tag: str = "latest") -> str: 

98 """`gco images uri` — return the registry URI for an image. No AWS calls. 

99 

100 Args: 

101 name: Repository name (without the ``gco/`` prefix). 

102 tag: Image tag. Defaults to ``latest``. 

103 """ 

104 return await asyncio.to_thread( 

105 lambda: json.dumps({"uri": _get_manager().get_uri(name, tag=tag)}) 

106 ) 

107 

108 

109@mcp.tool(tags={"safe", "images"}) 

110@audit_logged 

111async def images_replication_get() -> str: 

112 """`gco images replication get` — current ECR replication configuration.""" 

113 return await asyncio.to_thread(lambda: json.dumps(_get_manager().replication_get())) 

114 

115 

116@mcp.tool(tags={"safe", "images"}) 

117@audit_logged 

118async def images_replication_status() -> str: 

119 """`gco images replication status` — per-image replication status across project repos.""" 

120 return await asyncio.to_thread(lambda: json.dumps(_get_manager().replication_status())) 

121 

122 

123@mcp.tool(tags={"safe", "images"}) 

124@audit_logged 

125async def images_orphans(threshold_days: int = 30) -> str: 

126 """`gco images orphans` — list gco/* tags older than ``threshold_days`` with no references. 

127 

128 Args: 

129 threshold_days: Age threshold in days. Defaults to 30. 

130 """ 

131 return await asyncio.to_thread( 

132 lambda: json.dumps(_get_manager().orphans(threshold_days=threshold_days)) 

133 ) 

134 

135 

136@mcp.tool(tags={"safe", "images"}) 

137@audit_logged 

138async def images_mirror_plan(region: str, ecr_namespace: str | None = None) -> str: 

139 """Image mirror — show which third-party images would be mirrored into ECR. 

140 

141 Read-only planning view: resolves the destination registry and repository 

142 for every image the mirror manages (Volcano's ``docker.io/volcanosh/vc-*`` 

143 images today, derived from ``charts.yaml``) without creating any repository 

144 or copying anything. Use it to preview the mirror before a deploy, or to see 

145 where a consumer's ``image_registry`` override should point. The ``enabled`` 

146 field reflects the cdk.json ``volcano_image_mirror`` toggle that drives the 

147 auto-mirror on ``gco stacks deploy``. See ``docs/IMAGE_MIRROR.md``. 

148 

149 Args: 

150 region: AWS region whose ECR registry is the mirror destination. 

151 ecr_namespace: Destination namespace under the registry. Defaults to the 

152 cdk.json ``volcano_image_mirror.ecr_namespace`` (e.g. ``gco/dockerhub``). 

153 """ 

154 

155 def _plan() -> str: 

156 mirror = _get_image_mirror() 

157 plan = mirror.plan_mirror(region, ecr_namespace) 

158 plan["enabled"] = mirror.read_mirror_config()["enabled"] 

159 return json.dumps(plan) 

160 

161 return await asyncio.to_thread(_plan) 

162 

163 

164@mcp.tool(tags={"safe", "images"}) 

165@audit_logged 

166async def images_mirror_status(region: str, ecr_namespace: str | None = None) -> str: 

167 """Image mirror — report which managed images are already present in ECR. 

168 

169 Read-only: for every image the mirror manages, checks whether its tag 

170 already exists in the ``gco/*`` ECR namespace (ECR ``DescribeImages``; no 

171 writes). The result carries a ``mirrored`` flag per image plus top-level 

172 ``all_mirrored`` / ``missing``, so you can confirm a deploy's auto-mirror 

173 has populated everything the consuming Helm install (Volcano) needs before 

174 it runs, or diagnose a stuck install. See ``docs/IMAGE_MIRROR.md``. 

175 

176 Args: 

177 region: AWS region whose ECR registry is the mirror destination. 

178 ecr_namespace: Destination namespace under the registry. Defaults to the 

179 cdk.json ``volcano_image_mirror.ecr_namespace`` (e.g. ``gco/dockerhub``). 

180 """ 

181 return await asyncio.to_thread( 

182 lambda: json.dumps(_get_image_mirror().mirror_status(region, ecr_namespace)) 

183 ) 

184 

185 

186# ============================================================================= 

187# Administrative tools — Risk_Tier "low-risk" 

188# ============================================================================= 

189 

190 

191@mcp.tool(tags={"low-risk", "images"}) 

192@audit_logged 

193async def images_init(name: str, retain: bool = False) -> str: 

194 """`gco images init` — create the project ECR repo idempotently with default lifecycle. 

195 

196 Args: 

197 name: Repository name (without the ``gco/`` prefix). 

198 retain: When True, mark the repository with ``gco:retain=true`` so it 

199 survives stack destroys. 

200 """ 

201 return await asyncio.to_thread(lambda: json.dumps(_get_manager().init(name, retain=retain))) 

202 

203 

204@mcp.tool(tags={"low-risk", "images"}) 

205@audit_logged 

206async def images_lifecycle_get(name: str) -> str: 

207 """`gco images lifecycle get` — print the lifecycle policy on a repository. 

208 

209 Args: 

210 name: Repository name (without the ``gco/`` prefix). 

211 """ 

212 return await asyncio.to_thread(lambda: json.dumps(_get_manager().lifecycle_get(name))) 

213 

214 

215@mcp.tool(tags={"low-risk", "images"}) 

216@audit_logged 

217async def images_lifecycle_set(name: str, policy: dict[str, Any]) -> str: 

218 """`gco images lifecycle set` — replace the lifecycle policy on a repository. 

219 

220 Args: 

221 name: Repository name (without the ``gco/`` prefix). 

222 policy: ECR lifecycle policy document as a dict. 

223 """ 

224 return await asyncio.to_thread(lambda: json.dumps(_get_manager().lifecycle_set(name, policy))) 

225 

226 

227@mcp.tool(tags={"low-risk", "images"}) 

228@audit_logged 

229async def images_replication_sync() -> str: 

230 """`gco images replication sync` — apply the standard gco/* replication rule.""" 

231 return await asyncio.to_thread(lambda: json.dumps(_get_manager().replication_sync())) 

232 

233 

234# ============================================================================= 

235# Image publish — gated by GCO_ENABLE_IMAGE_PUBLISH 

236# ============================================================================= 

237# 

238# build/push are long-running data-upload operations. They run via 

239# ``_run_long_task`` so progress messages stream back through the 

240# FastMCP Progress dependency. 

241 

242if is_enabled(FLAG_IMAGE_PUBLISH): 

243 # images_mirror copies third-party images (Volcano's docker.io images) into 

244 # the project's gco/* ECR. Like build/push it uploads image data, so it 

245 # shares the GCO_ENABLE_IMAGE_PUBLISH gate. Unlike build/push it wraps the 

246 # mirror core directly via asyncio.to_thread (no Progress/Context injection 

247 # and no _run_long_task), so it registers here at the top of the flag block 

248 # rather than inside the Progress-dependent build/push block below. 

249 @mcp.tool(tags={"image", "images"}) 

250 @audit_logged 

251 async def images_mirror( 

252 region: str, 

253 ecr_namespace: str | None = None, 

254 skip_existing: bool = True, 

255 ) -> str: 

256 """[gated by GCO_ENABLE_IMAGE_PUBLISH] image-upload. 

257 

258 Mirror third-party images into the project's ``gco/*`` ECR so the 

259 cluster pulls them from same-account ECR instead of a rate-limited 

260 upstream (chiefly ``docker.io``, which has no credential-free ECR 

261 pull-through cache). Today that's Volcano's ``volcanosh/vc-*`` images, 

262 derived from ``charts.yaml``; the set is general — see 

263 ``docs/IMAGE_MIRROR.md`` for how to add another image. 

264 

265 Creates each destination repository if needed and copies the image 

266 preserving its full multi-arch manifest list (so both amd64 and arm64 

267 nodes find a match). Idempotent — already-mirrored tags are skipped when 

268 ``skip_existing`` is True, so repeat runs are a fast no-op. This is the 

269 same operation ``gco stacks deploy`` runs automatically when the cdk.json 

270 ``volcano_image_mirror`` toggle is enabled; invoke it directly to 

271 pre-seed or repair the mirror out of band. 

272 

273 Args: 

274 region: AWS region whose ECR registry is the mirror destination. 

275 ecr_namespace: Destination namespace under the registry. Defaults to 

276 the cdk.json ``volcano_image_mirror.ecr_namespace`` (e.g. 

277 ``gco/dockerhub``). 

278 skip_existing: When True (default), skip any image whose tag already 

279 exists in ECR so repeat runs don't re-copy. 

280 """ 

281 

282 def _run() -> str: 

283 mirror = _get_image_mirror() 

284 lines: list[str] = [] 

285 result = mirror.mirror_images( 

286 region, 

287 ecr_namespace=ecr_namespace, 

288 skip_existing=skip_existing, 

289 log=lines.append, 

290 ) 

291 result["log"] = lines 

292 return json.dumps(result) 

293 

294 return await asyncio.to_thread(_run) 

295 

296 @mcp.tool(tags={"image", "images"}, task=_TASK_CONFIG_OPTIONAL) 

297 @audit_logged 

298 async def images_build( 

299 context: str, 

300 name: str, 

301 tag: str | None = None, 

302 dockerfile: str = "Dockerfile", 

303 platform: str = "linux/amd64", 

304 retain: bool = False, 

305 *, 

306 ctx: Any = CurrentContext(), 

307 progress: Any = Progress(), 

308 ) -> str: 

309 """[gated by GCO_ENABLE_IMAGE_PUBLISH] long-running, data-upload. 

310 

311 `gco images build` — build a container image and push to ECR. 

312 

313 Args: 

314 context: Build context directory. 

315 name: Image name (lowercase letters, digits, dashes; max 63 chars). 

316 tag: Image tag (defaults to git short SHA, else ``latest``). 

317 dockerfile: Path to the Dockerfile, relative to ``context``. 

318 platform: ``--platform`` argument for the build. 

319 retain: When True, mark the repository with ``gco:retain=true`` 

320 so it survives stack destroys. 

321 """ 

322 argv = ["gco", "images", "build", context, "--name", name] 

323 if tag: 

324 argv += ["--tag", tag] 

325 argv += ["--dockerfile", dockerfile, "--platform", platform] 

326 if retain: 

327 argv.append("--retain") 

328 return await _run_long_task(argv, ctx=ctx, progress=progress, is_stack_op=False) 

329 

330 @mcp.tool(tags={"image", "images"}, task=_TASK_CONFIG_OPTIONAL) 

331 @audit_logged 

332 async def images_push( 

333 name: str, 

334 tag: str, 

335 local_image: str, 

336 retain: bool = False, 

337 *, 

338 ctx: Any = CurrentContext(), 

339 progress: Any = Progress(), 

340 ) -> str: 

341 """[gated by GCO_ENABLE_IMAGE_PUBLISH] long-running, data-upload. 

342 

343 `gco images push` — push an already-built local image to the project ECR repo. 

344 

345 Args: 

346 name: Image name (lowercase letters, digits, dashes; max 63 chars). 

347 tag: Image tag. 

348 local_image: Source image reference on the local container runtime. 

349 retain: When True, mark the repository with ``gco:retain=true`` 

350 so it survives stack destroys. 

351 """ 

352 argv = [ 

353 "gco", 

354 "images", 

355 "push", 

356 name, 

357 "--tag", 

358 tag, 

359 "--local-image", 

360 local_image, 

361 ] 

362 if retain: 

363 argv.append("--retain") 

364 return await _run_long_task(argv, ctx=ctx, progress=progress, is_stack_op=False) 

365 

366 

367# ============================================================================= 

368# Destructive image tools — gated by GCO_ENABLE_DESTRUCTIVE_OPERATIONS 

369# ============================================================================= 

370 

371 

372async def _ctx_warning(message: str) -> None: 

373 """Emit ``ctx.warning(...)`` from inside a tool body, no-op when no Context. 

374 

375 Tools wrapped here are short-lived enough that we don't need the full 

376 ``_run_long_task`` stack — we just want operators (and the audit log) 

377 to see a warning when destructive work runs. 

378 """ 

379 import contextlib as _contextlib 

380 

381 try: 

382 from fastmcp.server.dependencies import get_context 

383 

384 ctx = get_context() 

385 except Exception: 

386 return 

387 with _contextlib.suppress(Exception): 

388 await ctx.warning(message) 

389 

390 

391if is_enabled(FLAG_DESTRUCTIVE_OPERATIONS): 

392 

393 @mcp.tool(tags={"destructive", "images"}) 

394 @audit_logged 

395 async def images_delete_tag(name: str, tag: str) -> str: 

396 """[gated by GCO_ENABLE_DESTRUCTIVE_OPERATIONS] destructive. 

397 

398 `gco images delete-tag` — delete a single tag from a repository. 

399 Cannot be undone — the image manifest is removed from ECR. 

400 

401 Args: 

402 name: Repository name (without the ``gco/`` prefix). 

403 tag: Image tag to delete. 

404 """ 

405 await _ctx_warning(f"Deleting tag {tag!r} from gco/{name} — this cannot be undone.") 

406 return await asyncio.to_thread(lambda: json.dumps(_get_manager().delete_tag(name, tag))) 

407 

408 @mcp.tool(tags={"destructive", "images"}) 

409 @audit_logged 

410 async def images_delete_repo(name: str, force: bool = False) -> str: 

411 """[gated by GCO_ENABLE_DESTRUCTIVE_OPERATIONS] destructive. 

412 

413 `gco images delete-repo` — delete a whole repository. 

414 Cannot be undone — the repo and (when ``force=True``) every image 

415 inside it are permanently removed from ECR. 

416 

417 Args: 

418 name: Repository name (without the ``gco/`` prefix). 

419 force: When True, also delete every image inside the repo. 

420 """ 

421 await _ctx_warning( 

422 f"Deleting repository gco/{name} (force={force}) — this cannot be undone." 

423 ) 

424 return await asyncio.to_thread( 

425 lambda: json.dumps(_get_manager().delete_repo(name, force=force)) 

426 ) 

427 

428 @mcp.tool(tags={"destructive", "images"}) 

429 @audit_logged 

430 async def images_cleanup(name: str | None = None, all: bool = False) -> str: 

431 """[gated by GCO_ENABLE_DESTRUCTIVE_OPERATIONS] destructive. 

432 

433 `gco images cleanup` — remove every untagged image across one or all project repos. 

434 Cannot be undone — untagged image manifests are permanently deleted. 

435 

436 Args: 

437 name: Repository name to clean (without the ``gco/`` prefix). Required 

438 unless ``all=True``. 

439 all: When True, clean every project repository. 

440 """ 

441 scope = "all repos" if all else f"gco/{name}" 

442 await _ctx_warning(f"Cleaning untagged images from {scope} — this cannot be undone.") 

443 return await asyncio.to_thread( 

444 lambda: json.dumps(_get_manager().cleanup(name=name, all=all)) 

445 ) 

446 

447 @mcp.tool(tags={"destructive", "images"}) 

448 @audit_logged 

449 async def images_prune(dry_run: bool = True) -> str: 

450 """[gated by GCO_ENABLE_DESTRUCTIVE_OPERATIONS] destructive. 

451 

452 `gco images prune` — remove untagged images older than 30 days. 

453 Cannot be undone when ``dry_run=False``; the matching image manifests 

454 are permanently deleted. 

455 

456 Args: 

457 dry_run: When True (default), report what would be deleted without 

458 deleting anything. 

459 """ 

460 if not dry_run: 

461 await _ctx_warning( 

462 "Pruning untagged images older than 30 days — this cannot be undone." 

463 ) 

464 return await asyncio.to_thread(lambda: json.dumps(_get_manager().prune(dry_run=dry_run)))