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
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""Container image registry MCP tools.
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"""
10from __future__ import annotations
12import asyncio
13import json
14from typing import Any
16from audit import audit_logged
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
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
34from tools._long_task import _run_long_task
36_TASK_CONFIG_OPTIONAL = TaskConfig(mode="optional")
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
45 return get_image_manager()
48def _get_image_mirror() -> Any:
49 """Lazy-import the ``cli._image_mirror`` core so MCP server import stays light.
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
57 return _image_mirror
60# =============================================================================
61# Read-only tools — Risk_Tier "safe"
62# =============================================================================
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()))
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.
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)))
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.
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)))
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.
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 )
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()))
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()))
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.
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 )
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.
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``.
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 """
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)
161 return await asyncio.to_thread(_plan)
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.
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``.
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 )
186# =============================================================================
187# Administrative tools — Risk_Tier "low-risk"
188# =============================================================================
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.
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)))
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.
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)))
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.
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)))
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()))
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.
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.
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.
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.
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 """
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)
294 return await asyncio.to_thread(_run)
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.
311 `gco images build` — build a container image and push to ECR.
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)
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.
343 `gco images push` — push an already-built local image to the project ECR repo.
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)
367# =============================================================================
368# Destructive image tools — gated by GCO_ENABLE_DESTRUCTIVE_OPERATIONS
369# =============================================================================
372async def _ctx_warning(message: str) -> None:
373 """Emit ``ctx.warning(...)`` from inside a tool body, no-op when no Context.
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
381 try:
382 from fastmcp.server.dependencies import get_context
384 ctx = get_context()
385 except Exception:
386 return
387 with _contextlib.suppress(Exception):
388 await ctx.warning(message)
391if is_enabled(FLAG_DESTRUCTIVE_OPERATIONS):
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.
398 `gco images delete-tag` — delete a single tag from a repository.
399 Cannot be undone — the image manifest is removed from ECR.
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)))
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.
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.
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 )
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.
433 `gco images cleanup` — remove every untagged image across one or all project repos.
434 Cannot be undone — untagged image manifests are permanently deleted.
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 )
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.
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.
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)))