feat(docker): collect + persist /system/df image/disk usage (agent 1.6.0)
Backend for the image/disk panel (milestone 77 #942). Agent gains collect_disk_usage() — one /system/df call (gated on containers existing, so Docker-less hosts pay nothing), surfacing reclaimable bytes (image size held by unreferenced images), layers/containers/volumes/build-cache sizes, and the top 50 images by size. Emitted as sample["docker_disk"]; host_agent ingest tracks the newest sample's copy and hands it to the docker capability as a 5th arg. New current-state tables docker_disk_usage (one row/host) + docker_images (per-host image rows), docker_007 migration; ingest upserts the summary and replaces the image set per host (stale images pruned). Unit tests for the df parsing/reclaimable math + build_sample gating; integration test for persistence + image-set replacement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
@@ -151,7 +151,57 @@ async def _persist_swarm(session, host, swarm: dict) -> None:
|
||||
await session.execute(stale_nodes)
|
||||
|
||||
|
||||
async def persist_host_docker(session, host, snapshots, swarm=None) -> None:
|
||||
async def _persist_disk(session, host, disk: dict) -> None:
|
||||
"""Upsert this host's /system/df summary + replace its image storage rows.
|
||||
|
||||
Current-state (not time-series): the agent re-reports the full summary each
|
||||
interval, so we overwrite the single summary row and replace the image set
|
||||
(delete rows no longer present, upsert the rest), scoped to this host.
|
||||
"""
|
||||
from datetime import timezone
|
||||
from .models import DockerDiskUsage, DockerImage
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
summary = await session.get(DockerDiskUsage, host.id)
|
||||
if summary is None:
|
||||
summary = DockerDiskUsage(host_id=host.id)
|
||||
session.add(summary)
|
||||
summary.layers_size = int(disk.get("layers_size") or 0)
|
||||
summary.images_size = int(disk.get("images_size") or 0)
|
||||
summary.images_reclaimable = int(disk.get("images_reclaimable") or 0)
|
||||
summary.containers_size = int(disk.get("containers_size") or 0)
|
||||
summary.volumes_size = int(disk.get("volumes_size") or 0)
|
||||
summary.build_cache_size = int(disk.get("build_cache_size") or 0)
|
||||
summary.images_total = int(disk.get("images_total") or 0)
|
||||
summary.images_active = int(disk.get("images_active") or 0)
|
||||
summary.containers_count = int(disk.get("containers_count") or 0)
|
||||
summary.volumes_count = int(disk.get("volumes_count") or 0)
|
||||
summary.updated_at = now
|
||||
|
||||
images = disk.get("images") or []
|
||||
seen: set[str] = set()
|
||||
for im in images:
|
||||
iid = im.get("image_id")
|
||||
if not iid or iid in seen:
|
||||
continue
|
||||
seen.add(iid)
|
||||
row = await session.get(DockerImage, (host.id, iid))
|
||||
if row is None:
|
||||
row = DockerImage(host_id=host.id, image_id=iid)
|
||||
session.add(row)
|
||||
row.repo_tag = (im.get("repo_tag") or "<none>")[:512]
|
||||
row.size = int(im.get("size") or 0)
|
||||
row.shared_size = int(im.get("shared_size") or 0)
|
||||
row.containers = int(im.get("containers") or 0)
|
||||
row.updated_at = now
|
||||
stale = delete(DockerImage).where(DockerImage.host_id == host.id)
|
||||
if seen:
|
||||
stale = stale.where(DockerImage.image_id.notin_(seen))
|
||||
await session.execute(stale)
|
||||
|
||||
|
||||
async def persist_host_docker(session, host, snapshots, swarm=None, disk=None) -> None:
|
||||
"""Upsert containers + time-series + lifecycle events + swarm for one host.
|
||||
|
||||
`snapshots` is a list of (recorded_at: datetime, containers: list[dict]) —
|
||||
@@ -160,12 +210,16 @@ async def persist_host_docker(session, host, snapshots, swarm=None) -> None:
|
||||
DockerMetric rows; the newest snapshot drives current container state, the
|
||||
alert pipeline, and lifecycle-event derivation. `swarm` is the newest
|
||||
sample's swarm object (or None off managers) — persisted when present.
|
||||
`disk` is the newest sample's /system/df summary (or None on Docker-less
|
||||
hosts) — persisted when present.
|
||||
"""
|
||||
from steward.core.alerts import record_metric
|
||||
from .models import DockerContainer, DockerEvent, DockerMetric
|
||||
|
||||
if swarm is not None:
|
||||
await _persist_swarm(session, host, swarm)
|
||||
if disk is not None:
|
||||
await _persist_disk(session, host, disk)
|
||||
|
||||
if not snapshots:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Docker disk usage + image storage tables
|
||||
|
||||
Adds docker_disk_usage (one per-host /system/df summary row) and docker_images
|
||||
(the heavy-hitter image storage records). Both current-state, host-scoped,
|
||||
re-reported each interval. Additive create_table.
|
||||
|
||||
Revision ID: docker_007_disk_usage
|
||||
Revises: docker_006_metric_rollup
|
||||
Create Date: 2026-06-19
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "docker_007_disk_usage"
|
||||
down_revision: Union[str, None] = "docker_006_metric_rollup"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"docker_disk_usage",
|
||||
sa.Column("host_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("layers_size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("images_size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("images_reclaimable", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("containers_size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("volumes_size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("build_cache_size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("images_total", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("images_active", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("containers_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("volumes_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("host_id"),
|
||||
)
|
||||
op.create_table(
|
||||
"docker_images",
|
||||
sa.Column("host_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("image_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("repo_tag", sa.String(length=512), nullable=False, server_default="<none>"),
|
||||
sa.Column("size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("shared_size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("containers", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("host_id", "image_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("docker_images")
|
||||
op.drop_table("docker_disk_usage")
|
||||
@@ -189,6 +189,56 @@ class DockerSwarmService(Base):
|
||||
)
|
||||
|
||||
|
||||
class DockerDiskUsage(Base):
|
||||
"""Per-host Docker disk usage summary from `/system/df` (current state).
|
||||
|
||||
One row per host (the agent re-reports the full summary each interval).
|
||||
Reclaimable = bytes held by images no container references. Sizes are bytes
|
||||
(BigInteger — image caches exceed 2^31 easily).
|
||||
"""
|
||||
__tablename__ = "docker_disk_usage"
|
||||
|
||||
host_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
layers_size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
images_size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
images_reclaimable: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
containers_size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
volumes_size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
build_cache_size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
images_total: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
images_active: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
containers_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
volumes_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
class DockerImage(Base):
|
||||
"""Per-host image storage record (the heavy hitters from `/system/df`).
|
||||
|
||||
Keyed (host_id, image_id); `containers` is the reference count (0 ⇒ the
|
||||
image's size is reclaimable). Replaced wholesale per host on each report.
|
||||
"""
|
||||
__tablename__ = "docker_images"
|
||||
|
||||
host_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
image_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
repo_tag: Mapped[str] = mapped_column(String(512), nullable=False, default="<none>")
|
||||
size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
shared_size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
containers: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
class DockerSwarmNode(Base):
|
||||
"""A Swarm node as seen by a manager host: role / availability / status."""
|
||||
__tablename__ = "docker_swarm_nodes"
|
||||
|
||||
Reference in New Issue
Block a user