# 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), )