|
20 | 20 | from enum import IntEnum |
21 | 21 | from ipaddress import IPv4Address, IPv6Address, ip_address |
22 | 22 | from typing import Any, Literal, Self |
| 23 | +from urllib.parse import quote |
23 | 24 |
|
24 | 25 | import aiohttp |
25 | 26 | from aiohttp import web |
@@ -237,6 +238,14 @@ def validate_expose_payload(self) -> Self: |
237 | 238 | return self |
238 | 239 |
|
239 | 240 |
|
| 241 | +class RemoveImagePayload(BaseModel): |
| 242 | + image_ref: str = Field( |
| 243 | + ..., |
| 244 | + min_length=1, |
| 245 | + description="Docker image reference to remove (repo:tag, repo@digest, or image ID).", |
| 246 | + ) |
| 247 | + |
| 248 | + |
240 | 249 | ############################################################################### |
241 | 250 | # In-memory caches |
242 | 251 | ############################################################################### |
@@ -822,7 +831,16 @@ async def docker_exapp_exists(request: web.Request): |
822 | 831 | async with session.get(docker_api_url) as resp: |
823 | 832 | if resp.status == 200: |
824 | 833 | LOGGER.info("Container '%s' exists.", container_name) |
825 | | - return web.json_response({"exists": True}) |
| 834 | + # Surface the image reference the container was created from. AppAPI uses |
| 835 | + # this for image cleanup so it knows which ref to delete after the container |
| 836 | + # is gone, without having to track image refs in its own state. |
| 837 | + image_ref = "" |
| 838 | + try: |
| 839 | + data = await resp.json() |
| 840 | + image_ref = (data.get("Config") or {}).get("Image") or data.get("Image") or "" |
| 841 | + except (json.JSONDecodeError, aiohttp.ContentTypeError) as e: |
| 842 | + LOGGER.warning("Container '%s' exists but inspect body was not parseable: %s", container_name, e) |
| 843 | + return web.json_response({"exists": True, "image_ref": image_ref}) |
826 | 844 | if resp.status == 404: |
827 | 845 | LOGGER.info("Container '%s' does not exist.", container_name) |
828 | 846 | return web.json_response({"exists": False}) |
@@ -1446,6 +1464,138 @@ async def docker_exapp_remove(request: web.Request): |
1446 | 1464 | return web.HTTPNoContent() |
1447 | 1465 |
|
1448 | 1466 |
|
| 1467 | +async def docker_exapp_image_remove(request: web.Request): |
| 1468 | + """Remove a Docker image by reference, returning a structured result. |
| 1469 | +
|
| 1470 | + Response shape: |
| 1471 | + {"deleted": true, "bytes_freed": <int>} # image deleted |
| 1472 | + {"deleted": true, "bytes_freed": 0, "reason": "not_found"} # image already gone |
| 1473 | + {"deleted": false, "reason": "in_use"} # Docker refused (409) |
| 1474 | +
|
| 1475 | + Bytes_freed is best-effort: we inspect the image first to capture its size, but |
| 1476 | + if the inspect fails for any reason other than 404 we still try the delete and |
| 1477 | + just report bytes_freed=0. |
| 1478 | + """ |
| 1479 | + docker_engine_port = get_docker_engine_port(request) |
| 1480 | + try: |
| 1481 | + payload_dict = await request.json() |
| 1482 | + except json.JSONDecodeError: |
| 1483 | + LOGGER.warning("Invalid JSON body received for /docker/exapp/image_remove") |
| 1484 | + raise web.HTTPBadRequest(text="Invalid JSON body") from None |
| 1485 | + try: |
| 1486 | + payload = RemoveImagePayload.model_validate(payload_dict) |
| 1487 | + except ValidationError as e: |
| 1488 | + LOGGER.warning("Payload validation error for /docker/exapp/image_remove: %s", e) |
| 1489 | + raise web.HTTPBadRequest(text=f"Payload validation error: {e}") from None |
| 1490 | + |
| 1491 | + image_ref = payload.image_ref |
| 1492 | + encoded_ref = quote(image_ref, safe="") |
| 1493 | + inspect_url = f"http://{DOCKER_API_HOST}:{docker_engine_port}/images/{encoded_ref}/json" |
| 1494 | + delete_url = f"http://{DOCKER_API_HOST}:{docker_engine_port}/images/{encoded_ref}" |
| 1495 | + |
| 1496 | + bytes_freed = 0 |
| 1497 | + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30.0)) as session: |
| 1498 | + try: |
| 1499 | + async with session.get(inspect_url) as resp: |
| 1500 | + if resp.status == 200: |
| 1501 | + data = await resp.json() |
| 1502 | + bytes_freed = int(data.get("Size", 0)) |
| 1503 | + elif resp.status == 404: |
| 1504 | + LOGGER.info("Image '%s' not found at inspect, treating as already removed.", image_ref) |
| 1505 | + return web.json_response({"deleted": True, "bytes_freed": 0, "reason": "not_found"}) |
| 1506 | + else: |
| 1507 | + error_text = await resp.text() |
| 1508 | + LOGGER.warning( |
| 1509 | + "Image inspect for '%s' returned status %s: %s. Continuing to delete with bytes_freed=0.", |
| 1510 | + image_ref, |
| 1511 | + resp.status, |
| 1512 | + error_text, |
| 1513 | + ) |
| 1514 | + except aiohttp.ClientConnectorError as e: |
| 1515 | + LOGGER.error( |
| 1516 | + "Could not connect to Docker Engine at %s:%s for image inspect: %s", |
| 1517 | + DOCKER_API_HOST, |
| 1518 | + docker_engine_port, |
| 1519 | + e, |
| 1520 | + ) |
| 1521 | + raise web.HTTPServiceUnavailable( |
| 1522 | + text=f"Could not connect to Docker Engine on port {docker_engine_port}" |
| 1523 | + ) from e |
| 1524 | + except TimeoutError as e: |
| 1525 | + LOGGER.error( |
| 1526 | + "Timeout inspecting image '%s' via Docker Engine at %s:%s", |
| 1527 | + image_ref, |
| 1528 | + DOCKER_API_HOST, |
| 1529 | + docker_engine_port, |
| 1530 | + ) |
| 1531 | + raise web.HTTPGatewayTimeout(text="Timeout communicating with Docker Engine for image inspect") from e |
| 1532 | + except Exception as e: |
| 1533 | + LOGGER.warning( |
| 1534 | + "Unable to inspect image '%s' before removal: %s. Continuing with bytes_freed=0.", |
| 1535 | + image_ref, |
| 1536 | + e, |
| 1537 | + ) |
| 1538 | + |
| 1539 | + try: |
| 1540 | + async with session.delete(delete_url) as resp: |
| 1541 | + if resp.status == 200: |
| 1542 | + try: |
| 1543 | + ops = await resp.json() |
| 1544 | + except (json.JSONDecodeError, aiohttp.ContentTypeError): |
| 1545 | + ops = [] |
| 1546 | + actually_deleted = isinstance(ops, list) and any( |
| 1547 | + isinstance(op, dict) and "Deleted" in op for op in ops |
| 1548 | + ) |
| 1549 | + actual_bytes_freed = bytes_freed if actually_deleted else 0 |
| 1550 | + LOGGER.info( |
| 1551 | + "Image '%s' removed (deleted=%s, bytes_freed=%d).", |
| 1552 | + image_ref, |
| 1553 | + actually_deleted, |
| 1554 | + actual_bytes_freed, |
| 1555 | + ) |
| 1556 | + return web.json_response({"deleted": True, "bytes_freed": actual_bytes_freed}) |
| 1557 | + if resp.status == 404: |
| 1558 | + LOGGER.info("Image '%s' already gone at delete time.", image_ref) |
| 1559 | + return web.json_response({"deleted": True, "bytes_freed": 0, "reason": "not_found"}) |
| 1560 | + if resp.status == 409: |
| 1561 | + error_text = await resp.text() |
| 1562 | + LOGGER.info("Image '%s' is in use, skipping removal: %s", image_ref, error_text) |
| 1563 | + return web.json_response({"deleted": False, "reason": "in_use"}) |
| 1564 | + error_text = await resp.text() |
| 1565 | + LOGGER.error( |
| 1566 | + "Error removing image '%s' via Docker API (status %s): %s", |
| 1567 | + image_ref, |
| 1568 | + resp.status, |
| 1569 | + error_text, |
| 1570 | + ) |
| 1571 | + raise web.HTTPServiceUnavailable( |
| 1572 | + text=f"Error removing image '{image_ref}' via Docker Engine: Status {resp.status}" |
| 1573 | + ) |
| 1574 | + except aiohttp.ClientConnectorError as e: |
| 1575 | + LOGGER.error( |
| 1576 | + "Could not connect to Docker Engine at %s:%s to remove image: %s", |
| 1577 | + DOCKER_API_HOST, |
| 1578 | + docker_engine_port, |
| 1579 | + e, |
| 1580 | + ) |
| 1581 | + raise web.HTTPServiceUnavailable( |
| 1582 | + text=f"Could not connect to Docker Engine on port {docker_engine_port}" |
| 1583 | + ) from e |
| 1584 | + except TimeoutError as e: |
| 1585 | + LOGGER.error( |
| 1586 | + "Timeout removing image '%s' via Docker Engine at %s:%s", |
| 1587 | + image_ref, |
| 1588 | + DOCKER_API_HOST, |
| 1589 | + docker_engine_port, |
| 1590 | + ) |
| 1591 | + raise web.HTTPGatewayTimeout(text="Timeout communicating with Docker Engine for image removal") from e |
| 1592 | + except web.HTTPServiceUnavailable: |
| 1593 | + raise |
| 1594 | + except Exception as e: |
| 1595 | + LOGGER.exception("Unexpected error while removing image '%s' via Docker API.", image_ref) |
| 1596 | + raise web.HTTPInternalServerError(text="An unexpected error occurred during image removal.") from e |
| 1597 | + |
| 1598 | + |
1449 | 1599 | async def docker_exapp_install_certificates(request: web.Request): |
1450 | 1600 | docker_engine_port = get_docker_engine_port(request) |
1451 | 1601 | try: |
@@ -2985,6 +3135,7 @@ def create_web_app() -> web.Application: |
2985 | 3135 | app.router.add_post("/docker/exapp/stop", docker_exapp_stop) |
2986 | 3136 | app.router.add_post("/docker/exapp/wait_for_start", docker_exapp_wait_for_start) |
2987 | 3137 | app.router.add_post("/docker/exapp/remove", docker_exapp_remove) |
| 3138 | + app.router.add_post("/docker/exapp/image_remove", docker_exapp_image_remove) |
2988 | 3139 | app.router.add_post("/docker/exapp/install_certificates", docker_exapp_install_certificates) |
2989 | 3140 |
|
2990 | 3141 | # Kubernetes APIs wrappers |
|
0 commit comments