Skip to content

Commit f68704b

Browse files
authored
feat: add /docker/exapp/image_remove endpoint for ExApp image cleanup (#102)
* feat: add /docker/exapp/image_remove and image_ref in /docker/exapp/exists for ExApp image cleanup Signed-off-by: Oleksander Piskun <oleksandr2088@icloud.com> * fix: report bytes_freed=0 when image_remove only untags a multi-tagged image Signed-off-by: Oleksander Piskun <oleksandr2088@icloud.com> --------- Signed-off-by: Oleksander Piskun <oleksandr2088@icloud.com>
1 parent 8518a01 commit f68704b

1 file changed

Lines changed: 152 additions & 1 deletion

File tree

haproxy_agent.py

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from enum import IntEnum
2121
from ipaddress import IPv4Address, IPv6Address, ip_address
2222
from typing import Any, Literal, Self
23+
from urllib.parse import quote
2324

2425
import aiohttp
2526
from aiohttp import web
@@ -237,6 +238,14 @@ def validate_expose_payload(self) -> Self:
237238
return self
238239

239240

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+
240249
###############################################################################
241250
# In-memory caches
242251
###############################################################################
@@ -822,7 +831,16 @@ async def docker_exapp_exists(request: web.Request):
822831
async with session.get(docker_api_url) as resp:
823832
if resp.status == 200:
824833
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})
826844
if resp.status == 404:
827845
LOGGER.info("Container '%s' does not exist.", container_name)
828846
return web.json_response({"exists": False})
@@ -1446,6 +1464,138 @@ async def docker_exapp_remove(request: web.Request):
14461464
return web.HTTPNoContent()
14471465

14481466

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+
14491599
async def docker_exapp_install_certificates(request: web.Request):
14501600
docker_engine_port = get_docker_engine_port(request)
14511601
try:
@@ -2985,6 +3135,7 @@ def create_web_app() -> web.Application:
29853135
app.router.add_post("/docker/exapp/stop", docker_exapp_stop)
29863136
app.router.add_post("/docker/exapp/wait_for_start", docker_exapp_wait_for_start)
29873137
app.router.add_post("/docker/exapp/remove", docker_exapp_remove)
3138+
app.router.add_post("/docker/exapp/image_remove", docker_exapp_image_remove)
29883139
app.router.add_post("/docker/exapp/install_certificates", docker_exapp_install_certificates)
29893140

29903141
# Kubernetes APIs wrappers

0 commit comments

Comments
 (0)