578cc33cc0
Wires the agent's enriched + swarm payloads through the docker.persist_host_
samples capability:
* Swarm topology — persist sample["swarm"] into docker_swarm_services /
docker_swarm_nodes (upsert + prune stale, host-scoped so two managers don't
clobber). Migration docker_005 adds services.placement_json for the
task→node placement the agent now reports.
* Lifecycle events — _derive_events (pure, unit-tested) diffs the newest
snapshot against stored per-container state: start / stop / die (non-zero
exit) / oom / health_change → docker_events rows. Skipped on a host's first
snapshot so the baseline doesn't emit a start per existing container.
* Alerts — record restart_count (always) and is_healthy (1.0/0.0, only when a
HEALTHCHECK exists) alongside cpu/mem, under host-scoped resource names;
METRIC_CATALOG[docker] gains restart_count + is_healthy so they're alertable.
host_agent ingest captures the newest sample's swarm object and threads it to
the capability (now persist_host_docker(session, host, snapshots, swarm=None));
invoked when containers OR swarm are present, under the same SAVEPOINT. Unit
tests cover the event-diff matrix; integration tests cover event derivation
across two snapshots and swarm topology round-trip (incl. placement).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
178 lines
8.8 KiB
Python
178 lines
8.8 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,
|
|
)
|
|
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)
|
|
mem_usage_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
mem_limit_bytes: Mapped[int | None] = mapped_column(Integer, 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)
|
|
mem_usage_bytes: Mapped[int] = mapped_column(Integer, 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 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 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),
|
|
)
|