2545e8c6ce
docker_metrics.mem_usage_bytes and docker_containers.mem_usage_bytes / mem_limit_bytes were int4 (max 2,147,483,647). A container using >2.1 GB of RAM (e.g. 8.18 GB) overflowed the column, so asyncpg raised "value out of int32 range" and the entire docker ingest batch failed — no metrics stored for any host with a large container. The I/O counters and the milestone-77 rollup/disk tables already used BigInteger; this trio (docker_001-era) was missed. - models.py: DockerMetric.mem_usage_bytes, DockerContainer.mem_usage_bytes, DockerContainer.mem_limit_bytes → BigInteger. - migration docker_008_bigint_mem: ALTER COLUMN ... TYPE BIGINT (safe in-place int4→int8 promotion). - integration regression test: persist an ~8 GB container, assert the current-state and time-series rows round-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
265 lines
13 KiB
Python
265 lines
13 KiB
Python
# plugins/docker/models.py
|
|
from __future__ import annotations
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from sqlalchemy import (
|
|
BigInteger, Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text,
|
|
UniqueConstraint,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
from steward.models.base import Base
|
|
|
|
|
|
class DockerContainer(Base):
|
|
"""Latest known state per container, scoped to the host that reported it.
|
|
|
|
Collection is per-host via the host agent, so container names are only
|
|
unique within a host — the natural key is (host_id, name). host_id is NOT
|
|
NULL: every container arrives through a host_agent ingest that resolves a
|
|
Host first. Deleting a host cascades its containers away.
|
|
"""
|
|
__tablename__ = "docker_containers"
|
|
|
|
host_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
name: Mapped[str] = mapped_column(String(255), primary_key=True)
|
|
container_id: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
|
image: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
|
|
# running | stopped | paused | exited | dead
|
|
cpu_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
# BigInteger: a container's memory usage/limit routinely exceeds 2^31 bytes
|
|
# (~2.1 GB), which overflows int4 and fails the insert.
|
|
mem_usage_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
mem_limit_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
mem_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
restart_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
ports_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
|
# JSON: [{"host_port": 8080, "container_port": 80, "protocol": "tcp"}]
|
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
scraped_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False,
|
|
default=lambda: datetime.now(timezone.utc),
|
|
)
|
|
|
|
# ── Enrichment (agent ≥ 1.4.0) ────────────────────────────────────────────
|
|
# Health/exit/restart come from `docker inspect` (not the list endpoint);
|
|
# exit_code is only meaningful for stopped containers.
|
|
health: Mapped[str | None] = mapped_column(String(16), nullable=True) # healthy|unhealthy|starting
|
|
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
oom_killed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
# Grouping: compose project + swarm placement, read off container labels.
|
|
compose_project: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
service_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
task_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
node_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
# Cumulative-since-start I/O counters (BigInteger — they exceed 2^31 quickly).
|
|
net_rx_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
net_tx_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
blk_read_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
blk_write_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
class DockerMetric(Base):
|
|
"""Time-series CPU/memory per container — one row per sample per running
|
|
container, scoped to the reporting host."""
|
|
__tablename__ = "docker_metrics"
|
|
|
|
id: Mapped[str] = mapped_column(
|
|
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
|
)
|
|
host_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
container_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
|
scraped_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False,
|
|
default=lambda: datetime.now(timezone.utc),
|
|
index=True,
|
|
)
|
|
cpu_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
|
mem_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
|
# BigInteger: per-container memory usage exceeds 2^31 bytes (~2.1 GB) routinely.
|
|
mem_usage_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
|
|
# Per-container history lookups filter on (host_id, container_name) then sort
|
|
# by time — a composite index keeps the rows() sparkline queries cheap.
|
|
__table_args__ = (
|
|
Index("ix_docker_metrics_host_container_time",
|
|
"host_id", "container_name", "scraped_at"),
|
|
)
|
|
|
|
|
|
class DockerMetricHourly(Base):
|
|
"""Hourly rollup of docker_metrics — avg cpu/mem per container per hour.
|
|
|
|
Raw per-sample rows (~2880/container/day at 30s) are pruned beyond a short
|
|
window; before deletion they're aggregated here so multi-day history stays
|
|
cheap to store and query. One row per (host, container, hour bucket); the
|
|
unique constraint lets retention upsert idempotently if it re-runs before the
|
|
raw rows are deleted. `bucket` is the hour-truncated sample time.
|
|
"""
|
|
__tablename__ = "docker_metrics_hourly"
|
|
|
|
id: Mapped[str] = mapped_column(
|
|
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
|
)
|
|
host_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
container_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
bucket: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
cpu_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
|
mem_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
|
mem_usage_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
sample_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
__table_args__ = (
|
|
# One bucket per container per host — the conflict target for the
|
|
# idempotent rollup upsert; doubles as the history-query index.
|
|
UniqueConstraint("host_id", "container_name", "bucket",
|
|
name="uq_docker_metrics_hourly_bucket"),
|
|
Index("ix_docker_metrics_hourly_bucket", "bucket"),
|
|
)
|
|
|
|
|
|
class DockerEvent(Base):
|
|
"""Lifecycle events derived by diffing consecutive host snapshots.
|
|
|
|
The agent only reports current state, so Steward synthesises lifecycle by
|
|
comparing each new snapshot against the stored DockerContainer state for the
|
|
host: a container that appears → `start`; one that vanishes → `stop`; a
|
|
transition to a stopped status with a non-zero exit → `die`; an OOM-kill
|
|
flip → `oom`; a health status change → `health_change`. Scoped to the
|
|
reporting host (names are only unique within a host). `event` is a plain
|
|
string (no CHECK whitelist), matching how `status` is modelled on
|
|
DockerContainer — keeps the value set open without a migration per addition.
|
|
"""
|
|
__tablename__ = "docker_events"
|
|
|
|
id: Mapped[str] = mapped_column(
|
|
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
|
)
|
|
host_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
container_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
event: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
# start | stop | die | oom | health_change
|
|
detail: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
# e.g. "exit 137", "healthy→unhealthy", image on start
|
|
at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False,
|
|
default=lambda: datetime.now(timezone.utc),
|
|
)
|
|
|
|
# Timeline lookups filter on (host_id, container_name) and sort by time;
|
|
# retention prunes by `at` alone — index both access paths.
|
|
__table_args__ = (
|
|
Index("ix_docker_events_host_container_time",
|
|
"host_id", "container_name", "at"),
|
|
Index("ix_docker_events_at", "at"),
|
|
)
|
|
|
|
|
|
class DockerSwarmService(Base):
|
|
"""A Swarm service as seen by a manager host: desired vs running replicas.
|
|
|
|
Manager-scoped — only a host that is a Swarm manager reports these, so a
|
|
single-manager cluster yields one row set keyed by that host. Service names
|
|
are unique within a swarm; we still key by (host_id, service_name) so two
|
|
managers reporting independently don't collide.
|
|
"""
|
|
__tablename__ = "docker_swarm_services"
|
|
|
|
host_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
service_name: Mapped[str] = mapped_column(String(255), primary_key=True)
|
|
mode: Mapped[str] = mapped_column(String(16), nullable=False, default="replicated")
|
|
# replicated | global
|
|
desired: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
running: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
image: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
# task→node placement of running replicas: [{"node_id", "running"}], JSON.
|
|
# A manager sees every task, so this captures cross-node placement that the
|
|
# local docker_containers rows (this host only) can't.
|
|
placement_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False,
|
|
default=lambda: datetime.now(timezone.utc),
|
|
)
|
|
|
|
|
|
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"
|
|
|
|
host_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
node_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
hostname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
|
role: Mapped[str] = mapped_column(String(16), nullable=False, default="worker")
|
|
# manager | worker
|
|
availability: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
|
# active | pause | drain
|
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="unknown")
|
|
# ready | down | unknown
|
|
leader: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False,
|
|
default=lambda: datetime.now(timezone.utc),
|
|
)
|