Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0da88c7076 | ||
|
|
8c50ff242c | ||
|
|
59fece855d | ||
|
|
6c9b89390a | ||
|
|
4b97bd01ea | ||
|
|
8c2d994e4c | ||
|
|
414c8efa98 | ||
|
|
8f1c8c5cf7 | ||
|
|
07a841d91e | ||
|
|
a8de3570fe | ||
|
|
45565b2c01 | ||
|
|
c95194747d | ||
|
|
a9b3b11327 | ||
|
|
e8ac99174a | ||
|
|
01f5805139 |
@@ -14,6 +14,17 @@ on:
|
|||||||
# pull_request intentionally absent — push on [dev, main] already fires CI for
|
# pull_request intentionally absent — push on [dev, main] already fires CI for
|
||||||
# every dev commit and dev→main PRs. Single-operator repo, no fork PRs.
|
# every dev commit and dev→main PRs. Single-operator repo, no fork PRs.
|
||||||
|
|
||||||
|
# Serialize CI runs per ref so the publish lane never shares the runner's docker
|
||||||
|
# daemon with another run. Two publishes racing on one daemon evict each other's
|
||||||
|
# freshly-built image mid-push, breaking the post-build tag/push (issue #1093).
|
||||||
|
# cancel-in-progress:false is deliberate — rule 46 requires EVERY dev/main push to
|
||||||
|
# publish its own immutable :<sha> image, so a superseded run must still run to
|
||||||
|
# completion (never cancelled) to emit its SHA. (Forgejo honors `concurrency:` —
|
||||||
|
# CI-runner's build workflows use it.)
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Fast-fail lint lane. ruff is pre-installed in the ci-python image, so this
|
# Fast-fail lint lane. ruff is pre-installed in the ci-python image, so this
|
||||||
# runs with NO dependency install and surfaces lint bounces in seconds.
|
# runs with NO dependency install and surfaces lint bounces in seconds.
|
||||||
@@ -115,14 +126,25 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
IMAGE=git.fabledsword.com/bvandeusen/steward
|
IMAGE=git.fabledsword.com/bvandeusen/steward
|
||||||
|
# The moving tag for this ref: dev→:dev, main→:latest (rule 46). main IS
|
||||||
|
# the production line, so :latest tracks main's tip; there is no :main.
|
||||||
|
MOVING=""
|
||||||
|
if [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
||||||
|
MOVING="dev"
|
||||||
|
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
|
MOVING="latest"
|
||||||
|
fi
|
||||||
|
# Tag BOTH targets at build time (mirrors CI-runner's build workflows):
|
||||||
|
# one `docker build -t :<sha> -t :<moving>` points both tags at the image
|
||||||
|
# atomically, so we never run a post-push `docker tag` of an image a
|
||||||
|
# concurrent run could have evicted from the shared daemon (issue #1093).
|
||||||
|
if [ -n "$MOVING" ]; then
|
||||||
|
docker build -t "$IMAGE:${{ github.sha }}" -t "$IMAGE:$MOVING" .
|
||||||
|
docker push "$IMAGE:${{ github.sha }}"
|
||||||
|
docker push "$IMAGE:$MOVING"
|
||||||
|
else
|
||||||
docker build -t "$IMAGE:${{ github.sha }}" .
|
docker build -t "$IMAGE:${{ github.sha }}" .
|
||||||
docker push "$IMAGE:${{ github.sha }}"
|
docker push "$IMAGE:${{ github.sha }}"
|
||||||
if [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
|
||||||
docker tag "$IMAGE:${{ github.sha }}" "$IMAGE:dev"
|
|
||||||
docker push "$IMAGE:dev"
|
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
|
||||||
docker tag "$IMAGE:${{ github.sha }}" "$IMAGE:latest"
|
|
||||||
docker push "$IMAGE:latest"
|
|
||||||
fi
|
fi
|
||||||
- name: Prune dangling layers
|
- name: Prune dangling layers
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
|
|||||||
|
|
||||||
- Steward has **no frontend and no Redis/Celery** (no external workers), so there
|
- Steward has **no frontend and no Redis/Celery** (no external workers), so there
|
||||||
is no frontend-build lane and the only service container is Postgres.
|
is no frontend-build lane and the only service container is Postgres.
|
||||||
- Integration uses Forgejo Actions `services:` + a socket-discovered bridge IP
|
- Integration uses Fabled-Git Actions `services:` + a socket-discovered bridge IP
|
||||||
because `act_runner` (swarm-runner v0.6+) puts services on the default bridge
|
because `act_runner` (swarm-runner v0.6+) puts services on the default bridge
|
||||||
with no embedded DNS. FabledCurator's `ci.yml` is the canonical example of the
|
with no embedded DNS. FabledCurator's `ci.yml` is the canonical example of the
|
||||||
pattern; Steward's is the same shape minus Redis and minus sharding.
|
pattern; Steward's is the same shape minus Redis and minus sharding.
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
database:
|
database:
|
||||||
url: "postgresql+asyncpg://steward:password@localhost/steward"
|
url: "postgresql+asyncpg://steward:password@localhost/steward"
|
||||||
|
|
||||||
|
# Optional: seconds to wait at startup for the database to become reachable
|
||||||
|
# before giving up (default: 60). Steward retries with backoff rather than
|
||||||
|
# crashing when the DB is merely slow to arrive — replaying WAL after an
|
||||||
|
# unclean shutdown, or container DNS not yet resolving after a host reboot.
|
||||||
|
# Raise it if your database is routinely slower than this to accept
|
||||||
|
# connections. Env var: STEWARD_DB_CONNECT_TIMEOUT
|
||||||
|
# connect_timeout: 60
|
||||||
|
|
||||||
# Optional: override the auto-generated secret key.
|
# Optional: override the auto-generated secret key.
|
||||||
# If not set, a key is auto-generated on first run and saved to /data/secret.key.
|
# If not set, a key is auto-generated on first run and saved to /data/secret.key.
|
||||||
# secret_key: "change-me-to-a-random-string"
|
# secret_key: "change-me-to-a-random-string"
|
||||||
|
|||||||
@@ -74,8 +74,15 @@ token = a1b2c3d4...
|
|||||||
interval_seconds = 30
|
interval_seconds = 30
|
||||||
hostname = myhost # optional; defaults to uname -n
|
hostname = myhost # optional; defaults to uname -n
|
||||||
mounts = /, /mnt/data # optional; defaults to all real mounts (excluding tmpfs/devtmpfs/etc.)
|
mounts = /, /mnt/data # optional; defaults to all real mounts (excluding tmpfs/devtmpfs/etc.)
|
||||||
|
docker_logs_enabled = true # optional; on by default — per-host container-log kill-switch
|
||||||
|
docker_log_exclude = watchtower, noisy-svc # optional; container names whose logs this host skips collecting
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Container-log collection is on by default (no config needed). The two keys above
|
||||||
|
are the per-host opt-outs; the operator-facing global toggle + exclude live in
|
||||||
|
Settings and are enforced server-side (the push model has no channel to command
|
||||||
|
an agent), so they don't require touching a host's conf.
|
||||||
|
|
||||||
### Agent internals (function list, not classes)
|
### Agent internals (function list, not classes)
|
||||||
|
|
||||||
- `read_config(path)` — parses the conf file into a dict.
|
- `read_config(path)` — parses the conf file into a dict.
|
||||||
@@ -85,10 +92,17 @@ mounts = /, /mnt/data # optional; defaults to all real mounts (excludin
|
|||||||
- `collect_load()` — reads `/proc/loadavg`, returns `[1m, 5m, 15m]`.
|
- `collect_load()` — reads `/proc/loadavg`, returns `[1m, 5m, 15m]`.
|
||||||
- `collect_uptime()` — reads `/proc/uptime`, returns seconds since boot (int).
|
- `collect_uptime()` — reads `/proc/uptime`, returns seconds since boot (int).
|
||||||
- `collect_metadata()` — `os.uname()` for kernel + arch, `/etc/os-release` for distro. Called once at startup and cached.
|
- `collect_metadata()` — `os.uname()` for kernel + arch, `/etc/os-release` for distro. Called once at startup and cached.
|
||||||
|
- `collect_docker_logs(socket, containers, state, exclude)` — (m79) per running
|
||||||
|
container, fetches new log lines over the Docker socket with an incremental
|
||||||
|
`since` cursor kept per container in `state` (a container's first interval
|
||||||
|
seeds from a short tail). Demuxes the multiplexed stream, parses each line's
|
||||||
|
RFC3339 timestamp, caps the whole batch at a byte limit (a marker line records
|
||||||
|
a truncation; the deferred lines come next interval). Folded into the sample as
|
||||||
|
`docker_logs`; omitted when empty.
|
||||||
- `build_payload()` — assembles a snapshot from all collectors into one dict.
|
- `build_payload()` — assembles a snapshot from all collectors into one dict.
|
||||||
- `post_payload(url, token, payloads)` — POSTs a list of samples, returns success/failure.
|
- `post_payload(url, token, payloads)` — POSTs a list of samples, returns success/failure.
|
||||||
- `RingBuffer(maxlen=20)` — tiny FIFO wrapper, drops oldest when full.
|
- `RingBuffer(maxlen=20)` — tiny FIFO wrapper, drops oldest when full.
|
||||||
- `main_loop()` — the 30s loop: collect → try POST → on failure push to buffer + backoff → on success flush buffer.
|
- `main_loop()` — the 30s loop: collect → try POST → on failure push to buffer + backoff → on success flush buffer. Container logs are stripped from a sample before it's buffered (metrics survive an outage; stale logs are dropped).
|
||||||
|
|
||||||
**Target: ~300 lines total including docstrings.** More than that is a smell that the agent is over-scoping.
|
**Target: ~300 lines total including docstrings.** More than that is a smell that the agent is over-scoping.
|
||||||
|
|
||||||
@@ -255,6 +269,7 @@ Content-Type: application/json
|
|||||||
- **`metadata` is sent on every POST**, not just on change. Server-side diff detects actual changes and only writes on change. Cost per POST is one dict — negligible. Benefit: server can cleanly detect agent restarts.
|
- **`metadata` is sent on every POST**, not just on change. Server-side diff detects actual changes and only writes on change. Cost per POST is one dict — negligible. Benefit: server can cleanly detect agent restarts.
|
||||||
- **Raw bytes, not percentages, for memory and storage.** Percentages are derived server-side. Changing the "what counts as used" math doesn't require re-releasing the agent.
|
- **Raw bytes, not percentages, for memory and storage.** Percentages are derived server-side. Changing the "what counts as used" math doesn't require re-releasing the agent.
|
||||||
- **CPU is the one exception** — reported as a percentage because it's inherently a derivative (delta over time), not a snapshot. The agent must sample twice to compute it.
|
- **CPU is the one exception** — reported as a percentage because it's inherently a derivative (delta over time), not a snapshot. The agent must sample twice to compute it.
|
||||||
|
- **Container logs (m79) ride in the same push** as `docker_logs`: a list of `{container, stream, ts, line}` records — incremental since the previous interval. This pushes the *same direction* as metrics, so batched log history needs no inbound channel (only sub-second live-follow would). The server ingests them into `docker_logs`, enforces the global toggle + exclude list on ingest, and bounds storage with a per-container size+age ring. `docker_logs` is omitted when there's nothing new.
|
||||||
|
|
||||||
### Server expansion into `PluginMetric` rows
|
### Server expansion into `PluginMetric` rows
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,55 @@ def _derive_events(old_state: dict, new_containers: list) -> list:
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def _log_rows(log_batches, host_id: str):
|
||||||
|
"""Pure: flatten the agent's log batches → docker_logs row kwargs (no DB).
|
||||||
|
|
||||||
|
`log_batches` is a list of (recorded_at, records) where each record is
|
||||||
|
{"container", "stream", "ts", "line"}. A line's own Docker timestamp wins;
|
||||||
|
recorded_at is the fallback when the agent couldn't parse one. Drops the
|
||||||
|
agent's advisory truncation marker (container "_steward" — it signals a
|
||||||
|
deferral, not a real container), malformed records, and lineless records;
|
||||||
|
normalises an unknown stream to stdout. Unit-testable in isolation.
|
||||||
|
"""
|
||||||
|
for recorded_at, records in log_batches:
|
||||||
|
if not isinstance(records, list):
|
||||||
|
continue
|
||||||
|
for rec in records:
|
||||||
|
if not isinstance(rec, dict):
|
||||||
|
continue
|
||||||
|
name = rec.get("container")
|
||||||
|
line = rec.get("line")
|
||||||
|
if not name or name == "_steward" or line is None:
|
||||||
|
continue
|
||||||
|
ts = _parse_started_at(rec.get("ts")) or recorded_at
|
||||||
|
stream = rec.get("stream")
|
||||||
|
if stream not in ("stdout", "stderr"):
|
||||||
|
stream = "stdout"
|
||||||
|
yield {"host_id": host_id, "container_name": str(name)[:255],
|
||||||
|
"ts": ts, "stream": stream, "line": str(line)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_logs(session, host, log_batches) -> None:
|
||||||
|
"""Append pushed container log lines (one row per line) for this host.
|
||||||
|
|
||||||
|
Time-series / append-only — the per-container size+age ring (retention)
|
||||||
|
bounds growth, so a chatty container just keeps a shorter window. The global
|
||||||
|
toggle + exclude list (Settings, no restart) are enforced here: this is the
|
||||||
|
authoritative drop point, since the push model has no channel to tell an
|
||||||
|
agent to stop collecting.
|
||||||
|
"""
|
||||||
|
from steward.core.settings import get_setting
|
||||||
|
from .models import DockerLog
|
||||||
|
|
||||||
|
if not await get_setting(session, "docker.logs.enabled"):
|
||||||
|
return
|
||||||
|
exclude = set(await get_setting(session, "docker.logs.exclude") or ())
|
||||||
|
for row in _log_rows(log_batches, host.id):
|
||||||
|
if row["container_name"] in exclude:
|
||||||
|
continue
|
||||||
|
session.add(DockerLog(**row))
|
||||||
|
|
||||||
|
|
||||||
async def _persist_swarm(session, host, swarm: dict) -> None:
|
async def _persist_swarm(session, host, swarm: dict) -> None:
|
||||||
"""Upsert this manager's swarm topology; drop rows no longer reported.
|
"""Upsert this manager's swarm topology; drop rows no longer reported.
|
||||||
|
|
||||||
@@ -201,7 +250,8 @@ async def _persist_disk(session, host, disk: dict) -> None:
|
|||||||
await session.execute(stale)
|
await session.execute(stale)
|
||||||
|
|
||||||
|
|
||||||
async def persist_host_docker(session, host, snapshots, swarm=None, disk=None) -> None:
|
async def persist_host_docker(session, host, snapshots, swarm=None, disk=None,
|
||||||
|
logs=None) -> None:
|
||||||
"""Upsert containers + time-series + lifecycle events + swarm for one host.
|
"""Upsert containers + time-series + lifecycle events + swarm for one host.
|
||||||
|
|
||||||
`snapshots` is a list of (recorded_at: datetime, containers: list[dict]) —
|
`snapshots` is a list of (recorded_at: datetime, containers: list[dict]) —
|
||||||
@@ -211,7 +261,9 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None) -
|
|||||||
alert pipeline, and lifecycle-event derivation. `swarm` is the newest
|
alert pipeline, and lifecycle-event derivation. `swarm` is the newest
|
||||||
sample's swarm object (or None off managers) — persisted when present.
|
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
|
`disk` is the newest sample's /system/df summary (or None on Docker-less
|
||||||
hosts) — persisted when present.
|
hosts) — persisted when present. `logs` is a list of (recorded_at, records)
|
||||||
|
log batches (m79) — appended one row per line when present, independent of
|
||||||
|
whether this sample also carried container metrics.
|
||||||
"""
|
"""
|
||||||
from steward.core.alerts import record_metric
|
from steward.core.alerts import record_metric
|
||||||
from .models import DockerContainer, DockerEvent, DockerMetric
|
from .models import DockerContainer, DockerEvent, DockerMetric
|
||||||
@@ -220,6 +272,8 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None) -
|
|||||||
await _persist_swarm(session, host, swarm)
|
await _persist_swarm(session, host, swarm)
|
||||||
if disk is not None:
|
if disk is not None:
|
||||||
await _persist_disk(session, host, disk)
|
await _persist_disk(session, host, disk)
|
||||||
|
if logs:
|
||||||
|
await _persist_logs(session, host, logs)
|
||||||
|
|
||||||
if not snapshots:
|
if not snapshots:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Docker container logs table
|
||||||
|
|
||||||
|
Adds docker_logs — one row per container log line, pushed by the host agent and
|
||||||
|
folded into its metrics push. Time-series, host-scoped (container names are only
|
||||||
|
unique within a host). Bounded by a per-container size+age ring in retention, so
|
||||||
|
a chatty container keeps a shorter window rather than growing without limit.
|
||||||
|
Additive create_table + twin indexes (viewer lookup by (host, container, ts);
|
||||||
|
age-cutoff prune by ts).
|
||||||
|
|
||||||
|
Revision ID: docker_009_container_logs
|
||||||
|
Revises: docker_008_bigint_mem
|
||||||
|
Create Date: 2026-07-19
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_009_container_logs"
|
||||||
|
down_revision: Union[str, None] = "docker_008_bigint_mem"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"docker_logs",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("host_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("container_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("ts", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("stream", sa.String(length=8), nullable=False, server_default="stdout"),
|
||||||
|
sa.Column("line", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_docker_logs_host_id", "docker_logs", ["host_id"])
|
||||||
|
op.create_index("ix_docker_logs_container_name", "docker_logs", ["container_name"])
|
||||||
|
op.create_index("ix_docker_logs_host_container_time",
|
||||||
|
"docker_logs", ["host_id", "container_name", "ts"])
|
||||||
|
op.create_index("ix_docker_logs_ts", "docker_logs", ["ts"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_docker_logs_ts", table_name="docker_logs")
|
||||||
|
op.drop_index("ix_docker_logs_host_container_time", table_name="docker_logs")
|
||||||
|
op.drop_index("ix_docker_logs_container_name", table_name="docker_logs")
|
||||||
|
op.drop_index("ix_docker_logs_host_id", table_name="docker_logs")
|
||||||
|
op.drop_table("docker_logs")
|
||||||
@@ -163,6 +163,43 @@ class DockerEvent(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DockerLog(Base):
|
||||||
|
"""Container log lines pushed by the host agent — one row per line.
|
||||||
|
|
||||||
|
Time-series, scoped to the reporting host (container names are only unique
|
||||||
|
within a host, same identity as docker_metrics). The agent tails each running
|
||||||
|
container incrementally and folds new lines into its metrics push; `ts` is the
|
||||||
|
line's own Docker timestamp. Bounded by a per-container size+age ring
|
||||||
|
(retention), so a chatty container just keeps a shorter window rather than
|
||||||
|
growing without limit.
|
||||||
|
"""
|
||||||
|
__tablename__ = "docker_logs"
|
||||||
|
|
||||||
|
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)
|
||||||
|
ts: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
stream: Mapped[str] = mapped_column(String(8), nullable=False, default="stdout")
|
||||||
|
# stdout | stderr
|
||||||
|
line: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
|
||||||
|
# Viewer filters on (host_id, container_name) and sorts by time; the ring
|
||||||
|
# prune walks the same key newest-first. A second index on `ts` alone serves
|
||||||
|
# the age-cutoff delete. Twin-index idiom, mirroring docker_events.
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_docker_logs_host_container_time",
|
||||||
|
"host_id", "container_name", "ts"),
|
||||||
|
Index("ix_docker_logs_ts", "ts"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DockerSwarmService(Base):
|
class DockerSwarmService(Base):
|
||||||
"""A Swarm service as seen by a manager host: desired vs running replicas.
|
"""A Swarm service as seen by a manager host: desired vs running replicas.
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ async def run_docker_retention(
|
|||||||
events_days: int,
|
events_days: int,
|
||||||
metrics_raw_days: int,
|
metrics_raw_days: int,
|
||||||
metrics_rollup_days: int,
|
metrics_rollup_days: int,
|
||||||
|
logs_retention_days: int = 3,
|
||||||
|
logs_max_bytes_per_container: int = 5_000_000,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Roll up + prune Docker time-series. Returns a counts dict for logging.
|
"""Roll up + prune Docker time-series. Returns a counts dict for logging.
|
||||||
@@ -47,18 +49,22 @@ async def run_docker_retention(
|
|||||||
re-run is idempotent, then delete those raw rows.
|
re-run is idempotent, then delete those raw rows.
|
||||||
2. Prune rolled-up rows older than the rollup window.
|
2. Prune rolled-up rows older than the rollup window.
|
||||||
3. Prune docker_events older than the events window.
|
3. Prune docker_events older than the events window.
|
||||||
|
4. Prune docker_logs with a per-container size+age ring (m79): drop lines
|
||||||
|
older than the age window, then keep only the newest ~cap bytes per
|
||||||
|
(host, container).
|
||||||
"""
|
"""
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
from .models import DockerEvent, DockerMetric, DockerMetricHourly
|
from .models import DockerEvent, DockerLog, DockerMetric, DockerMetricHourly
|
||||||
|
|
||||||
if now is None:
|
if now is None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
rolled = rolled_rows = events_pruned = rollup_pruned = 0
|
rolled = rolled_rows = events_pruned = rollup_pruned = 0
|
||||||
|
logs_age_pruned = logs_size_pruned = 0
|
||||||
|
|
||||||
# ── 1. Roll up raw metrics older than the raw window into hourly buckets ──
|
# ── 1. Roll up raw metrics older than the raw window into hourly buckets ──
|
||||||
raw_cutoff = _rollup_cutoff(now, metrics_raw_days)
|
raw_cutoff = _rollup_cutoff(now, metrics_raw_days)
|
||||||
@@ -120,9 +126,35 @@ async def run_docker_retention(
|
|||||||
)
|
)
|
||||||
events_pruned = res.rowcount or 0
|
events_pruned = res.rowcount or 0
|
||||||
|
|
||||||
|
# ── 4. Container-log ring: age cutoff, then per-container byte cap (m79) ──
|
||||||
|
logs_cutoff = now - timedelta(days=logs_retention_days)
|
||||||
|
res = await session.execute(
|
||||||
|
delete(DockerLog).where(DockerLog.ts < logs_cutoff)
|
||||||
|
)
|
||||||
|
logs_age_pruned = res.rowcount or 0
|
||||||
|
|
||||||
|
# Size ring: per (host, container), sum line bytes newest-first; delete a row
|
||||||
|
# once its strictly-newer siblings already fill the cap. Using the EXCLUSIVE
|
||||||
|
# prefix (running total minus this row) means the newest row always survives,
|
||||||
|
# so a single line larger than the cap is never wiped out.
|
||||||
|
running = func.sum(func.length(DockerLog.line)).over(
|
||||||
|
partition_by=[DockerLog.host_id, DockerLog.container_name],
|
||||||
|
order_by=[DockerLog.ts.desc(), DockerLog.id.desc()],
|
||||||
|
)
|
||||||
|
prefix_excl = (running - func.length(DockerLog.line)).label("prefix_excl")
|
||||||
|
ranked = select(DockerLog.id, prefix_excl).subquery()
|
||||||
|
over_cap = select(ranked.c.id).where(
|
||||||
|
ranked.c.prefix_excl >= logs_max_bytes_per_container)
|
||||||
|
res = await session.execute(
|
||||||
|
delete(DockerLog).where(DockerLog.id.in_(over_cap))
|
||||||
|
)
|
||||||
|
logs_size_pruned = res.rowcount or 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"buckets_rolled": rolled,
|
"buckets_rolled": rolled,
|
||||||
"raw_rows_rolled": rolled_rows,
|
"raw_rows_rolled": rolled_rows,
|
||||||
"rollup_pruned": rollup_pruned,
|
"rollup_pruned": rollup_pruned,
|
||||||
"events_pruned": events_pruned,
|
"events_pruned": events_pruned,
|
||||||
|
"logs_age_pruned": logs_age_pruned,
|
||||||
|
"logs_size_pruned": logs_size_pruned,
|
||||||
}
|
}
|
||||||
|
|||||||
+143
-6
@@ -3,7 +3,10 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from quart import Blueprint, current_app, render_template, request
|
from quart import (
|
||||||
|
Blueprint, current_app, jsonify, redirect, render_template, request,
|
||||||
|
session, url_for,
|
||||||
|
)
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from steward.auth.middleware import require_role
|
from steward.auth.middleware import require_role
|
||||||
@@ -13,13 +16,31 @@ from steward.core.time_range import parse_range, DEFAULT_RANGE
|
|||||||
from .dedup import dedup_by_container_id
|
from .dedup import dedup_by_container_id
|
||||||
from .swarm_view import build_swarm_services
|
from .swarm_view import build_swarm_services
|
||||||
from .models import (
|
from .models import (
|
||||||
DockerContainer, DockerDiskUsage, DockerEvent, DockerImage, DockerMetric,
|
DockerContainer, DockerDiskUsage, DockerEvent, DockerImage, DockerLog,
|
||||||
DockerSwarmNode, DockerSwarmService,
|
DockerMetric, DockerSwarmNode, DockerSwarmService,
|
||||||
)
|
)
|
||||||
|
|
||||||
docker_bp = Blueprint("docker", __name__, template_folder="templates")
|
docker_bp = Blueprint("docker", __name__, template_folder="templates")
|
||||||
|
|
||||||
|
|
||||||
|
def _error(status: int, code: str, detail: str | None = None):
|
||||||
|
body: dict = {"ok": False, "error": code}
|
||||||
|
if detail:
|
||||||
|
body["detail"] = detail
|
||||||
|
return jsonify(body), status
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_extra_vars(prune_target: str) -> dict:
|
||||||
|
"""Extra-vars for the bundled docker_prune playbook. "Prune unused images"
|
||||||
|
means ALL unused (docker image prune -a), not just dangling; the full system
|
||||||
|
prune stays conservative (-f, no -a) per the operator's choice (m78). Pure
|
||||||
|
helper so the mapping is unit-tested without the request/DB stack."""
|
||||||
|
extra_vars: dict = {"prune_target": prune_target}
|
||||||
|
if prune_target == "images":
|
||||||
|
extra_vars["prune_all_images"] = True
|
||||||
|
return extra_vars
|
||||||
|
|
||||||
|
|
||||||
def _human_bytes(n: int | None) -> str:
|
def _human_bytes(n: int | None) -> str:
|
||||||
"""Compact binary size string (e.g. '1.4 GiB', '512 MiB', '0 B')."""
|
"""Compact binary size string (e.g. '1.4 GiB', '512 MiB', '0 B')."""
|
||||||
if n is None:
|
if n is None:
|
||||||
@@ -499,9 +520,13 @@ async def swarm():
|
|||||||
async def disk():
|
async def disk():
|
||||||
"""Image/disk usage page: reclaimable space, per-image sizes, stopped count.
|
"""Image/disk usage page: reclaimable space, per-image sizes, stopped count.
|
||||||
|
|
||||||
Read-only — prune actions are deferred to the cleanup-actions milestone, so
|
Admins can reclaim space per host via the prune buttons, which fire the
|
||||||
this surfaces the numbers and notes where reclaim lives.
|
audited bundled prune playbook through Ansible (m78) — the collection agent
|
||||||
|
itself stays read-only.
|
||||||
"""
|
"""
|
||||||
|
from steward.core.capabilities import has_capability
|
||||||
|
from steward.models.ansible_inventory import AnsibleTarget
|
||||||
|
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
|
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
|
||||||
images = list((await db.execute(
|
images = list((await db.execute(
|
||||||
@@ -516,6 +541,16 @@ async def disk():
|
|||||||
for hid in stopped_rows:
|
for hid in stopped_rows:
|
||||||
stopped_by_host[hid] = stopped_by_host.get(hid, 0) + 1
|
stopped_by_host[hid] = stopped_by_host.get(hid, 0) + 1
|
||||||
hosts = await _host_map(db, {s.host_id for s in summaries})
|
hosts = await _host_map(db, {s.host_id for s in summaries})
|
||||||
|
# Which hosts have a linked Ansible target — gates the prune buttons
|
||||||
|
# (a target is required to route the playbook run to that host).
|
||||||
|
host_ids = {s.host_id for s in summaries}
|
||||||
|
target_by_host: dict[str, str] = {}
|
||||||
|
if host_ids:
|
||||||
|
for hid, tid in (await db.execute(
|
||||||
|
select(AnsibleTarget.host_id, AnsibleTarget.id)
|
||||||
|
.where(AnsibleTarget.host_id.in_(host_ids))
|
||||||
|
)).all():
|
||||||
|
target_by_host[hid] = tid
|
||||||
|
|
||||||
images_by_host: dict[str, list] = {}
|
images_by_host: dict[str, list] = {}
|
||||||
for im in images:
|
for im in images:
|
||||||
@@ -542,11 +577,60 @@ async def disk():
|
|||||||
},
|
},
|
||||||
"stopped": stopped_by_host.get(s.host_id, 0),
|
"stopped": stopped_by_host.get(s.host_id, 0),
|
||||||
"images": images_by_host.get(s.host_id, []),
|
"images": images_by_host.get(s.host_id, []),
|
||||||
|
"has_target": s.host_id in target_by_host,
|
||||||
}
|
}
|
||||||
for s in summaries
|
for s in summaries
|
||||||
]
|
]
|
||||||
host_groups.sort(key=lambda g: g["host_name"].lower())
|
host_groups.sort(key=lambda g: g["host_name"].lower())
|
||||||
return await render_template("docker/disk.html", host_groups=host_groups)
|
return await render_template(
|
||||||
|
"docker/disk.html", host_groups=host_groups,
|
||||||
|
ansible_available=has_capability("ansible.run_playbook"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.post("/disk/<host_id>/prune")
|
||||||
|
@require_role(UserRole.admin)
|
||||||
|
async def disk_prune(host_id: str):
|
||||||
|
"""Reclaim Docker disk on a host by firing the bundled prune playbook via
|
||||||
|
Ansible (audited, admin-gated) — the collection agent stays read-only (m78).
|
||||||
|
`target` selects the scope: containers | images | system.
|
||||||
|
"""
|
||||||
|
from steward.core.capabilities import has_capability, invoke_capability
|
||||||
|
from steward.ansible.sources import BUILTIN_SOURCE_NAME
|
||||||
|
from steward.models.ansible_inventory import AnsibleTarget
|
||||||
|
|
||||||
|
if not has_capability("ansible.run_playbook"):
|
||||||
|
return _error(400, "ansible_unavailable", "Ansible is not available")
|
||||||
|
|
||||||
|
form = await request.form
|
||||||
|
prune_target = (form.get("target", "") or "").strip()
|
||||||
|
if prune_target not in ("containers", "images", "system"):
|
||||||
|
return _error(400, "bad_target", "Unknown prune target")
|
||||||
|
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
target = (await db.execute(
|
||||||
|
select(AnsibleTarget).where(AnsibleTarget.host_id == host_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if target is None:
|
||||||
|
return _error(400, "no_target",
|
||||||
|
"Link an Ansible target to this host before pruning")
|
||||||
|
|
||||||
|
# extra_vars_map outranks the playbook's own `vars:` defaults.
|
||||||
|
extra_vars = _prune_extra_vars(prune_target)
|
||||||
|
|
||||||
|
actor_role = UserRole(session.get("user_role", "viewer"))
|
||||||
|
run, _source, err = await invoke_capability(
|
||||||
|
"ansible.run_playbook", actor_role,
|
||||||
|
current_app._get_current_object(), # type: ignore[attr-defined]
|
||||||
|
source_name=BUILTIN_SOURCE_NAME,
|
||||||
|
playbook_path="maintenance/docker_prune.yml",
|
||||||
|
inventory_scope=f"steward:target:{target.id}",
|
||||||
|
params={"extra_vars_map": extra_vars},
|
||||||
|
triggered_by=session.get("user_id"),
|
||||||
|
)
|
||||||
|
if err:
|
||||||
|
return _error(400, "prune_failed", err)
|
||||||
|
return redirect(url_for("ansible.run_detail", run_id=run.id))
|
||||||
|
|
||||||
|
|
||||||
@docker_bp.get("/container/<host_id>/<name>/history")
|
@docker_bp.get("/container/<host_id>/<name>/history")
|
||||||
@@ -563,3 +647,56 @@ async def container_history(host_id: str, name: str):
|
|||||||
have_data=len(cpu_hist) >= 2,
|
have_data=len(cpu_hist) >= 2,
|
||||||
range_key=range_key,
|
range_key=range_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Newest lines returned per fetch — the retained window is bounded by the ring,
|
||||||
|
# but a container can still hold thousands of lines; cap what one fetch renders.
|
||||||
|
_LOG_TAIL_DEFAULT = 500
|
||||||
|
|
||||||
|
|
||||||
|
async def _query_logs(db, host_id: str, name: str, stream: str, query: str,
|
||||||
|
limit: int) -> list:
|
||||||
|
"""The recent retained lines for one container, newest-first, optionally
|
||||||
|
filtered by stream and a case-insensitive substring.
|
||||||
|
|
||||||
|
Newest-first is deliberate: the viewer replaces the list on each poll, which
|
||||||
|
resets scroll to the top — so the latest lines stay visible without any
|
||||||
|
scroll handling, and older lines are a scroll away.
|
||||||
|
"""
|
||||||
|
stmt = (select(DockerLog.ts, DockerLog.stream, DockerLog.line)
|
||||||
|
.where(DockerLog.host_id == host_id)
|
||||||
|
.where(DockerLog.container_name == name))
|
||||||
|
if stream in ("stdout", "stderr"):
|
||||||
|
stmt = stmt.where(DockerLog.stream == stream)
|
||||||
|
if query:
|
||||||
|
stmt = stmt.where(DockerLog.line.ilike(f"%{query}%"))
|
||||||
|
stmt = stmt.order_by(DockerLog.ts.desc(), DockerLog.id.desc()).limit(limit)
|
||||||
|
return (await db.execute(stmt)).all()
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/container/<host_id>/<name>/logs")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def container_logs(host_id: str, name: str):
|
||||||
|
"""Full log-viewer page for one container (lines load via an HTMX fragment)."""
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
host = await db.get(Host, host_id)
|
||||||
|
return await render_template(
|
||||||
|
"docker/container_logs.html", host=host, host_id=host_id, name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/container/<host_id>/<name>/logs/lines")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def container_logs_lines(host_id: str, name: str):
|
||||||
|
"""HTMX fragment: the recent retained log lines, stream + text filtered.
|
||||||
|
Polled every few seconds by the viewer for near-live follow."""
|
||||||
|
stream = request.args.get("stream", "all")
|
||||||
|
query = (request.args.get("q") or "").strip()
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
rows = await _query_logs(db, host_id, name, stream, query, _LOG_TAIL_DEFAULT)
|
||||||
|
return await render_template(
|
||||||
|
"docker/_container_logs_lines.html",
|
||||||
|
lines=[{"ts": r.ts, "stream": r.stream, "line": r.line} for r in rows],
|
||||||
|
name=name, tail=_LOG_TAIL_DEFAULT,
|
||||||
|
filtered=bool(query) or stream in ("stdout", "stderr"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{# docker/_container_logs_lines.html — log-line fragment, HTMX-polled (m79).
|
||||||
|
{{ l.line }} is auto-escaped by Jinja, so log content can't inject markup. #}
|
||||||
|
{% if lines %}
|
||||||
|
{% for l in lines %}
|
||||||
|
<div style="display:flex;gap:0.6rem;white-space:pre-wrap;word-break:break-word;">
|
||||||
|
<span style="color:var(--text-dim);flex-shrink:0;" title="{{ l.ts }}">{{ l.ts.strftime("%m-%d %H:%M:%S") }}</span>
|
||||||
|
<span style="flex-shrink:0;width:3.2rem;color:{% if l.stream == 'stderr' %}var(--red){% else %}var(--text-muted){% endif %};">{{ l.stream }}</span>
|
||||||
|
<span style="flex:1;min-width:0;{% if l.stream == 'stderr' %}color:var(--text);{% endif %}">{{ l.line }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div style="color:var(--text-muted);padding:1rem 0.25rem;">
|
||||||
|
{% if filtered %}
|
||||||
|
No lines match the current filter.
|
||||||
|
{% else %}
|
||||||
|
No logs collected yet for <code>{{ name }}</code>. Lines appear within a few
|
||||||
|
seconds of the container writing to stdout/stderr — unless it's on the log
|
||||||
|
exclude list or log collection is turned off in
|
||||||
|
<a href="/settings/thresholds/">Settings → Thresholds & Retention</a>.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
|
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
|
||||||
{{ container.status }}{% if uptime %} · up {{ uptime }}{% endif %}
|
{{ container.status }}{% if uptime %} · up {{ uptime }}{% endif %}
|
||||||
{% if host %} · on <a href="/hosts/{{ host.id }}" style="color:var(--text-muted);">{{ host.name }}</a>{% endif %}
|
{% if host %} · on <a href="/hosts/{{ host.id }}" style="color:var(--text-muted);">{{ host.name }}</a>{% endif %}
|
||||||
|
· <a href="/plugins/docker/container/{{ host_id }}/{{ name }}/logs">View logs</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Facts grid ──────────────────────────────────────────────────────────── #}
|
{# ── Facts grid ──────────────────────────────────────────────────────────── #}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{# docker/container_logs.html — per-container log viewer (m79) #}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import crumbs %}
|
||||||
|
{% block title %}Logs — {{ name }} — Docker — Steward{% endblock %}
|
||||||
|
{% block breadcrumb %}{{ crumbs([
|
||||||
|
("Docker", "/plugins/docker/"),
|
||||||
|
(name, "/plugins/docker/container/" ~ host_id ~ "/" ~ name),
|
||||||
|
("Logs", "")]) }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:baseline;gap:0.6rem;margin-bottom:0.35rem;flex-wrap:wrap;">
|
||||||
|
<h1 class="page-title" style="margin-bottom:0;">{{ name }}</h1>
|
||||||
|
<span style="font-size:0.9rem;color:var(--text-muted);">logs</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1rem;">
|
||||||
|
Recent lines (newest first) collected by the host agent{% if host %} on
|
||||||
|
<a href="/hosts/{{ host.id }}" style="color:var(--text-muted);">{{ host.name }}</a>{% endif %} —
|
||||||
|
updated every few seconds.
|
||||||
|
<a href="/plugins/docker/container/{{ host_id }}/{{ name }}">← back to container</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Controls: stream filter + text search (drive the fragment via HTMX) ───── #}
|
||||||
|
<form id="log-controls" onsubmit="return false;"
|
||||||
|
style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.6rem;">
|
||||||
|
<label style="font-size:0.8rem;color:var(--text-muted);display:flex;align-items:center;gap:0.35rem;">
|
||||||
|
Stream
|
||||||
|
<select name="stream" style="padding:0.25rem 0.4rem;">
|
||||||
|
<option value="all">all</option>
|
||||||
|
<option value="stdout">stdout</option>
|
||||||
|
<option value="stderr">stderr</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<input type="search" name="q" placeholder="Filter lines…" autocomplete="off"
|
||||||
|
aria-label="Filter log lines"
|
||||||
|
style="flex:1;min-width:180px;padding:0.3rem 0.5rem;">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card-flush">
|
||||||
|
<div id="log-lines"
|
||||||
|
hx-get="/plugins/docker/container/{{ host_id }}/{{ name }}/logs/lines"
|
||||||
|
hx-trigger="load, every 5s, change from:#log-controls, keyup changed delay:400ms from:#log-controls"
|
||||||
|
hx-include="#log-controls"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
role="log" aria-live="polite" tabindex="0"
|
||||||
|
style="max-height:70vh;overflow:auto;padding:0.5rem 0.75rem;
|
||||||
|
font-family:ui-monospace,monospace;font-size:0.8rem;line-height:1.5;">
|
||||||
|
<div style="color:var(--text-muted);">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
<h1 class="page-title" style="margin-bottom:0.4rem;">Image & disk usage</h1>
|
<h1 class="page-title" style="margin-bottom:0.4rem;">Image & disk usage</h1>
|
||||||
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
|
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
|
||||||
Reclaimable = space held by images no container references. Cleanup actions
|
Reclaimable = space held by images no container references. Admins can prune
|
||||||
(prune) arrive in a later release — these are read-only figures for now.
|
per host below; each action runs an audited Ansible playbook on that host.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if host_groups %}
|
{% if host_groups %}
|
||||||
@@ -47,6 +47,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ── Cleanup actions (admin only; audited Ansible prune run) ───────────── #}
|
||||||
|
{% if session.user_role == 'admin' %}
|
||||||
|
<div style="margin-bottom:1rem;">
|
||||||
|
{% if ansible_available and g.has_target %}
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;">
|
||||||
|
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||||
|
data-msg="Remove all STOPPED containers on {{ g.host_name|e }}? This cannot be undone."
|
||||||
|
onsubmit="return confirm(this.dataset.msg);">
|
||||||
|
<input type="hidden" name="target" value="containers">
|
||||||
|
<button type="submit" class="btn btn-sm">Prune stopped containers</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||||
|
data-msg="Remove ALL unused images on {{ g.host_name|e }} (docker image prune -a)? Any image not used by a container is deleted."
|
||||||
|
onsubmit="return confirm(this.dataset.msg);">
|
||||||
|
<input type="hidden" name="target" value="images">
|
||||||
|
<button type="submit" class="btn btn-sm">Prune unused images</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||||
|
data-msg="Run a full system prune on {{ g.host_name|e }}? Removes stopped containers, unused networks, dangling images and build cache."
|
||||||
|
onsubmit="return confirm(this.dataset.msg);">
|
||||||
|
<input type="hidden" name="target" value="system">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">System prune…</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.72rem;color:var(--text-muted);margin-top:0.4rem;">
|
||||||
|
Reclaimed space appears on the next agent sample.
|
||||||
|
</div>
|
||||||
|
{% elif not ansible_available %}
|
||||||
|
<div style="font-size:0.74rem;color:var(--text-muted);">Cleanup needs the Ansible runner (currently unavailable).</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="font-size:0.74rem;color:var(--text-muted);">
|
||||||
|
Link an <a href="/hosts/{{ g.host_id }}">Ansible target</a> to this host to enable prune actions.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# ── Per-image table ──────────────────────────────────────────────────── #}
|
{# ── Per-image table ──────────────────────────────────────────────────── #}
|
||||||
<div class="card-flush">
|
<div class="card-flush">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
|
|||||||
+254
-10
@@ -20,7 +20,7 @@ from collections import deque
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
AGENT_VERSION = "1.6.0"
|
AGENT_VERSION = "1.7.0"
|
||||||
|
|
||||||
# Default path to the local Docker Engine socket. Overridable via the
|
# Default path to the local Docker Engine socket. Overridable via the
|
||||||
# `docker_socket` config key; collection is silently skipped if it's absent or
|
# `docker_socket` config key; collection is silently skipped if it's absent or
|
||||||
@@ -34,7 +34,10 @@ class ConfigError(Exception):
|
|||||||
|
|
||||||
REQUIRED_KEYS = ("url", "token")
|
REQUIRED_KEYS = ("url", "token")
|
||||||
INT_KEYS = ("interval_seconds",)
|
INT_KEYS = ("interval_seconds",)
|
||||||
LIST_KEYS = ("mounts",)
|
LIST_KEYS = ("mounts", "docker_log_exclude")
|
||||||
|
# Truthy strings for bool keys; anything else (incl. empty) is False.
|
||||||
|
BOOL_KEYS = ("docker_logs_enabled",)
|
||||||
|
_BOOL_TRUE = ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
def read_config(path: str) -> dict:
|
def read_config(path: str) -> dict:
|
||||||
@@ -58,6 +61,8 @@ def read_config(path: str) -> dict:
|
|||||||
raise ConfigError(f"{path}:{lineno}: {key} must be int")
|
raise ConfigError(f"{path}:{lineno}: {key} must be int")
|
||||||
elif key in LIST_KEYS:
|
elif key in LIST_KEYS:
|
||||||
cfg[key] = [v.strip() for v in value.split(",") if v.strip()]
|
cfg[key] = [v.strip() for v in value.split(",") if v.strip()]
|
||||||
|
elif key in BOOL_KEYS:
|
||||||
|
cfg[key] = value.lower() in _BOOL_TRUE
|
||||||
else:
|
else:
|
||||||
cfg[key] = value
|
cfg[key] = value
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -69,6 +74,10 @@ def read_config(path: str) -> dict:
|
|||||||
|
|
||||||
cfg.setdefault("interval_seconds", 30)
|
cfg.setdefault("interval_seconds", 30)
|
||||||
cfg.setdefault("docker_socket", DEFAULT_DOCKER_SOCKET)
|
cfg.setdefault("docker_socket", DEFAULT_DOCKER_SOCKET)
|
||||||
|
# Container-log collection is on by default (operator preference); a host can
|
||||||
|
# opt out with `docker_logs_enabled = false` or thin it with docker_log_exclude.
|
||||||
|
cfg.setdefault("docker_logs_enabled", True)
|
||||||
|
cfg.setdefault("docker_log_exclude", [])
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -403,6 +412,15 @@ def _rates(cur: dict, prev: dict, dt: float) -> dict:
|
|||||||
|
|
||||||
DOCKER_API_TIMEOUT = 5.0
|
DOCKER_API_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
# Container-log collection (m79). Logs ride in the same push as metrics, so one
|
||||||
|
# interval's batch is capped to keep a chatty container from bloating a POST (and
|
||||||
|
# the backoff ring): once the cap is hit a marker line is emitted and collection
|
||||||
|
# stops — the deferred lines come on the next interval. On a container's first
|
||||||
|
# interval we seed from a short tail, then switch to an incremental since-cursor
|
||||||
|
# kept per container in the agent's rate-state.
|
||||||
|
DOCKER_LOG_BATCH_MAX_BYTES = 262144 # 256 KiB of log text per push, all containers
|
||||||
|
DOCKER_LOG_FIRST_TAIL = 50 # lines seeded on a container's first interval
|
||||||
|
|
||||||
|
|
||||||
def _dechunk(body: bytes) -> bytes:
|
def _dechunk(body: bytes) -> bytes:
|
||||||
"""Decode an HTTP/1.1 chunked-transfer body into the raw payload."""
|
"""Decode an HTTP/1.1 chunked-transfer body into the raw payload."""
|
||||||
@@ -650,6 +668,198 @@ def collect_docker(socket_path: str) -> list:
|
|||||||
return list(ex.map(lambda c: _collect_one_container(socket_path, c), containers))
|
return list(ex.map(lambda c: _collect_one_container(socket_path, c), containers))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── container logs (m79) ─────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The logs endpoint returns a raw multiplexed byte stream, not JSON, so it needs
|
||||||
|
# a sibling of _docker_request that hands back bytes. Non-TTY containers frame
|
||||||
|
# stdout/stderr with an 8-byte header (stream byte + 4-byte big-endian length);
|
||||||
|
# TTY containers emit unframed bytes. We request timestamps=1, so every line is
|
||||||
|
# prefixed with an RFC3339Nano timestamp we parse for the since-cursor.
|
||||||
|
|
||||||
|
_LOG_STREAM_NAMES = {0: "stdout", 1: "stdout", 2: "stderr"}
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_request_raw(socket_path: str, path: str,
|
||||||
|
timeout: float = DOCKER_API_TIMEOUT) -> bytes:
|
||||||
|
"""GET `path` from the Docker API over the Unix socket; return the raw body.
|
||||||
|
|
||||||
|
Sibling of `_docker_request` for non-JSON endpoints (container logs). Reuses
|
||||||
|
the connect / send / de-chunk scaffolding; raises OSError on any transport
|
||||||
|
problem or non-2xx status so callers can silent-skip. A 200 with an empty
|
||||||
|
body (no new lines since the cursor) returns b"".
|
||||||
|
"""
|
||||||
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
try:
|
||||||
|
sock.connect(socket_path)
|
||||||
|
req = (
|
||||||
|
"GET " + path + " HTTP/1.1\r\n"
|
||||||
|
"Host: docker\r\n"
|
||||||
|
"Accept: application/octet-stream\r\n"
|
||||||
|
"Connection: close\r\n"
|
||||||
|
"\r\n"
|
||||||
|
)
|
||||||
|
sock.sendall(req.encode("ascii"))
|
||||||
|
chunks = []
|
||||||
|
while True:
|
||||||
|
buf = sock.recv(65536)
|
||||||
|
if not buf:
|
||||||
|
break
|
||||||
|
chunks.append(buf)
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
raw = b"".join(chunks)
|
||||||
|
head, _, body = raw.partition(b"\r\n\r\n")
|
||||||
|
header_text = head.decode("latin-1")
|
||||||
|
status_line = header_text.split("\r\n", 1)[0]
|
||||||
|
parts = status_line.split(None, 2)
|
||||||
|
status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else 0
|
||||||
|
if not (200 <= status < 300):
|
||||||
|
raise OSError(f"docker API {path} returned {status}")
|
||||||
|
if "transfer-encoding: chunked" in header_text.lower():
|
||||||
|
body = _dechunk(body)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _demux_docker_logs(raw: bytes) -> list:
|
||||||
|
"""Split a Docker logs stream into [(stream_name, payload_bytes), …].
|
||||||
|
|
||||||
|
Non-TTY containers multiplex with an 8-byte frame header (stream byte in
|
||||||
|
{0,1,2} + 4-byte big-endian length). The stream is treated as framed only if
|
||||||
|
the whole buffer parses as clean back-to-back frames; anything else (a TTY
|
||||||
|
container's raw stream, or a malformed header) falls back to a single stdout
|
||||||
|
blob so no bytes are lost.
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
frames = []
|
||||||
|
i, n = 0, len(raw)
|
||||||
|
while i + 8 <= n:
|
||||||
|
stream = raw[i]
|
||||||
|
if stream > 2 or raw[i + 1] or raw[i + 2] or raw[i + 3]:
|
||||||
|
break # not a valid frame header → not framed
|
||||||
|
size = int.from_bytes(raw[i + 4:i + 8], "big")
|
||||||
|
if i + 8 + size > n:
|
||||||
|
break # frame overruns the buffer → not framed
|
||||||
|
frames.append((_LOG_STREAM_NAMES.get(stream, "stdout"),
|
||||||
|
raw[i + 8:i + 8 + size]))
|
||||||
|
i += 8 + size
|
||||||
|
if frames and i == n:
|
||||||
|
return frames
|
||||||
|
return [("stdout", raw)]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_log_ts(token: str):
|
||||||
|
"""Parse a Docker RFC3339Nano timestamp token into a datetime, or None.
|
||||||
|
|
||||||
|
Normalises `Z` → `+00:00` and trims Docker's nanosecond precision to the
|
||||||
|
microseconds datetime.fromisoformat accepts (stdlib, Python 3.8+ safe).
|
||||||
|
"""
|
||||||
|
t = token.strip()
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
if t.endswith("Z"):
|
||||||
|
t = t[:-1] + "+00:00"
|
||||||
|
m = re.match(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(.*)$", t)
|
||||||
|
if m:
|
||||||
|
frac = m.group(2) or ""
|
||||||
|
if len(frac) > 7: # "." + up to 6 fractional digits
|
||||||
|
frac = frac[:7]
|
||||||
|
t = m.group(1) + frac + m.group(3)
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(t)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_container_logs(raw: bytes) -> list:
|
||||||
|
"""Demux + line-split a logs stream → [(dt|None, stream, line), …] in order.
|
||||||
|
|
||||||
|
Each line is timestamp-prefixed (we request timestamps=1); an unparseable
|
||||||
|
prefix yields dt=None with the full raw line kept.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for stream, payload in _demux_docker_logs(raw):
|
||||||
|
text = payload.decode("utf-8", "replace")
|
||||||
|
for raw_line in text.split("\n"):
|
||||||
|
if not raw_line:
|
||||||
|
continue
|
||||||
|
token, _, msg = raw_line.partition(" ")
|
||||||
|
dt = _parse_log_ts(token)
|
||||||
|
if dt is not None:
|
||||||
|
out.append((dt, stream, msg))
|
||||||
|
else:
|
||||||
|
out.append((None, stream, raw_line))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def collect_docker_logs(socket_path: str, containers: list, state: dict,
|
||||||
|
exclude=None,
|
||||||
|
max_bytes: int = DOCKER_LOG_BATCH_MAX_BYTES) -> list:
|
||||||
|
"""New container-log lines since the last interval, for running containers.
|
||||||
|
|
||||||
|
Keeps a per-container since-cursor in `state["docker_log_cursors"]`
|
||||||
|
(name → unix ts) so each interval fetches only what's new; a container's
|
||||||
|
first interval seeds from a short tail. The whole batch is capped at
|
||||||
|
`max_bytes` of log text — once exceeded a truncation marker is appended and
|
||||||
|
collection stops (logs must never balloon a push). `exclude` container names
|
||||||
|
are skipped entirely (a per-host bandwidth opt-out). Best-effort: a
|
||||||
|
per-container transport error just contributes nothing this interval.
|
||||||
|
Returns [{"container", "stream", "ts", "line"}, …].
|
||||||
|
"""
|
||||||
|
exclude_set = set(exclude or ())
|
||||||
|
cursors = state.setdefault("docker_log_cursors", {})
|
||||||
|
running = {c.get("name") for c in (containers or [])
|
||||||
|
if c.get("name") and c.get("status") == "running"}
|
||||||
|
# Forget cursors for containers no longer running so state can't grow without
|
||||||
|
# bound over the agent's lifetime.
|
||||||
|
for gone in [k for k in cursors if k not in running]:
|
||||||
|
del cursors[gone]
|
||||||
|
|
||||||
|
out: list = []
|
||||||
|
total = 0
|
||||||
|
truncated = False
|
||||||
|
for c in (containers or []):
|
||||||
|
name = c.get("name")
|
||||||
|
if not name or name not in running or name in exclude_set:
|
||||||
|
continue
|
||||||
|
prev = cursors.get(name)
|
||||||
|
base = f"/containers/{name}/logs?stdout=1&stderr=1×tamps=1"
|
||||||
|
# since accepts a fractional Unix ts; we still dedup the boundary line.
|
||||||
|
path = (f"{base}&tail={DOCKER_LOG_FIRST_TAIL}" if prev is None
|
||||||
|
else f"{base}&since={prev:.9f}")
|
||||||
|
try:
|
||||||
|
raw = _docker_request_raw(socket_path, path)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
newest = prev
|
||||||
|
for dt, stream, line in _parse_container_logs(raw):
|
||||||
|
ts_unix = dt.timestamp() if dt is not None else None
|
||||||
|
if prev is not None and ts_unix is not None and ts_unix <= prev:
|
||||||
|
continue # boundary line already sent last interval
|
||||||
|
if total >= max_bytes:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
out.append({"container": name, "stream": stream,
|
||||||
|
"ts": dt.isoformat() if dt is not None else None,
|
||||||
|
"line": line})
|
||||||
|
total += len(line)
|
||||||
|
if ts_unix is not None and (newest is None or ts_unix > newest):
|
||||||
|
newest = ts_unix
|
||||||
|
if newest is not None:
|
||||||
|
cursors[name] = newest
|
||||||
|
if truncated:
|
||||||
|
break
|
||||||
|
|
||||||
|
if truncated:
|
||||||
|
out.append({"container": "_steward", "stream": "stderr",
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"line": (f"[steward] log batch truncated at {max_bytes} bytes; "
|
||||||
|
"remaining lines deferred to the next interval")})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ─── swarm (manager-only) ────────────────────────────────────────────────────
|
# ─── swarm (manager-only) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -852,13 +1062,17 @@ class RingBuffer:
|
|||||||
|
|
||||||
|
|
||||||
def build_sample(mounts: list[str], state: dict,
|
def build_sample(mounts: list[str], state: dict,
|
||||||
docker_socket: str = DEFAULT_DOCKER_SOCKET) -> dict:
|
docker_socket: str = DEFAULT_DOCKER_SOCKET, *,
|
||||||
|
docker_logs_enabled: bool = True,
|
||||||
|
docker_log_exclude=None) -> dict:
|
||||||
"""Collect one full sample. Partial samples allowed if a collector fails.
|
"""Collect one full sample. Partial samples allowed if a collector fails.
|
||||||
|
|
||||||
`state` carries the previous network/disk counters + monotonic timestamp so
|
`state` carries the previous network/disk counters + monotonic timestamp so
|
||||||
throughput rates can be derived from deltas; it is mutated in place.
|
throughput rates can be derived from deltas (and the per-container log
|
||||||
`docker_socket` is probed best-effort — the `docker` key is omitted entirely
|
since-cursors); it is mutated in place. `docker_socket` is probed
|
||||||
when no containers are found, so non-Docker hosts add nothing to the payload.
|
best-effort — the `docker` key is omitted entirely when no containers are
|
||||||
|
found, so non-Docker hosts add nothing to the payload. `docker_logs_enabled`
|
||||||
|
/ `docker_log_exclude` gate incremental container-log collection.
|
||||||
"""
|
"""
|
||||||
sample: dict = {"ts": datetime.now(timezone.utc).isoformat()}
|
sample: dict = {"ts": datetime.now(timezone.utc).isoformat()}
|
||||||
try:
|
try:
|
||||||
@@ -940,6 +1154,18 @@ def build_sample(mounts: list[str], state: dict,
|
|||||||
if disk is not None:
|
if disk is not None:
|
||||||
sample["docker_disk"] = disk
|
sample["docker_disk"] = disk
|
||||||
|
|
||||||
|
# Container logs (m79): incremental per-container tail folded into the same
|
||||||
|
# push as metrics. Skipped when there are no containers or logging is off;
|
||||||
|
# the key is omitted when nothing new arrived this interval.
|
||||||
|
if docker and docker_logs_enabled:
|
||||||
|
try:
|
||||||
|
logs = collect_docker_logs(docker_socket, docker, state,
|
||||||
|
exclude=docker_log_exclude)
|
||||||
|
except Exception:
|
||||||
|
logs = []
|
||||||
|
if logs:
|
||||||
|
sample["docker_logs"] = logs
|
||||||
|
|
||||||
return sample
|
return sample
|
||||||
|
|
||||||
|
|
||||||
@@ -958,6 +1184,18 @@ def build_payload(samples: list[dict], hostname: str, metadata: dict) -> dict:
|
|||||||
BACKOFF_CAP = 300
|
BACKOFF_CAP = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_logs(sample: dict) -> dict:
|
||||||
|
"""Strip container logs from a sample before it's buffered for retry.
|
||||||
|
|
||||||
|
Logs are the one payload we never carry across a backoff — they'd bloat the
|
||||||
|
ring buffer and go stale — while metrics are kept so an outage doesn't lose
|
||||||
|
them. The since-cursor already advanced when the logs were collected, so the
|
||||||
|
dropped lines are simply not re-sent (accepted loss during an outage).
|
||||||
|
"""
|
||||||
|
sample.pop("docker_logs", None)
|
||||||
|
return sample
|
||||||
|
|
||||||
|
|
||||||
def next_backoff(current: int) -> int:
|
def next_backoff(current: int) -> int:
|
||||||
if current <= 0:
|
if current <= 0:
|
||||||
return 30
|
return 30
|
||||||
@@ -1022,6 +1260,8 @@ def main_loop(conf_path: str) -> int:
|
|||||||
hostname = cfg.get("hostname") or socket.gethostname()
|
hostname = cfg.get("hostname") or socket.gethostname()
|
||||||
mounts = cfg.get("mounts") or default_mounts()
|
mounts = cfg.get("mounts") or default_mounts()
|
||||||
docker_socket = cfg.get("docker_socket") or DEFAULT_DOCKER_SOCKET
|
docker_socket = cfg.get("docker_socket") or DEFAULT_DOCKER_SOCKET
|
||||||
|
docker_logs_enabled = cfg.get("docker_logs_enabled", True)
|
||||||
|
docker_log_exclude = cfg.get("docker_log_exclude") or []
|
||||||
buffer = RingBuffer(maxlen=20)
|
buffer = RingBuffer(maxlen=20)
|
||||||
backoff = 0
|
backoff = 0
|
||||||
# Carries previous net/disk counters + monotonic ts for rate computation.
|
# Carries previous net/disk counters + monotonic ts for rate computation.
|
||||||
@@ -1036,13 +1276,17 @@ def main_loop(conf_path: str) -> int:
|
|||||||
cfg = read_config(conf_path)
|
cfg = read_config(conf_path)
|
||||||
mounts = cfg.get("mounts") or default_mounts()
|
mounts = cfg.get("mounts") or default_mounts()
|
||||||
docker_socket = cfg.get("docker_socket") or DEFAULT_DOCKER_SOCKET
|
docker_socket = cfg.get("docker_socket") or DEFAULT_DOCKER_SOCKET
|
||||||
|
docker_logs_enabled = cfg.get("docker_logs_enabled", True)
|
||||||
|
docker_log_exclude = cfg.get("docker_log_exclude") or []
|
||||||
metadata = collect_metadata() # refresh host_ip/distro on reload
|
metadata = collect_metadata() # refresh host_ip/distro on reload
|
||||||
_log("INFO", "config reloaded")
|
_log("INFO", "config reloaded")
|
||||||
except ConfigError as e:
|
except ConfigError as e:
|
||||||
_log("ERROR", f"reload failed: {e}")
|
_log("ERROR", f"reload failed: {e}")
|
||||||
_reload_requested = False
|
_reload_requested = False
|
||||||
|
|
||||||
sample = build_sample(mounts, rate_state, docker_socket)
|
sample = build_sample(mounts, rate_state, docker_socket,
|
||||||
|
docker_logs_enabled=docker_logs_enabled,
|
||||||
|
docker_log_exclude=docker_log_exclude)
|
||||||
buffered = buffer.drain()
|
buffered = buffer.drain()
|
||||||
payload = build_payload(
|
payload = build_payload(
|
||||||
samples=buffered + [sample],
|
samples=buffered + [sample],
|
||||||
@@ -1061,12 +1305,12 @@ def main_loop(conf_path: str) -> int:
|
|||||||
_log("ERROR", "server rejected payload (400) — dropping sample")
|
_log("ERROR", "server rejected payload (400) — dropping sample")
|
||||||
elif status == 401:
|
elif status == 401:
|
||||||
_log("ERROR", "token rejected (401) — check config + UI")
|
_log("ERROR", "token rejected (401) — check config + UI")
|
||||||
buffer.push(sample)
|
buffer.push(_drop_logs(sample))
|
||||||
else:
|
else:
|
||||||
_log("WARN", f"POST failed (status={status}); buffering")
|
_log("WARN", f"POST failed (status={status}); buffering")
|
||||||
for s in buffered:
|
for s in buffered:
|
||||||
buffer.push(s)
|
buffer.push(_drop_logs(s))
|
||||||
buffer.push(sample)
|
buffer.push(_drop_logs(sample))
|
||||||
backoff = next_backoff(backoff)
|
backoff = next_backoff(backoff)
|
||||||
sleep_for = backoff
|
sleep_for = backoff
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ async def ingest():
|
|||||||
accepted = 0
|
accepted = 0
|
||||||
latest_ts: datetime | None = None
|
latest_ts: datetime | None = None
|
||||||
docker_snapshots: list[tuple[datetime, list]] = []
|
docker_snapshots: list[tuple[datetime, list]] = []
|
||||||
|
docker_log_batches: list[tuple[datetime, list]] = []
|
||||||
latest_swarm: dict | None = None
|
latest_swarm: dict | None = None
|
||||||
latest_swarm_ts: datetime | None = None
|
latest_swarm_ts: datetime | None = None
|
||||||
latest_disk: dict | None = None
|
latest_disk: dict | None = None
|
||||||
@@ -217,6 +218,11 @@ async def ingest():
|
|||||||
docker = sample.get("docker")
|
docker = sample.get("docker")
|
||||||
if isinstance(docker, list) and docker:
|
if isinstance(docker, list) and docker:
|
||||||
docker_snapshots.append((recorded_at, docker))
|
docker_snapshots.append((recorded_at, docker))
|
||||||
|
# Container logs are time-series (append every line, not newest-only);
|
||||||
|
# each record carries its own Docker ts, recorded_at is the fallback.
|
||||||
|
docker_logs = sample.get("docker_logs")
|
||||||
|
if isinstance(docker_logs, list) and docker_logs:
|
||||||
|
docker_log_batches.append((recorded_at, docker_logs))
|
||||||
# Swarm is current-state, not time-series — keep only the newest
|
# Swarm is current-state, not time-series — keep only the newest
|
||||||
# sample's topology (a manager re-reports it every interval).
|
# sample's topology (a manager re-reports it every interval).
|
||||||
swarm = sample.get("swarm")
|
swarm = sample.get("swarm")
|
||||||
@@ -240,7 +246,8 @@ async def ingest():
|
|||||||
# (opportunistic synergy via the capability registry — no hard import,
|
# (opportunistic synergy via the capability registry — no hard import,
|
||||||
# no-op when docker is disabled). A failure here must never sink the
|
# no-op when docker is disabled). A failure here must never sink the
|
||||||
# whole ingest, so the metrics above still land.
|
# whole ingest, so the metrics above still land.
|
||||||
if docker_snapshots or latest_swarm is not None or latest_disk is not None:
|
if (docker_snapshots or latest_swarm is not None
|
||||||
|
or latest_disk is not None or docker_log_batches):
|
||||||
from steward.core.capabilities import has_capability, invoke_capability
|
from steward.core.capabilities import has_capability, invoke_capability
|
||||||
if has_capability("docker.persist_host_samples"):
|
if has_capability("docker.persist_host_samples"):
|
||||||
try:
|
try:
|
||||||
@@ -250,7 +257,7 @@ async def ingest():
|
|||||||
await invoke_capability(
|
await invoke_capability(
|
||||||
"docker.persist_host_samples", UserRole.admin,
|
"docker.persist_host_samples", UserRole.admin,
|
||||||
session, host, docker_snapshots, latest_swarm,
|
session, host, docker_snapshots, latest_swarm,
|
||||||
latest_disk,
|
latest_disk, docker_log_batches,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
current_app.logger.exception(
|
current_app.logger.exception(
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ cat > "$CONF_FILE" <<EOF
|
|||||||
url = $STEWARD_URL
|
url = $STEWARD_URL
|
||||||
token = $AGENT_TOKEN
|
token = $AGENT_TOKEN
|
||||||
interval_seconds = 30
|
interval_seconds = 30
|
||||||
|
# Container logs are collected by default. To opt this host out entirely:
|
||||||
|
# docker_logs_enabled = false
|
||||||
|
# To skip specific noisy containers (comma-separated names):
|
||||||
|
# docker_log_exclude = watchtower, some-chatty-service
|
||||||
EOF
|
EOF
|
||||||
chown "root:$AGENT_USER" "$CONF_FILE"
|
chown "root:$AGENT_USER" "$CONF_FILE"
|
||||||
chmod 0640 "$CONF_FILE"
|
chmod 0640 "$CONF_FILE"
|
||||||
|
|||||||
@@ -1,29 +1,64 @@
|
|||||||
---
|
---
|
||||||
# description: Reclaim disk on Docker / Swarm nodes by pruning unused images, containers, networks and build cache.
|
# description: Reclaim disk on Docker / Swarm nodes — prune stopped containers, unused images, or a full system prune.
|
||||||
# steward:category: maintenance
|
# steward:category: maintenance
|
||||||
# steward:confirm: true
|
# steward:confirm: true
|
||||||
# Reclaim disk on Docker / Docker Swarm nodes by removing unused data.
|
# Reclaim disk on Docker / Docker Swarm nodes by removing unused data.
|
||||||
# Safe by default: prunes dangling images, stopped containers, unused networks
|
# `prune_target` selects the scope (default `system` preserves the original
|
||||||
# and build cache. Set extra-vars to widen scope:
|
# behavior for existing manual/scheduled callers that don't set it):
|
||||||
# prune_all_images=true also remove ALL unused images (not just dangling)
|
# prune_target=containers remove stopped containers only (docker container prune)
|
||||||
# prune_volumes=true also remove unused named volumes (data loss risk)
|
# prune_target=images remove unused images (docker image prune;
|
||||||
- name: Docker system prune
|
# + prune_all_images=true → -a, i.e. ALL unused, not just dangling)
|
||||||
|
# prune_target=system docker system prune (dangling images, stopped
|
||||||
|
# containers, unused networks, build cache). Widen with:
|
||||||
|
# prune_all_images=true also ALL unused images
|
||||||
|
# prune_volumes=true also unused named volumes (data loss risk)
|
||||||
|
- name: Docker prune
|
||||||
hosts: all
|
hosts: all
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
become: true
|
become: true
|
||||||
vars:
|
vars:
|
||||||
|
prune_target: system
|
||||||
prune_all_images: false
|
prune_all_images: false
|
||||||
prune_volumes: false
|
prune_volumes: false
|
||||||
tasks:
|
tasks:
|
||||||
|
- name: Validate prune_target
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: prune_target in ['containers', 'images', 'system']
|
||||||
|
fail_msg: "prune_target must be one of: containers, images, system (got '{{ prune_target }}')"
|
||||||
|
quiet: true
|
||||||
|
|
||||||
|
# ── Stopped containers only ───────────────────────────────────────────────
|
||||||
|
- name: Prune stopped containers
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: ['docker', 'container', 'prune', '-f']
|
||||||
|
register: container_prune
|
||||||
|
changed_when: "'Total reclaimed space: 0B' not in container_prune.stdout"
|
||||||
|
when: prune_target == 'containers'
|
||||||
|
|
||||||
|
# ── Unused images (dangling, or all unused with -a) ───────────────────────
|
||||||
|
- name: Prune unused images
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: >-
|
||||||
|
{{ ['docker', 'image', 'prune', '-f']
|
||||||
|
+ (['-a'] if prune_all_images | bool else []) }}
|
||||||
|
register: image_prune
|
||||||
|
changed_when: "'Total reclaimed space: 0B' not in image_prune.stdout"
|
||||||
|
when: prune_target == 'images'
|
||||||
|
|
||||||
|
# ── Full system prune (default) ───────────────────────────────────────────
|
||||||
- name: Run docker system prune
|
- name: Run docker system prune
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
argv: >-
|
argv: >-
|
||||||
{{ ['docker', 'system', 'prune', '-f']
|
{{ ['docker', 'system', 'prune', '-f']
|
||||||
+ (['-a'] if prune_all_images | bool else [])
|
+ (['-a'] if prune_all_images | bool else [])
|
||||||
+ (['--volumes'] if prune_volumes | bool else []) }}
|
+ (['--volumes'] if prune_volumes | bool else []) }}
|
||||||
register: prune_result
|
register: system_prune
|
||||||
changed_when: "'Total reclaimed space: 0B' not in prune_result.stdout"
|
changed_when: "'Total reclaimed space: 0B' not in system_prune.stdout"
|
||||||
|
when: prune_target == 'system'
|
||||||
|
|
||||||
- name: Report reclaimed space
|
- name: Report reclaimed space
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: "{{ prune_result.stdout_lines | select | list }}"
|
msg: >-
|
||||||
|
{{ ((container_prune.stdout_lines | default([]))
|
||||||
|
+ (image_prune.stdout_lines | default([]))
|
||||||
|
+ (system_prune.stdout_lines | default([]))) | select | list }}
|
||||||
|
|||||||
+42
-2
@@ -3,9 +3,12 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from quart import Quart, render_template
|
from quart import Quart, render_template, request
|
||||||
from .config import load_bootstrap
|
from .config import load_bootstrap
|
||||||
from .database import init_db
|
from .database import (
|
||||||
|
init_db, ensure_database_reachable, DatabaseUnavailable,
|
||||||
|
DB_CONNECT_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -23,6 +26,7 @@ def create_app(
|
|||||||
bootstrap = {
|
bootstrap = {
|
||||||
"database_url": "postgresql+asyncpg://test/test",
|
"database_url": "postgresql+asyncpg://test/test",
|
||||||
"secret_key": "test-secret-key",
|
"secret_key": "test-secret-key",
|
||||||
|
"db_connect_timeout": DB_CONNECT_TIMEOUT_SECONDS,
|
||||||
"plugin_dirs": ["plugins"],
|
"plugin_dirs": ["plugins"],
|
||||||
"plugin_install_dir": "plugins",
|
"plugin_install_dir": "plugins",
|
||||||
}
|
}
|
||||||
@@ -42,6 +46,19 @@ def create_app(
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
app.db_sessionmaker = MagicMock()
|
app.db_sessionmaker = MagicMock()
|
||||||
|
|
||||||
|
# ── 2b. Block until the database is actually reachable ────────────────────
|
||||||
|
# Everything from here down touches the DB synchronously (migrations,
|
||||||
|
# secret re-encryption, settings load). Gate all of it behind one readiness
|
||||||
|
# check so a DB that is merely slow to come up — WAL recovery after an
|
||||||
|
# unclean shutdown, or Docker DNS not yet serving the `db` name after a host
|
||||||
|
# reboot — is waited out instead of crashing the container on a traceback.
|
||||||
|
if not testing:
|
||||||
|
from .database import wait_for_database
|
||||||
|
wait_for_database(
|
||||||
|
app.config["DATABASE_URL"],
|
||||||
|
timeout_seconds=bootstrap["db_connect_timeout"],
|
||||||
|
)
|
||||||
|
|
||||||
# ── 3. Core migrations only (creates app_settings table) ──────────────────
|
# ── 3. Core migrations only (creates app_settings table) ──────────────────
|
||||||
if not testing:
|
if not testing:
|
||||||
from .core.migration_runner import run_core_migrations
|
from .core.migration_runner import run_core_migrations
|
||||||
@@ -231,6 +248,29 @@ def create_app(
|
|||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# ── 11b. Database availability gate ────────────────────────────────────────
|
||||||
|
# Every page in Steward reads the database, so a database that has gone away
|
||||||
|
# under a running app turns each request into an opaque 500 (this is how a
|
||||||
|
# login attempt surfaced a bare gaierror). Acquire a connection up front,
|
||||||
|
# retrying briefly to ride out a restart, and answer honestly if it stays
|
||||||
|
# down rather than failing deep inside a handler with a generic error.
|
||||||
|
if not testing:
|
||||||
|
@app.before_request
|
||||||
|
async def _database_gate():
|
||||||
|
# /health is a liveness probe for the container itself — it must
|
||||||
|
# stay answerable while the database is down, or a restart loop
|
||||||
|
# gets triggered by a dependency outage. Static files need no DB.
|
||||||
|
if request.endpoint in ("health", "static"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
await ensure_database_reachable(app._db_engine)
|
||||||
|
except DatabaseUnavailable as exc:
|
||||||
|
logger.error("Database unreachable while serving %s: %s",
|
||||||
|
request.path, exc)
|
||||||
|
return await render_template(
|
||||||
|
"errors/database_unavailable.html"), 503
|
||||||
|
return None
|
||||||
|
|
||||||
# ── 12. Error handlers ─────────────────────────────────────────────────────
|
# ── 12. Error handlers ─────────────────────────────────────────────────────
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
async def not_found(_):
|
async def not_found(_):
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, Any]:
|
|||||||
|
|
||||||
secret_key = _resolve_secret_key(raw)
|
secret_key = _resolve_secret_key(raw)
|
||||||
|
|
||||||
|
db_connect_timeout = _resolve_db_connect_timeout(raw)
|
||||||
|
|
||||||
# Plugin discovery spans two roots (see load_plugins / migration_runner):
|
# Plugin discovery spans two roots (see load_plugins / migration_runner):
|
||||||
# • bundled — first-party plugins shipped inside the image at repo-root
|
# • bundled — first-party plugins shipped inside the image at repo-root
|
||||||
# `plugins/`; they version atomically with core and are read-only at runtime.
|
# `plugins/`; they version atomically with core and are read-only at runtime.
|
||||||
@@ -68,12 +70,47 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"database_url": database_url,
|
"database_url": database_url,
|
||||||
"secret_key": secret_key,
|
"secret_key": secret_key,
|
||||||
|
"db_connect_timeout": db_connect_timeout,
|
||||||
"plugin_dirs": plugin_dirs,
|
"plugin_dirs": plugin_dirs,
|
||||||
# Installs/downloads target the external (writable, persistent) dir.
|
# Installs/downloads target the external (writable, persistent) dir.
|
||||||
"plugin_install_dir": external_plugin_dir or bundled_plugin_dir,
|
"plugin_install_dir": external_plugin_dir or bundled_plugin_dir,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_db_connect_timeout(raw: dict) -> float:
|
||||||
|
"""How long to wait for the database at startup, in seconds.
|
||||||
|
|
||||||
|
Bootstrap-only by necessity: this governs reaching the DB, so it cannot
|
||||||
|
itself be read from the DB like the rest of Steward's settings.
|
||||||
|
|
||||||
|
A deployment whose database is slower to come up than the default (a large
|
||||||
|
cluster replaying WAL, a remote DB behind a link that takes a while) can
|
||||||
|
raise it rather than crash-looping the container.
|
||||||
|
"""
|
||||||
|
from .database import DB_CONNECT_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
value = _env("DB_CONNECT_TIMEOUT") or raw.get("database", {}).get("connect_timeout")
|
||||||
|
if value is None or value == "":
|
||||||
|
return DB_CONNECT_TIMEOUT_SECONDS
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning(
|
||||||
|
"Invalid database connect timeout %r — using default %.0fs",
|
||||||
|
value, DB_CONNECT_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
return DB_CONNECT_TIMEOUT_SECONDS
|
||||||
|
if parsed <= 0:
|
||||||
|
# 0/negative would mean "never wait", which is the broken behaviour this
|
||||||
|
# setting exists to fix — treat it as a mistake, not as an opt-out.
|
||||||
|
logger.warning(
|
||||||
|
"Database connect timeout %.0fs is not positive — using default %.0fs",
|
||||||
|
parsed, DB_CONNECT_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
return DB_CONNECT_TIMEOUT_SECONDS
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def _resolve_secret_key(raw: dict) -> str:
|
def _resolve_secret_key(raw: dict) -> str:
|
||||||
"""Resolve secret_key: env var → file → auto-generate.
|
"""Resolve secret_key: env var → file → auto-generate.
|
||||||
|
|
||||||
|
|||||||
@@ -76,10 +76,14 @@ async def _run_docker_retention(session, now: datetime) -> None:
|
|||||||
raw_days = int(await get_setting(session, "docker.retention.metrics_raw_days") or 7)
|
raw_days = int(await get_setting(session, "docker.retention.metrics_raw_days") or 7)
|
||||||
rollup_days = int(await get_setting(session, "docker.retention.metrics_rollup_days") or 90)
|
rollup_days = int(await get_setting(session, "docker.retention.metrics_rollup_days") or 90)
|
||||||
events_days = int(await get_setting(session, "docker.retention.events_days") or 30)
|
events_days = int(await get_setting(session, "docker.retention.events_days") or 30)
|
||||||
|
logs_days = int(await get_setting(session, "docker.logs.retention_days") or 3)
|
||||||
|
logs_cap = int(
|
||||||
|
await get_setting(session, "docker.logs.max_bytes_per_container") or 5_000_000)
|
||||||
counts = await invoke_capability(
|
counts = await invoke_capability(
|
||||||
"docker.run_retention", UserRole.viewer, session,
|
"docker.run_retention", UserRole.viewer, session,
|
||||||
events_days=events_days, metrics_raw_days=raw_days,
|
events_days=events_days, metrics_raw_days=raw_days,
|
||||||
metrics_rollup_days=rollup_days, now=now,
|
metrics_rollup_days=rollup_days, logs_retention_days=logs_days,
|
||||||
|
logs_max_bytes_per_container=logs_cap, now=now,
|
||||||
)
|
)
|
||||||
if counts and any(counts.values()):
|
if counts and any(counts.values()):
|
||||||
logger.info("Docker retention: %s", counts)
|
logger.info("Docker retention: %s", counts)
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ def _import_plugin(name: str, plugin_path: Path):
|
|||||||
"""Load a plugin module by file path, avoiding sys.modules stdlib collisions.
|
"""Load a plugin module by file path, avoiding sys.modules stdlib collisions.
|
||||||
|
|
||||||
Using importlib.import_module(name) fails for plugins whose names shadow
|
Using importlib.import_module(name) fails for plugins whose names shadow
|
||||||
Python stdlib modules (e.g. the 'http' plugin vs stdlib's 'http' package).
|
Python stdlib modules (e.g. a plugin named 'json' or 'http' vs the stdlib
|
||||||
|
package of the same name).
|
||||||
This helper loads from the filesystem path directly and registers the module
|
This helper loads from the filesystem path directly and registers the module
|
||||||
under a namespaced key so relative imports within the plugin still work.
|
under a namespaced key so relative imports within the plugin still work.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -84,6 +85,14 @@ DEFAULTS: dict[str, Any] = {
|
|||||||
"docker.retention.metrics_raw_days": 7,
|
"docker.retention.metrics_raw_days": 7,
|
||||||
"docker.retention.metrics_rollup_days": 90,
|
"docker.retention.metrics_rollup_days": 90,
|
||||||
"docker.retention.events_days": 30,
|
"docker.retention.events_days": 30,
|
||||||
|
# Container logs (m79): on by default for every container (operator
|
||||||
|
# preference). `exclude` names containers the server drops on ingest; the
|
||||||
|
# per-container ring bounds storage (rotate oldest past whichever of ~age or
|
||||||
|
# ~bytes hits first — a chatty container just keeps a shorter window).
|
||||||
|
"docker.logs.enabled": True,
|
||||||
|
"docker.logs.exclude": [],
|
||||||
|
"docker.logs.retention_days": 3,
|
||||||
|
"docker.logs.max_bytes_per_container": 5_000_000,
|
||||||
# Host/plugin metrics retention (plugin_metrics): keep a short raw window at
|
# Host/plugin metrics retention (plugin_metrics): keep a short raw window at
|
||||||
# the agent's ~30s cadence, then roll up to hourly averages kept much longer.
|
# the agent's ~30s cadence, then roll up to hourly averages kept much longer.
|
||||||
"metrics.retention.raw_days": 7,
|
"metrics.retention.raw_days": 7,
|
||||||
@@ -98,7 +107,6 @@ DEFAULTS: dict[str, Any] = {
|
|||||||
# Per-plugin yaml config defaults are merged on top at load time.
|
# Per-plugin yaml config defaults are merged on top at load time.
|
||||||
"plugin.docker": {"enabled": True},
|
"plugin.docker": {"enabled": True},
|
||||||
"plugin.host_agent": {"enabled": True},
|
"plugin.host_agent": {"enabled": True},
|
||||||
"plugin.http": {"enabled": True},
|
|
||||||
"plugin.snmp": {"enabled": True},
|
"plugin.snmp": {"enabled": True},
|
||||||
# OIDC single-sign-on
|
# OIDC single-sign-on
|
||||||
"oidc.enabled": False,
|
"oidc.enabled": False,
|
||||||
@@ -221,6 +229,82 @@ async def set_setting(session: AsyncSession, key: str, value: Any) -> None:
|
|||||||
_undecryptable_secrets.discard(key)
|
_undecryptable_secrets.discard(key)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_setting(session: AsyncSession, key: str) -> bool:
|
||||||
|
"""Remove a stored setting row. Returns True if a row was actually deleted.
|
||||||
|
|
||||||
|
Call in a transaction. Deleting a key that also has a DEFAULT reverts it to
|
||||||
|
that default rather than unsetting it — so this only meaningfully *removes*
|
||||||
|
a setting when no default declares it.
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(AppSetting).where(AppSetting.key == key)
|
||||||
|
)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
await session.delete(row)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_stored_plugin_names(session: AsyncSession) -> list[str]:
|
||||||
|
"""Plugin names that have a real stored row, ignoring DEFAULTS.
|
||||||
|
|
||||||
|
Deliberately NOT derived from get_all_settings: that merges DEFAULTS in, and
|
||||||
|
a plugin.* key present only as a default is a code-level declaration rather
|
||||||
|
than operator data. Offering to "remove" such a key would be a lie — the
|
||||||
|
default would simply reassert it on the next load (exactly the bug behind
|
||||||
|
the phantom `plugin.http` entry). Only stored rows can actually be cleaned up.
|
||||||
|
|
||||||
|
Returns names only, never values: plugin config can hold credentials, and
|
||||||
|
nothing that lists orphans needs to read them.
|
||||||
|
"""
|
||||||
|
prefix = "plugin."
|
||||||
|
result = await session.execute(
|
||||||
|
select(AppSetting.key).where(AppSetting.key.like(f"{prefix}%"))
|
||||||
|
)
|
||||||
|
return sorted(
|
||||||
|
key[len(prefix):] for key in result.scalars() if key != prefix
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_orphaned_plugins(
|
||||||
|
stored_names: Iterable[str],
|
||||||
|
installed_names: Iterable[str],
|
||||||
|
failures: Mapping[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Plugins that are configured or failed to load, but are not installed.
|
||||||
|
|
||||||
|
Pure so it can be tested without a DB or a filesystem. "Not installed" is
|
||||||
|
intentionally not treated as "safe to delete" — an external plugin under
|
||||||
|
/data/plugins can be missing merely because the volume isn't mounted or an
|
||||||
|
install failed, so the caller surfaces these for an explicit operator
|
||||||
|
decision instead of removing them automatically.
|
||||||
|
|
||||||
|
Driven by the UNION of stored settings and load failures, not by stored
|
||||||
|
settings alone. `load_plugins` records a failure for any enabled plugin
|
||||||
|
whose directory it cannot find, and the admin banner counts those — so a
|
||||||
|
failure with no stored row (a plugin enabled by a DEFAULTS entry) would
|
||||||
|
otherwise be counted in the banner and shown nowhere, which is exactly the
|
||||||
|
dead end this section exists to close.
|
||||||
|
|
||||||
|
Each row carries `removable`: only a plugin with a real stored row can
|
||||||
|
actually be cleaned up. Offering Remove for a default-declared plugin would
|
||||||
|
be a lie — deleting nothing, while the default reasserts it on next load.
|
||||||
|
"""
|
||||||
|
installed = set(installed_names)
|
||||||
|
failures = dict(failures or {})
|
||||||
|
stored = {name for name in stored_names if name}
|
||||||
|
candidates = (stored | set(failures)) - installed
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"reason": failures.get(name),
|
||||||
|
"removable": name in stored,
|
||||||
|
}
|
||||||
|
for name in sorted(candidates)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def get_all_settings(session: AsyncSession) -> dict[str, Any]:
|
async def get_all_settings(session: AsyncSession) -> dict[str, Any]:
|
||||||
"""Return flat key→value dict with defaults filled in for missing keys."""
|
"""Return flat key→value dict with defaults filled in for missing keys."""
|
||||||
result = await session.execute(select(AppSetting))
|
result = await session.execute(select(AppSetting))
|
||||||
|
|||||||
+195
-2
@@ -1,10 +1,49 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
create_async_engine, async_sessionmaker, AsyncEngine, AsyncSession,
|
||||||
|
)
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Recycle pooled connections after 30 minutes. Nothing in Postgres closes an
|
||||||
|
# idle connection by default, but the path to it is not always durable: NAT and
|
||||||
|
# conntrack tables drop idle flows, and a Docker network rebuild silently
|
||||||
|
# strands existing sockets. Capping connection age means a stranded connection
|
||||||
|
# is retired on a timer instead of surfacing as a failed query later.
|
||||||
|
POOL_RECYCLE_SECONDS = 1800
|
||||||
|
|
||||||
|
# Total budget for the database to become reachable at startup, and the backoff
|
||||||
|
# ceiling between attempts. 60s comfortably covers Postgres WAL recovery after
|
||||||
|
# an unclean shutdown (observed ~7s) plus Docker DNS coming up on a host reboot.
|
||||||
|
DB_CONNECT_TIMEOUT_SECONDS = 60.0
|
||||||
|
_BACKOFF_INITIAL_SECONDS = 0.5
|
||||||
|
_BACKOFF_MAX_SECONDS = 5.0
|
||||||
|
|
||||||
|
# Per-REQUEST retry schedule (seconds between attempts), total ~1.75s across 4
|
||||||
|
# tries. Far shorter than the startup budget on purpose: nobody is watching a
|
||||||
|
# container boot, but somebody is watching this page load. Long enough to ride
|
||||||
|
# out a pool blip or a fast reconnect, short enough that a genuinely-down
|
||||||
|
# database gets an honest answer instead of a spinner.
|
||||||
|
_REQUEST_RETRY_DELAYS = (0.25, 0.5, 1.0)
|
||||||
|
|
||||||
|
# Postgres SQLSTATEs that will never resolve by waiting — retrying these just
|
||||||
|
# delays a clear error behind a full timeout budget.
|
||||||
|
_FATAL_SQLSTATES = {
|
||||||
|
"28P01", # invalid_password
|
||||||
|
"28000", # invalid_authorization_specification
|
||||||
|
"3D000", # invalid_catalog_name — database does not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def init_db(app: "Quart") -> None:
|
def init_db(app: "Quart") -> None:
|
||||||
"""Create async engine and attach db_sessionmaker to app.
|
"""Create async engine and attach db_sessionmaker to app.
|
||||||
@@ -13,8 +52,162 @@ def init_db(app: "Quart") -> None:
|
|||||||
Does not create tables — Alembic handles migrations.
|
Does not create tables — Alembic handles migrations.
|
||||||
"""
|
"""
|
||||||
db_url: str = app.config["DATABASE_URL"]
|
db_url: str = app.config["DATABASE_URL"]
|
||||||
engine = create_async_engine(db_url, echo=False)
|
engine = create_async_engine(
|
||||||
|
db_url,
|
||||||
|
echo=False,
|
||||||
|
# Check a pooled connection is still alive before handing it out. When
|
||||||
|
# the database restarts, every connection already in the pool is dead
|
||||||
|
# at the socket level; without this, SQLAlchemy only discovers that by
|
||||||
|
# failing a real query, so the first operation after a DB restart
|
||||||
|
# errors out on whatever triggered it (a request, a scheduled poll).
|
||||||
|
# The check is a cheap round-trip and it makes a DB restart invisible.
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=POOL_RECYCLE_SECONDS,
|
||||||
|
)
|
||||||
app.db_sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
app.db_sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||||
engine, expire_on_commit=False
|
engine, expire_on_commit=False
|
||||||
)
|
)
|
||||||
app._db_engine = engine
|
app._db_engine = engine
|
||||||
|
|
||||||
|
|
||||||
|
def _fatal_sqlstate(exc: BaseException) -> str | None:
|
||||||
|
"""Return the SQLSTATE if this error chain carries a non-retryable one."""
|
||||||
|
seen: set[int] = set()
|
||||||
|
cur: BaseException | None = exc
|
||||||
|
while cur is not None and id(cur) not in seen:
|
||||||
|
seen.add(id(cur))
|
||||||
|
sqlstate = getattr(cur, "sqlstate", None)
|
||||||
|
if sqlstate in _FATAL_SQLSTATES:
|
||||||
|
return sqlstate
|
||||||
|
cur = cur.__cause__ or cur.__context__
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _describe(exc: BaseException) -> str:
|
||||||
|
"""Innermost cause, which is the part that says what actually went wrong.
|
||||||
|
|
||||||
|
SQLAlchemy wraps DBAPI errors several layers deep; the outer message is
|
||||||
|
boilerplate, so surface the root for the waiting-for-database log line.
|
||||||
|
"""
|
||||||
|
cur: BaseException = exc
|
||||||
|
seen: set[int] = {id(cur)}
|
||||||
|
while True:
|
||||||
|
nxt = cur.__cause__ or cur.__context__
|
||||||
|
if nxt is None or id(nxt) in seen:
|
||||||
|
return f"{type(cur).__name__}: {cur}"
|
||||||
|
seen.add(id(nxt))
|
||||||
|
cur = nxt
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_database(
|
||||||
|
db_url: str,
|
||||||
|
timeout_seconds: float = DB_CONNECT_TIMEOUT_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
"""Block until the database accepts a connection, or raise after the budget.
|
||||||
|
|
||||||
|
Steward otherwise assumes the database is both resolvable and *ready* the
|
||||||
|
first time it asks, which is false in two ordinary situations:
|
||||||
|
|
||||||
|
• Host reboot / full stack restart — the app container can try to resolve
|
||||||
|
the `db` service name before Docker's embedded DNS has the record,
|
||||||
|
giving `gaierror -2 Name or service not known`.
|
||||||
|
• Unclean shutdown — Postgres is listening but still replaying WAL, and
|
||||||
|
refuses connections with "the database system is not yet accepting
|
||||||
|
connections" until recovery reaches a consistent state.
|
||||||
|
|
||||||
|
compose's `depends_on: service_healthy` covers ordering on a clean `up`,
|
||||||
|
but not either of the above. Both are transient and self-healing, so retry
|
||||||
|
rather than dumping a traceback and dying. Credential and missing-database
|
||||||
|
errors are NOT transient and fail immediately.
|
||||||
|
"""
|
||||||
|
deadline = time.monotonic() + timeout_seconds
|
||||||
|
delay = _BACKOFF_INITIAL_SECONDS
|
||||||
|
attempt = 0
|
||||||
|
last_exc: BaseException | None = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
attempt += 1
|
||||||
|
try:
|
||||||
|
asyncio.run(_probe(db_url))
|
||||||
|
except Exception as exc: # broad by design — classified just below
|
||||||
|
sqlstate = _fatal_sqlstate(exc)
|
||||||
|
if sqlstate is not None:
|
||||||
|
# Wrong password / missing database: waiting cannot fix it.
|
||||||
|
raise
|
||||||
|
last_exc = exc
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
logger.warning(
|
||||||
|
"Database not ready (attempt %d, %.0fs budget left): %s — "
|
||||||
|
"retrying in %.1fs",
|
||||||
|
attempt, remaining, _describe(exc), min(delay, remaining),
|
||||||
|
)
|
||||||
|
time.sleep(min(delay, remaining))
|
||||||
|
delay = min(delay * 2, _BACKOFF_MAX_SECONDS)
|
||||||
|
else:
|
||||||
|
if attempt > 1:
|
||||||
|
logger.info("Database ready after %d attempt(s)", attempt)
|
||||||
|
return
|
||||||
|
|
||||||
|
detail = f" Last error: {_describe(last_exc)}" if last_exc is not None else ""
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Database did not become available within {timeout_seconds:.0f}s "
|
||||||
|
f"({attempt} attempts).{detail}"
|
||||||
|
) from last_exc
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseUnavailable(Exception):
|
||||||
|
"""The database could not be reached while serving a request."""
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_database_reachable(engine: AsyncEngine) -> None:
|
||||||
|
"""Check out one pooled connection, retrying briefly, or raise.
|
||||||
|
|
||||||
|
Startup readiness (wait_for_database) does not help once the app is already
|
||||||
|
serving: if the database goes away underneath a running Steward, the next
|
||||||
|
request needs a connection, the pooled ones are dead, and establishing a new
|
||||||
|
one fails — which is how a login attempt turned into an opaque 500.
|
||||||
|
|
||||||
|
pool_pre_ping already makes recovery automatic *once the database is back*.
|
||||||
|
What it cannot do is wait: while the container is genuinely down, its
|
||||||
|
replacement connect fails too. So retry briefly here to ride out a restart,
|
||||||
|
then give up and let the caller render an honest "database unavailable"
|
||||||
|
page rather than a generic error.
|
||||||
|
|
||||||
|
The budget is deliberately short — a person is waiting on this request, and
|
||||||
|
a page that hangs for half a minute is worse than one that says plainly
|
||||||
|
what is wrong and offers a retry.
|
||||||
|
"""
|
||||||
|
last_exc: BaseException | None = None
|
||||||
|
for attempt, delay in enumerate(_REQUEST_RETRY_DELAYS + (None,)):
|
||||||
|
try:
|
||||||
|
async with engine.connect():
|
||||||
|
if attempt:
|
||||||
|
logger.info(
|
||||||
|
"Database reachable again after %d retry attempt(s)", attempt)
|
||||||
|
return
|
||||||
|
except (SQLAlchemyError, OSError) as exc:
|
||||||
|
# Bad credentials / missing database will never resolve by waiting,
|
||||||
|
# and they are not what this guard is for — let them surface.
|
||||||
|
if _fatal_sqlstate(exc) is not None:
|
||||||
|
raise
|
||||||
|
last_exc = exc
|
||||||
|
if delay is not None:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
raise DatabaseUnavailable(_describe(last_exc)) from last_exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _probe(db_url: str) -> None:
|
||||||
|
"""Open one throwaway connection and round-trip a trivial query.
|
||||||
|
|
||||||
|
Uses its own engine with NullPool: this runs before the app engine exists,
|
||||||
|
and a probe connection must never be left in a pool for real work to reuse.
|
||||||
|
"""
|
||||||
|
engine = create_async_engine(db_url, echo=False, poolclass=NullPool)
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text("SELECT 1"))
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Drop the stored setting for the removed http plugin
|
||||||
|
|
||||||
|
The standalone `http` plugin was dissolved into the unified Monitor entity
|
||||||
|
(0022_unify_monitors), but its `plugin.http` settings key outlived it: the key
|
||||||
|
stayed in core DEFAULTS, so the operator saw a plugin that no longer exists
|
||||||
|
reported as "enabled" with no way to clear it — deleting the row alone did
|
||||||
|
nothing, because the default reasserted it on the next settings load.
|
||||||
|
|
||||||
|
The DEFAULTS entry is removed in the same change; this migration clears any row
|
||||||
|
an operator's database still carries so the two agree. Per family rule 22, the
|
||||||
|
removed subsystem takes its setting row with it.
|
||||||
|
|
||||||
|
Note: `http` remains a valid MONITOR TYPE (icmp/tcp/dns/http). This touches only
|
||||||
|
the plugin-enablement key, never monitor data.
|
||||||
|
|
||||||
|
Revision ID: 0025_drop_http_plugin_setting
|
||||||
|
Revises: 0024_plugin_metrics_hourly
|
||||||
|
Create Date: 2026-08-13
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "0025_drop_http_plugin_setting"
|
||||||
|
down_revision: Union[str, None] = "0024_plugin_metrics_hourly"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
sa.text("DELETE FROM app_settings WHERE key = 'plugin.http'")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Deliberately empty. Re-inserting `plugin.http` would recreate the exact
|
||||||
|
# phantom this migration exists to remove, and the plugin it configured no
|
||||||
|
# longer exists to read it.
|
||||||
|
pass
|
||||||
@@ -8,7 +8,8 @@ from steward.auth.middleware import require_role
|
|||||||
from steward.core.audit import log_audit
|
from steward.core.audit import log_audit
|
||||||
from steward.models.users import UserRole
|
from steward.models.users import UserRole
|
||||||
from steward.core.settings import (
|
from steward.core.settings import (
|
||||||
get_all_settings, set_setting,
|
get_all_settings, set_setting, delete_setting,
|
||||||
|
get_stored_plugin_names, find_orphaned_plugins,
|
||||||
to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg,
|
to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg,
|
||||||
to_oidc_cfg, to_ldap_cfg, to_thresholds_cfg,
|
to_oidc_cfg, to_ldap_cfg, to_thresholds_cfg,
|
||||||
)
|
)
|
||||||
@@ -133,6 +134,7 @@ _RETENTION_FIELDS = [
|
|||||||
("docker_metrics_raw_days", "docker.retention.metrics_raw_days"),
|
("docker_metrics_raw_days", "docker.retention.metrics_raw_days"),
|
||||||
("docker_metrics_rollup_days", "docker.retention.metrics_rollup_days"),
|
("docker_metrics_rollup_days", "docker.retention.metrics_rollup_days"),
|
||||||
("docker_events_days", "docker.retention.events_days"),
|
("docker_events_days", "docker.retention.events_days"),
|
||||||
|
("docker_logs_retention_days", "docker.logs.retention_days"),
|
||||||
("metrics_raw_days", "metrics.retention.raw_days"),
|
("metrics_raw_days", "metrics.retention.raw_days"),
|
||||||
("metrics_rollup_days", "metrics.retention.rollup_days"),
|
("metrics_rollup_days", "metrics.retention.rollup_days"),
|
||||||
]
|
]
|
||||||
@@ -170,6 +172,20 @@ async def save_thresholds():
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
await set_setting(db, key, val)
|
await set_setting(db, key, val)
|
||||||
|
# Container-log controls (m79). Checkbox: present ⇒ on (this is a
|
||||||
|
# full-page form, so absence is a genuine "off"). Exclude: comma-split
|
||||||
|
# names. Size: entered in MB, stored as bytes.
|
||||||
|
await set_setting(db, "docker.logs.enabled", "docker_logs_enabled" in form)
|
||||||
|
exclude = [n.strip() for n in form.get("docker_logs_exclude", "").split(",")
|
||||||
|
if n.strip()]
|
||||||
|
await set_setting(db, "docker.logs.exclude", exclude)
|
||||||
|
max_mb = form.get("docker_logs_max_mb", "")
|
||||||
|
if max_mb != "":
|
||||||
|
try:
|
||||||
|
await set_setting(db, "docker.logs.max_bytes_per_container",
|
||||||
|
max(1, int(max_mb)) * 1_000_000)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
await _reload_app_config()
|
await _reload_app_config()
|
||||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||||
"settings.saved", detail={"section": "thresholds"})
|
"settings.saved", detail={"section": "thresholds"})
|
||||||
@@ -572,7 +588,7 @@ def _build_plugin_cfg_from_form(plugin: dict, form) -> dict:
|
|||||||
# of a host), not discrete vendor integrations. Presentation-only split — they
|
# of a host), not discrete vendor integrations. Presentation-only split — they
|
||||||
# still load through the normal plugin mechanism. A plugin.yaml may set
|
# still load through the normal plugin mechanism. A plugin.yaml may set
|
||||||
# kind: capability|integration to override.
|
# kind: capability|integration to override.
|
||||||
CAPABILITY_PLUGINS = {"host_agent", "http", "snmp", "docker"}
|
CAPABILITY_PLUGINS = {"host_agent", "snmp", "docker"}
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.get("/plugins/")
|
@settings_bp.get("/plugins/")
|
||||||
@@ -580,22 +596,65 @@ CAPABILITY_PLUGINS = {"host_agent", "http", "snmp", "docker"}
|
|||||||
async def plugins():
|
async def plugins():
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
settings = await get_all_settings(db)
|
settings = await get_all_settings(db)
|
||||||
|
stored_plugin_names = await get_stored_plugin_names(db)
|
||||||
discovered = _discover_plugins()
|
discovered = _discover_plugins()
|
||||||
_merge_plugin_config(discovered, to_plugins_cfg(settings))
|
_merge_plugin_config(discovered, to_plugins_cfg(settings))
|
||||||
for p in discovered:
|
for p in discovered:
|
||||||
p["_kind"] = p.get("kind") or (
|
p["_kind"] = p.get("kind") or (
|
||||||
"capability" if p["_dir"] in CAPABILITY_PLUGINS else "integration")
|
"capability" if p["_dir"] in CAPABILITY_PLUGINS else "integration")
|
||||||
repos = _get_plugin_repos(settings)
|
repos = _get_plugin_repos(settings)
|
||||||
|
# Config left behind by plugins that are no longer installed. Surfaced for an
|
||||||
|
# explicit operator decision rather than cleaned up automatically: an external
|
||||||
|
# plugin can be missing because /data/plugins isn't mounted or an install
|
||||||
|
# failed, and silently dropping its row would destroy stored credentials.
|
||||||
|
#
|
||||||
|
# Load failures are folded in so every plugin counted by the admin banner has
|
||||||
|
# a row here. A plugin that failed BECAUSE it has no directory is not in
|
||||||
|
# `discovered`, so without this it would be counted in the banner and shown
|
||||||
|
# nowhere on the page that banner links to.
|
||||||
|
from steward.core.plugin_manager import get_plugin_failures
|
||||||
|
orphans = find_orphaned_plugins(
|
||||||
|
stored_plugin_names,
|
||||||
|
[p["_dir"] for p in discovered],
|
||||||
|
get_plugin_failures(),
|
||||||
|
)
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"settings/plugins.html",
|
"settings/plugins.html",
|
||||||
capabilities=[p for p in discovered if p["_kind"] == "capability"],
|
capabilities=[p for p in discovered if p["_kind"] == "capability"],
|
||||||
integrations=[p for p in discovered if p["_kind"] != "capability"],
|
integrations=[p for p in discovered if p["_kind"] != "capability"],
|
||||||
discovered_plugins=discovered,
|
discovered_plugins=discovered,
|
||||||
|
orphaned_plugins=orphans,
|
||||||
repos=repos,
|
repos=repos,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.post("/plugins/orphans/<name>/remove/")
|
||||||
|
@require_role(UserRole.admin)
|
||||||
|
async def plugin_orphan_remove(name: str):
|
||||||
|
"""Delete the stored settings row for a plugin that is no longer installed.
|
||||||
|
|
||||||
|
Refuses if the plugin IS installed — that would silently wipe a live
|
||||||
|
plugin's config, and disabling it is what the operator wants there instead.
|
||||||
|
"""
|
||||||
|
installed = {p["_dir"] for p in _discover_plugins()}
|
||||||
|
if name in installed:
|
||||||
|
return redirect(url_for("settings.plugins"))
|
||||||
|
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
async with db.begin():
|
||||||
|
removed = await delete_setting(db, f"plugin.{name}")
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
await _reload_app_config()
|
||||||
|
from steward.core.plugin_index import clear_catalog_cache
|
||||||
|
clear_catalog_cache()
|
||||||
|
await log_audit(
|
||||||
|
current_app, session.get("user_id"), session.get("username", ""),
|
||||||
|
"plugin.settings_removed", entity_type="plugin", entity_id=name)
|
||||||
|
return redirect(url_for("settings.plugins"))
|
||||||
|
|
||||||
|
|
||||||
# ── Per-plugin detail (settings) ──────────────────────────────────────────────
|
# ── Per-plugin detail (settings) ──────────────────────────────────────────────
|
||||||
|
|
||||||
@settings_bp.get("/plugins/<name>/")
|
@settings_bp.get("/plugins/<name>/")
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Database unavailable — Steward{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div style="text-align:center; padding: 4rem 2rem; font-family: var(--font-serif);">
|
||||||
|
<h1 style="color: var(--yellow); font-size: 2.5rem; margin-bottom: 0.5rem;">503</h1>
|
||||||
|
<p style="color: var(--text); font-size: 1.2rem; margin-bottom: 0.25rem;">The records are out of reach.</p>
|
||||||
|
<p style="color: var(--text-dim); margin-bottom: 0.5rem; max-width: 34rem; margin-left:auto; margin-right:auto;">
|
||||||
|
Steward is running, but cannot reach its database. Nothing has been lost —
|
||||||
|
this page will work again as soon as the database is back.
|
||||||
|
</p>
|
||||||
|
<p style="color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1.5rem;">
|
||||||
|
Steward already retried for a moment before showing this.
|
||||||
|
</p>
|
||||||
|
<a href="{{ request.path }}" class="btn">Try again</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<p style="color:var(--text-muted);font-size:0.82rem;margin:0 0 0.75rem;max-width:720px;">
|
<p style="color:var(--text-muted);font-size:0.82rem;margin:0 0 0.75rem;max-width:720px;">
|
||||||
Built-in ways to monitor your hosts (agent metrics, HTTP/uptime, SNMP, Docker). These are
|
Built-in ways to monitor your hosts (agent metrics, SNMP, Docker). These are
|
||||||
facets of a host — you'll see their data in the <a href="/hosts/">Hosts</a> and
|
facets of a host — you'll see their data in the <a href="/hosts/">Hosts</a> and
|
||||||
<a href="/status">Status</a> sections, not as separate areas. On by default.
|
<a href="/status">Status</a> sections, not as separate areas. On by default.
|
||||||
</p>
|
</p>
|
||||||
@@ -88,6 +88,63 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Orphaned settings ─────────────────────────────────────────────────────── #}
|
||||||
|
{# Hidden entirely when there is nothing to clean up — an empty "Configured but
|
||||||
|
not installed" panel would read as a problem rather than a clean state. #}
|
||||||
|
{% if orphaned_plugins %}
|
||||||
|
<div style="margin-bottom:1.5rem;max-width:720px;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.4rem;">Configured but not installed</div>
|
||||||
|
<p style="color:var(--text-muted);font-size:0.82rem;margin:0 0 0.75rem;">
|
||||||
|
Plugins Steward has settings for, or tried to load, but cannot find on disk — including
|
||||||
|
anything counted by the "failed to load" banner. This usually means the plugin was
|
||||||
|
removed, but it also happens when an external plugin directory isn't mounted or an
|
||||||
|
install failed part-way, so nothing is deleted automatically. Removing an entry
|
||||||
|
discards that plugin's saved configuration, including any credentials.
|
||||||
|
</p>
|
||||||
|
<div style="display:grid;gap:0.6rem;">
|
||||||
|
{% for orphan in orphaned_plugins %}
|
||||||
|
<div class="card" style="padding:0.85rem 1rem;display:flex;align-items:center;gap:0.75rem;">
|
||||||
|
<span style="width:8px;height:8px;border-radius:50%;flex-shrink:0;
|
||||||
|
background:{{ 'var(--red)' if orphan.reason else 'var(--yellow)' }};"
|
||||||
|
title="{{ orphan.reason or 'Configured but not installed' }}"></span>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;">
|
||||||
|
<span style="font-weight:600;font-size:0.9rem;color:var(--text);">{{ orphan.name }}</span>
|
||||||
|
{% if orphan.reason %}
|
||||||
|
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
|
||||||
|
background:color-mix(in srgb,var(--red) 15%,var(--bg));color:var(--red);">Failed to load</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
|
||||||
|
background:var(--yellow-dim);color:var(--yellow);">Not installed</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if orphan.reason %}
|
||||||
|
<div style="font-size:0.77rem;color:var(--red);margin-top:0.1rem;
|
||||||
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"
|
||||||
|
title="{{ orphan.reason }}">{{ orphan.reason }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.1rem;">
|
||||||
|
{% if orphan.removable %}
|
||||||
|
Settings key <code>plugin.{{ orphan.name }}</code> has no matching plugin.
|
||||||
|
{% else %}
|
||||||
|
Enabled by a built-in default with no stored settings to remove — this is a
|
||||||
|
packaging bug, not leftover configuration. Please report it.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if orphan.removable %}
|
||||||
|
<form method="post" action="/settings/plugins/orphans/{{ orphan.name }}/remove/"
|
||||||
|
onsubmit="return confirm('Remove stored settings for "{{ orphan.name }}"? Its saved configuration, including any credentials, will be discarded. This cannot be undone.');"
|
||||||
|
style="margin:0;flex-shrink:0;">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm" style="font-size:0.78rem;">Remove</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# ── Plugin Repositories ───────────────────────────────────────────────────── #}
|
{# ── Plugin Repositories ───────────────────────────────────────────────────── #}
|
||||||
<div style="margin-bottom:2rem;max-width:720px;">
|
<div style="margin-bottom:2rem;max-width:720px;">
|
||||||
<div class="section-title" style="margin-bottom:0.75rem;">Plugin Repositories</div>
|
<div class="section-title" style="margin-bottom:0.75rem;">Plugin Repositories</div>
|
||||||
|
|||||||
@@ -70,6 +70,54 @@
|
|||||||
"Keep container start/stop/die/health history this long.") }}
|
"Keep container start/stop/die/health history this long.") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="max-width:640px;margin-top:1rem;">
|
||||||
|
<h2 class="section-title" style="margin-bottom:0.5rem;">Container logs</h2>
|
||||||
|
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
|
||||||
|
The host agent tails each container's logs and pushes them here, viewable per
|
||||||
|
container. On by default for every container; storage is bounded by a
|
||||||
|
per-container ring (oldest lines rotate out once the age or size cap is hit,
|
||||||
|
whichever comes first). Applied by the hourly cleanup and on ingest.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:1.1rem;">
|
||||||
|
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
|
||||||
|
<input type="checkbox" name="docker_logs_enabled" style="width:auto;"
|
||||||
|
{% if settings["docker.logs.enabled"] %}checked{% endif %}>
|
||||||
|
Collect container logs
|
||||||
|
</label>
|
||||||
|
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">
|
||||||
|
Global kill-switch. When off, pushed log lines are dropped and no new logs are stored.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:1.1rem;">
|
||||||
|
<label>Exclude containers</label>
|
||||||
|
<div style="margin-top:0.25rem;">
|
||||||
|
<input type="text" name="docker_logs_exclude"
|
||||||
|
value="{{ settings['docker.logs.exclude'] | join(', ') }}"
|
||||||
|
placeholder="watchtower, some-chatty-service" style="width:100%;max-width:420px;">
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">
|
||||||
|
Comma-separated container names whose logs are dropped on ingest (e.g. known-noisy ones).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ days("Log retention", "docker_logs_retention_days", "docker.logs.retention_days",
|
||||||
|
"Keep each container's log lines at most this long before rotating them out.") }}
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:0.25rem;">
|
||||||
|
<label>Max size per container <span style="color:var(--text-muted);font-size:0.8rem;">(MB)</span></label>
|
||||||
|
<div style="margin-top:0.25rem;">
|
||||||
|
<input type="number" name="docker_logs_max_mb" min="1" step="1"
|
||||||
|
value="{{ (settings['docker.logs.max_bytes_per_container'] // 1000000) or 1 }}"
|
||||||
|
style="max-width:110px;">
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">
|
||||||
|
Newest lines are kept up to this size per container; older lines rotate out first.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="max-width:640px;margin-top:1rem;">
|
<div class="card" style="max-width:640px;margin-top:1rem;">
|
||||||
<h2 class="section-title" style="margin-bottom:0.5rem;">Host metrics retention</h2>
|
<h2 class="section-title" style="margin-bottom:0.5rem;">Host metrics retention</h2>
|
||||||
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
|
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
"""The bundled first-party playbook source is always present and discoverable."""
|
"""The bundled first-party playbook source is always present and discoverable."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from steward.ansible.sources import (
|
from steward.ansible.sources import (
|
||||||
BUILTIN_SOURCE_NAME,
|
BUILTIN_SOURCE_NAME,
|
||||||
|
discover_playbook_meta,
|
||||||
discover_playbooks,
|
discover_playbooks,
|
||||||
get_sources,
|
get_sources,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bundled_content(rel_path: str) -> str:
|
||||||
|
builtin = get_sources({"sources": []})[0]
|
||||||
|
return (Path(builtin["path"]) / rel_path).read_text()
|
||||||
|
|
||||||
|
|
||||||
def test_builtin_source_is_first_and_local():
|
def test_builtin_source_is_first_and_local():
|
||||||
sources = get_sources({"sources": []})
|
sources = get_sources({"sources": []})
|
||||||
assert sources[0]["name"] == BUILTIN_SOURCE_NAME
|
assert sources[0]["name"] == BUILTIN_SOURCE_NAME
|
||||||
@@ -17,3 +27,33 @@ def test_bundled_playbooks_are_discoverable():
|
|||||||
playbooks = discover_playbooks(builtin["path"])
|
playbooks = discover_playbooks(builtin["path"])
|
||||||
assert "maintenance/docker_prune.yml" in playbooks
|
assert "maintenance/docker_prune.yml" in playbooks
|
||||||
assert "host_agent/install.yml" in playbooks
|
assert "host_agent/install.yml" in playbooks
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_prune_playbook_parses_and_keeps_meta():
|
||||||
|
"""The prune playbook stays valid YAML and keeps its self-describing meta
|
||||||
|
(a destructive maintenance run that must prompt for confirmation)."""
|
||||||
|
content = _bundled_content("maintenance/docker_prune.yml")
|
||||||
|
plays = yaml.safe_load(content)
|
||||||
|
assert isinstance(plays, list) and plays # at least one play
|
||||||
|
|
||||||
|
meta = discover_playbook_meta(content)
|
||||||
|
assert meta["confirm"] is True
|
||||||
|
assert meta["category"] == "maintenance"
|
||||||
|
assert meta["description"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_prune_supports_all_three_targets():
|
||||||
|
"""M78 drives the three prune buttons via a single `prune_target` var;
|
||||||
|
`system` is the default so pre-M78 callers (no var set) are unchanged."""
|
||||||
|
content = _bundled_content("maintenance/docker_prune.yml")
|
||||||
|
play = yaml.safe_load(content)[0]
|
||||||
|
assert play["vars"]["prune_target"] == "system"
|
||||||
|
|
||||||
|
# Every target the routes/UI can send has a matching guarded task.
|
||||||
|
guards = {
|
||||||
|
task.get("when")
|
||||||
|
for task in play["tasks"]
|
||||||
|
if isinstance(task.get("when"), str) and "prune_target" in task["when"]
|
||||||
|
}
|
||||||
|
for target in ("containers", "images", "system"):
|
||||||
|
assert f"prune_target == '{target}'" in guards
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""DB connection resilience: pool, startup readiness, and request-time outage.
|
||||||
|
|
||||||
|
Covers the three recovery gaps that let a database restart take the app down:
|
||||||
|
the engine handing out stale pooled connections (#2626), startup assuming the
|
||||||
|
DB is reachable and ready the instant it asks (#2627), and a request finding
|
||||||
|
the database gone and failing with an opaque 500 (#2635).
|
||||||
|
"""
|
||||||
|
import types
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from steward import database
|
||||||
|
from steward.database import (
|
||||||
|
POOL_RECYCLE_SECONDS,
|
||||||
|
_describe,
|
||||||
|
_fatal_sqlstate,
|
||||||
|
init_db,
|
||||||
|
wait_for_database,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _PGError(Exception):
|
||||||
|
"""Stand-in for an asyncpg error, which carries a SQLSTATE attribute."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, sqlstate: str | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.sqlstate = sqlstate
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def no_sleep(monkeypatch):
|
||||||
|
"""Drive the retry budget off a fake clock instead of wall time.
|
||||||
|
|
||||||
|
sleep() records its duration and advances the clock by exactly that much,
|
||||||
|
so backoff and timeout behaviour are deterministic rather than dependent on
|
||||||
|
how fast the test machine happens to run.
|
||||||
|
"""
|
||||||
|
slept: list[float] = []
|
||||||
|
clock = {"now": 0.0}
|
||||||
|
|
||||||
|
def fake_sleep(seconds: float) -> None:
|
||||||
|
slept.append(seconds)
|
||||||
|
clock["now"] += seconds
|
||||||
|
|
||||||
|
monkeypatch.setattr(database.time, "sleep", fake_sleep)
|
||||||
|
monkeypatch.setattr(database.time, "monotonic", lambda: clock["now"])
|
||||||
|
return slept
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_raising(*errors, then_succeed: bool = True):
|
||||||
|
"""Async probe stub raising the given errors in order.
|
||||||
|
|
||||||
|
Once the list is exhausted it succeeds, unless then_succeed is False — in
|
||||||
|
which case it keeps raising the last error forever (a DB that never comes
|
||||||
|
back).
|
||||||
|
"""
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
async def probe(db_url):
|
||||||
|
i = calls["n"]
|
||||||
|
calls["n"] += 1
|
||||||
|
if i < len(errors):
|
||||||
|
raise errors[i]
|
||||||
|
if not then_succeed:
|
||||||
|
raise errors[-1]
|
||||||
|
|
||||||
|
probe.calls = calls
|
||||||
|
return probe
|
||||||
|
|
||||||
|
|
||||||
|
# ── engine pool configuration (issue #2626) ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_enables_pre_ping_and_recycle():
|
||||||
|
app = types.SimpleNamespace(
|
||||||
|
config={"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db"}
|
||||||
|
)
|
||||||
|
init_db(app)
|
||||||
|
|
||||||
|
# _pre_ping / _recycle are SQLAlchemy pool internals; there is no public
|
||||||
|
# accessor, and these are exactly the settings a DB restart depends on.
|
||||||
|
pool = app._db_engine.sync_engine.pool
|
||||||
|
assert pool._pre_ping is True
|
||||||
|
assert pool._recycle == POOL_RECYCLE_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_pool_recycle_is_positive():
|
||||||
|
# A non-positive recycle disables age-based retirement entirely.
|
||||||
|
assert POOL_RECYCLE_SECONDS > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── retry classification ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_fatal_sqlstate_detects_bad_password():
|
||||||
|
exc = _PGError("password authentication failed", sqlstate="28P01")
|
||||||
|
assert _fatal_sqlstate(exc) == "28P01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fatal_sqlstate_finds_sqlstate_through_cause_chain():
|
||||||
|
inner = _PGError("database does not exist", sqlstate="3D000")
|
||||||
|
outer = RuntimeError("wrapped by sqlalchemy")
|
||||||
|
outer.__cause__ = inner
|
||||||
|
assert _fatal_sqlstate(outer) == "3D000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transient_errors_are_not_fatal():
|
||||||
|
assert _fatal_sqlstate(OSError(-2, "Name or service not known")) is None
|
||||||
|
# "the database system is not yet accepting connections" — resolves on its own.
|
||||||
|
assert _fatal_sqlstate(_PGError("not yet accepting", sqlstate="57P03")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_unwraps_to_innermost_cause():
|
||||||
|
inner = OSError("Name or service not known")
|
||||||
|
outer = RuntimeError("sqlalchemy boilerplate")
|
||||||
|
outer.__cause__ = inner
|
||||||
|
assert "Name or service not known" in _describe(outer)
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_survives_self_referential_cause():
|
||||||
|
exc = RuntimeError("loop")
|
||||||
|
exc.__cause__ = exc
|
||||||
|
assert "loop" in _describe(exc)
|
||||||
|
|
||||||
|
|
||||||
|
# ── wait_for_database (issue #2627) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_immediately_when_db_is_up(monkeypatch, no_sleep):
|
||||||
|
probe = _probe_raising()
|
||||||
|
monkeypatch.setattr(database, "_probe", probe)
|
||||||
|
|
||||||
|
wait_for_database("postgresql+asyncpg://u:p@db/steward", timeout_seconds=10)
|
||||||
|
|
||||||
|
assert probe.calls["n"] == 1
|
||||||
|
assert no_sleep == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_retries_dns_failure_then_succeeds(monkeypatch, no_sleep):
|
||||||
|
# The reported failure: gaierror -2 while Docker DNS is not yet serving `db`.
|
||||||
|
probe = _probe_raising(
|
||||||
|
OSError(-2, "Name or service not known"),
|
||||||
|
OSError(-2, "Name or service not known"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(database, "_probe", probe)
|
||||||
|
|
||||||
|
wait_for_database("postgresql+asyncpg://u:p@db/steward", timeout_seconds=30)
|
||||||
|
|
||||||
|
assert probe.calls["n"] == 3
|
||||||
|
assert len(no_sleep) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_retries_while_postgres_is_still_recovering(monkeypatch, no_sleep):
|
||||||
|
probe = _probe_raising(
|
||||||
|
_PGError("the database system is not yet accepting connections", "57P03"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(database, "_probe", probe)
|
||||||
|
|
||||||
|
wait_for_database("postgresql+asyncpg://u:p@db/steward", timeout_seconds=30)
|
||||||
|
|
||||||
|
assert probe.calls["n"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_backoff_grows_between_attempts(monkeypatch, no_sleep):
|
||||||
|
probe = _probe_raising(*[OSError("refused")] * 4)
|
||||||
|
monkeypatch.setattr(database, "_probe", probe)
|
||||||
|
|
||||||
|
wait_for_database("postgresql+asyncpg://u:p@db/steward", timeout_seconds=60)
|
||||||
|
|
||||||
|
assert no_sleep == sorted(no_sleep), "delays should be non-decreasing"
|
||||||
|
assert no_sleep[-1] > no_sleep[0], "backoff should grow, not stay flat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backoff_is_capped(monkeypatch, no_sleep):
|
||||||
|
probe = _probe_raising(*[OSError("refused")] * 12)
|
||||||
|
monkeypatch.setattr(database, "_probe", probe)
|
||||||
|
|
||||||
|
wait_for_database("postgresql+asyncpg://u:p@db/steward", timeout_seconds=600)
|
||||||
|
|
||||||
|
assert max(no_sleep) <= database._BACKOFF_MAX_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_credentials_fail_immediately_without_retrying(monkeypatch, no_sleep):
|
||||||
|
probe = _probe_raising(_PGError("password authentication failed", "28P01"))
|
||||||
|
monkeypatch.setattr(database, "_probe", probe)
|
||||||
|
|
||||||
|
with pytest.raises(_PGError):
|
||||||
|
wait_for_database("postgresql+asyncpg://u:bad@db/steward", timeout_seconds=30)
|
||||||
|
|
||||||
|
# Waiting cannot fix a wrong password — don't burn the whole budget on it.
|
||||||
|
assert probe.calls["n"] == 1
|
||||||
|
assert no_sleep == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_database_fails_immediately(monkeypatch, no_sleep):
|
||||||
|
probe = _probe_raising(_PGError("database does not exist", "3D000"))
|
||||||
|
monkeypatch.setattr(database, "_probe", probe)
|
||||||
|
|
||||||
|
with pytest.raises(_PGError):
|
||||||
|
wait_for_database("postgresql+asyncpg://u:p@db/nope", timeout_seconds=30)
|
||||||
|
|
||||||
|
assert probe.calls["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_gives_up_after_budget_with_actionable_message(monkeypatch, no_sleep):
|
||||||
|
probe = _probe_raising(
|
||||||
|
OSError("Name or service not known"), then_succeed=False
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(database, "_probe", probe)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as excinfo:
|
||||||
|
wait_for_database("postgresql+asyncpg://u:p@db/steward", timeout_seconds=2)
|
||||||
|
|
||||||
|
message = str(excinfo.value)
|
||||||
|
assert "did not become available" in message
|
||||||
|
# The operator needs the underlying cause, not just "timed out".
|
||||||
|
assert "Name or service not known" in message
|
||||||
|
|
||||||
|
|
||||||
|
# ── ensure_database_reachable (issue #2635) ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeEngine:
|
||||||
|
"""Minimal stand-in for AsyncEngine.connect() as an async context manager."""
|
||||||
|
|
||||||
|
def __init__(self, *errors, then_succeed: bool = True):
|
||||||
|
self.errors = list(errors)
|
||||||
|
self.then_succeed = then_succeed
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
engine = self
|
||||||
|
|
||||||
|
class _Ctx:
|
||||||
|
async def __aenter__(self):
|
||||||
|
index = engine.calls
|
||||||
|
engine.calls += 1
|
||||||
|
if index < len(engine.errors):
|
||||||
|
raise engine.errors[index]
|
||||||
|
if not engine.then_succeed:
|
||||||
|
raise engine.errors[-1]
|
||||||
|
return object()
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc_info):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return _Ctx()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def no_async_sleep(monkeypatch):
|
||||||
|
"""Record awaited retry delays without spending them."""
|
||||||
|
slept: list[float] = []
|
||||||
|
|
||||||
|
async def fake_sleep(seconds: float) -> None:
|
||||||
|
slept.append(seconds)
|
||||||
|
|
||||||
|
monkeypatch.setattr(database.asyncio, "sleep", fake_sleep)
|
||||||
|
return slept
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reachable_on_first_try_costs_no_retries(no_async_sleep):
|
||||||
|
engine = _FakeEngine()
|
||||||
|
await database.ensure_database_reachable(engine)
|
||||||
|
assert engine.calls == 1
|
||||||
|
assert no_async_sleep == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rides_out_a_brief_outage(no_async_sleep):
|
||||||
|
# The reported shape: DNS gone while the db container restarts.
|
||||||
|
engine = _FakeEngine(OSError(-2, "Name or service not known"))
|
||||||
|
await database.ensure_database_reachable(engine)
|
||||||
|
assert engine.calls == 2
|
||||||
|
assert no_async_sleep == [0.25]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_uses_the_full_retry_schedule_before_giving_up(no_async_sleep):
|
||||||
|
engine = _FakeEngine(OSError("refused"), then_succeed=False)
|
||||||
|
with pytest.raises(database.DatabaseUnavailable):
|
||||||
|
await database.ensure_database_reachable(engine)
|
||||||
|
|
||||||
|
# One attempt per delay, plus a final attempt after the last wait.
|
||||||
|
assert engine.calls == len(database._REQUEST_RETRY_DELAYS) + 1
|
||||||
|
assert no_async_sleep == list(database._REQUEST_RETRY_DELAYS)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_budget_stays_short(no_async_sleep):
|
||||||
|
# A person is waiting on this; guard against the schedule growing into a hang.
|
||||||
|
assert sum(database._REQUEST_RETRY_DELAYS) <= 3.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unavailable_error_carries_the_underlying_cause(no_async_sleep):
|
||||||
|
engine = _FakeEngine(
|
||||||
|
OSError("Name or service not known"), then_succeed=False)
|
||||||
|
with pytest.raises(database.DatabaseUnavailable) as excinfo:
|
||||||
|
await database.ensure_database_reachable(engine)
|
||||||
|
assert "Name or service not known" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bad_credentials_are_not_masked_as_unavailable(no_async_sleep):
|
||||||
|
# Retrying a wrong password would be pointless, and reporting it as
|
||||||
|
# "database unavailable" would send the operator chasing the wrong problem.
|
||||||
|
engine = _FakeEngine(
|
||||||
|
_PGError("password authentication failed", "28P01"), then_succeed=False)
|
||||||
|
with pytest.raises(_PGError):
|
||||||
|
await database.ensure_database_reachable(engine)
|
||||||
|
assert engine.calls == 1
|
||||||
|
assert no_async_sleep == []
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Guards against plugin settings outliving the plugin they configure.
|
||||||
|
|
||||||
|
The `http` plugin was folded into the unified Monitor entity, but its
|
||||||
|
`plugin.http` DEFAULTS entry survived — so the operator saw a plugin that no
|
||||||
|
longer exists reported as enabled, with no way to clear it (deleting the row
|
||||||
|
did nothing; the default reasserted it). These tests make that class of drift a
|
||||||
|
CI failure instead of a support question.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from steward.core.settings import DEFAULTS, find_orphaned_plugins
|
||||||
|
from steward.settings.routes import CAPABILITY_PLUGINS
|
||||||
|
|
||||||
|
BUNDLED_PLUGINS_DIR = Path(__file__).resolve().parents[2] / "plugins"
|
||||||
|
|
||||||
|
|
||||||
|
def _bundled_plugin_names() -> set[str]:
|
||||||
|
"""Plugin dirs shipped inside the image, identified by their plugin.yaml."""
|
||||||
|
return {
|
||||||
|
entry.name
|
||||||
|
for entry in BUNDLED_PLUGINS_DIR.iterdir()
|
||||||
|
if entry.is_dir() and (entry / "plugin.yaml").exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _default_plugin_names() -> set[str]:
|
||||||
|
prefix = "plugin."
|
||||||
|
return {k[len(prefix):] for k in DEFAULTS if k.startswith(prefix)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundled_plugins_dir_is_discoverable():
|
||||||
|
# Guards the test itself: a wrong path would make everything below vacuous.
|
||||||
|
assert _bundled_plugin_names(), f"no bundled plugins found under {BUNDLED_PLUGINS_DIR}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_default_plugin_key_has_a_real_bundled_plugin():
|
||||||
|
"""A plugin.* default naming a non-existent plugin is always a bug.
|
||||||
|
|
||||||
|
Bundled plugins ship in the image and version atomically with core, so
|
||||||
|
unlike external plugins they cannot be transiently missing — there is no
|
||||||
|
benign reason for this to fail.
|
||||||
|
"""
|
||||||
|
missing = _default_plugin_names() - _bundled_plugin_names()
|
||||||
|
assert not missing, (
|
||||||
|
f"DEFAULTS declares plugin(s) with no bundled directory: {sorted(missing)}. "
|
||||||
|
f"If the plugin was removed, delete its plugin.<name> key from DEFAULTS "
|
||||||
|
f"and add a migration dropping the stored row."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_plugin_default_is_gone():
|
||||||
|
# Explicit regression: this exact key is what the operator hit.
|
||||||
|
assert "plugin.http" not in DEFAULTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_plugins_all_exist():
|
||||||
|
"""CAPABILITY_PLUGINS classifies discovered plugins; stale names are dead weight."""
|
||||||
|
missing = CAPABILITY_PLUGINS - _bundled_plugin_names()
|
||||||
|
assert not missing, f"CAPABILITY_PLUGINS names non-existent plugin(s): {sorted(missing)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── orphan detection ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_orphans_are_stored_names_with_no_installed_plugin():
|
||||||
|
rows = find_orphaned_plugins(["docker", "traefik", "ancient"], ["docker", "traefik"])
|
||||||
|
assert [r["name"] for r in rows] == ["ancient"]
|
||||||
|
assert rows[0]["removable"] is True
|
||||||
|
assert rows[0]["reason"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_orphans_when_everything_is_installed():
|
||||||
|
assert find_orphaned_plugins(["docker", "snmp"], ["docker", "snmp"]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_installed_plugin_without_stored_settings_is_not_an_orphan():
|
||||||
|
# Never configured is not the same as left behind.
|
||||||
|
assert find_orphaned_plugins([], ["docker"]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_orphans_are_sorted_and_skip_empty_names():
|
||||||
|
rows = find_orphaned_plugins(["zeta", "", "alpha"], [])
|
||||||
|
assert [r["name"] for r in rows] == ["alpha", "zeta"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepts_arbitrary_iterables():
|
||||||
|
# Callers pass a generator of discovered dirs, not a list.
|
||||||
|
rows = find_orphaned_plugins((n for n in ["gone"]), (n for n in ["here"]))
|
||||||
|
assert [r["name"] for r in rows] == ["gone"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── load failures folded in (issue #2638) ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_failure_reason_is_attached_to_its_row():
|
||||||
|
rows = find_orphaned_plugins(
|
||||||
|
["ancient"], [], {"ancient": "Plugin directory not found in: ['/app/plugins']"})
|
||||||
|
assert rows[0]["reason"] == "Plugin directory not found in: ['/app/plugins']"
|
||||||
|
|
||||||
|
|
||||||
|
def test_failure_with_no_stored_row_still_gets_a_row():
|
||||||
|
"""The banner counts it, so the page must show it — that was the dead end.
|
||||||
|
|
||||||
|
A plugin enabled by a DEFAULTS entry has no stored row, so a stored-only
|
||||||
|
view would leave the banner pointing at a page with nothing on it.
|
||||||
|
"""
|
||||||
|
rows = find_orphaned_plugins([], [], {"http": "Plugin directory not found"})
|
||||||
|
assert [r["name"] for r in rows] == ["http"]
|
||||||
|
assert rows[0]["reason"] == "Plugin directory not found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_failure_with_no_stored_row_is_not_removable():
|
||||||
|
# Offering Remove would delete nothing while the default reasserts it.
|
||||||
|
rows = find_orphaned_plugins([], [], {"http": "Plugin directory not found"})
|
||||||
|
assert rows[0]["removable"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_failure_of_an_installed_plugin_is_not_listed_here():
|
||||||
|
# A discovered plugin that failed already shows the reason on its own card.
|
||||||
|
assert find_orphaned_plugins(
|
||||||
|
["docker"], ["docker"], {"docker": "boom"}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_stored_orphan_and_failure_are_not_duplicated():
|
||||||
|
rows = find_orphaned_plugins(["gone"], [], {"gone": "Plugin directory not found"})
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["removable"] is True and rows[0]["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_undiscovered_failure_is_represented():
|
||||||
|
# The invariant that keeps the banner count and this list in agreement.
|
||||||
|
failures = {"a": "x", "b": "y", "docker": "z"}
|
||||||
|
rows = find_orphaned_plugins([], ["docker"], failures)
|
||||||
|
listed = {r["name"] for r in rows}
|
||||||
|
assert listed == {"a", "b"}
|
||||||
@@ -9,7 +9,9 @@ and a DEFAULTS-merged-with-stored dict, mirroring get_all_settings' merge.
|
|||||||
from steward.core import settings as settings_module
|
from steward.core import settings as settings_module
|
||||||
from steward.core.settings import DEFAULTS, to_plugins_cfg
|
from steward.core.settings import DEFAULTS, to_plugins_cfg
|
||||||
|
|
||||||
DEFAULT_ON = {"docker", "host_agent", "http", "snmp"}
|
# NB: no "http" — that plugin was dissolved into the unified Monitor entity, and
|
||||||
|
# its lingering default is what test_plugin_settings_hygiene.py now guards against.
|
||||||
|
DEFAULT_ON = {"docker", "host_agent", "snmp"}
|
||||||
VENDOR_OPT_IN = {"traefik", "unifi"}
|
VENDOR_OPT_IN = {"traefik", "unifi"}
|
||||||
|
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ def test_stored_choice_overrides_default():
|
|||||||
cfg = to_plugins_cfg(merged)
|
cfg = to_plugins_cfg(merged)
|
||||||
assert cfg["docker"]["enabled"] is False
|
assert cfg["docker"]["enabled"] is False
|
||||||
# untouched defaults remain enabled
|
# untouched defaults remain enabled
|
||||||
assert cfg["http"]["enabled"] is True
|
assert cfg["snmp"]["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_defaults_use_plugin_dot_namespace():
|
def test_defaults_use_plugin_dot_namespace():
|
||||||
|
|||||||
@@ -75,6 +75,28 @@ def test_events_and_swarm_tables_exist(app):
|
|||||||
asyncio.run(_go())
|
asyncio.run(_go())
|
||||||
|
|
||||||
|
|
||||||
|
@_NEEDS_DB
|
||||||
|
def test_docker_logs_table_shape(app):
|
||||||
|
"""docker_009 created docker_logs: host-scoped, with the twin indexes the
|
||||||
|
viewer (host, container, ts) and the age-cutoff prune (ts) rely on."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
cols = {r[0] for r in (await s.execute(text(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'docker_logs'"))).all()}
|
||||||
|
assert cols, "docker_logs missing entirely"
|
||||||
|
assert {"id", "host_id", "container_name", "ts", "stream", "line"} <= cols
|
||||||
|
idx = {r[0] for r in (await s.execute(text(
|
||||||
|
"SELECT indexname FROM pg_indexes "
|
||||||
|
"WHERE tablename = 'docker_logs'"))).all()}
|
||||||
|
assert "ix_docker_logs_host_container_time" in idx
|
||||||
|
assert "ix_docker_logs_ts" in idx
|
||||||
|
|
||||||
|
asyncio.run(_go())
|
||||||
|
|
||||||
|
|
||||||
def _persist_fn(app):
|
def _persist_fn(app):
|
||||||
"""Resolve persist_host_docker via the registered capability if the docker
|
"""Resolve persist_host_docker via the registered capability if the docker
|
||||||
plugin is loaded, else import it directly (the import is safe only when the
|
plugin is loaded, else import it directly (the import is safe only when the
|
||||||
@@ -150,6 +172,97 @@ def test_persist_scopes_containers_by_host(app):
|
|||||||
assert enrich == ("healthy", 2, "web", 1000) # enrichment round-trips
|
assert enrich == ("healthy", 2, "web", 1000) # enrichment round-trips
|
||||||
|
|
||||||
|
|
||||||
|
@_NEEDS_DB
|
||||||
|
def test_persist_logs_stores_lines(app):
|
||||||
|
"""Pushed container logs land in docker_logs, host-scoped, one row per line,
|
||||||
|
ordered by their own ts; the agent's _steward truncation marker is dropped."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
|
||||||
|
persist = _persist_fn(app)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
log_batches = [(now, [
|
||||||
|
{"container": "web", "stream": "stdout",
|
||||||
|
"ts": "2026-01-01T00:00:01+00:00", "line": "started"},
|
||||||
|
{"container": "web", "stream": "stderr",
|
||||||
|
"ts": "2026-01-01T00:00:02+00:00", "line": "warn: x"},
|
||||||
|
{"container": "_steward", "stream": "stderr", "ts": None, "line": "truncated"},
|
||||||
|
])]
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
async with s.begin():
|
||||||
|
await s.execute(text("DELETE FROM docker_logs"))
|
||||||
|
h = Host(id=str(uuid.uuid4()), name="loghost", address="10.0.0.9")
|
||||||
|
s.add(h)
|
||||||
|
await s.flush()
|
||||||
|
hid = h.id
|
||||||
|
# snapshots empty; logs passed as the 6th positional arg.
|
||||||
|
await persist(s, h, [], None, None, log_batches)
|
||||||
|
rows = (await s.execute(text(
|
||||||
|
"SELECT container_name, stream, line FROM docker_logs "
|
||||||
|
"WHERE host_id = :h ORDER BY ts"), {"h": hid})).all()
|
||||||
|
return [tuple(r) for r in rows]
|
||||||
|
|
||||||
|
assert asyncio.run(_go()) == [
|
||||||
|
("web", "stdout", "started"),
|
||||||
|
("web", "stderr", "warn: x"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@_NEEDS_DB
|
||||||
|
def test_persist_logs_respects_toggle_and_exclude(app):
|
||||||
|
"""Server-side controls (Settings): an excluded container's lines are dropped
|
||||||
|
while others persist; the global toggle off stores nothing new."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from sqlalchemy import text
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
from steward.core.settings import set_setting
|
||||||
|
|
||||||
|
persist = _persist_fn(app)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
batch = [(now, [
|
||||||
|
{"container": "web", "stream": "stdout", "ts": None, "line": "keep-me"},
|
||||||
|
{"container": "noisy", "stream": "stdout", "ts": None, "line": "drop-me"},
|
||||||
|
])]
|
||||||
|
|
||||||
|
hid = str(uuid.uuid4())
|
||||||
|
# Stand-in host: the logs-only persist path reads only host.id, so a plain
|
||||||
|
# object sidesteps ORM attribute-expiry across the commit boundaries below.
|
||||||
|
host = SimpleNamespace(id=hid, name="loghost3")
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
# Back-to-back begin blocks with NO read in between — a SELECT between
|
||||||
|
# them would autobegin a txn and make the next begin() collide.
|
||||||
|
async with s.begin():
|
||||||
|
await s.execute(text("DELETE FROM docker_logs"))
|
||||||
|
await set_setting(s, "docker.logs.enabled", True)
|
||||||
|
await set_setting(s, "docker.logs.exclude", ["noisy"])
|
||||||
|
s.add(Host(id=hid, name="loghost3", address="10.7.7.10"))
|
||||||
|
await s.flush() # host row before its log rows (FK order)
|
||||||
|
await persist(s, host, [], None, None, batch) # web kept, noisy excluded
|
||||||
|
async with s.begin(): # global kill-switch off → nothing stored
|
||||||
|
await set_setting(s, "docker.logs.enabled", False)
|
||||||
|
await persist(s, host, [], None, None, batch)
|
||||||
|
async with s.begin(): # restore defaults for other tests
|
||||||
|
await set_setting(s, "docker.logs.enabled", True)
|
||||||
|
await set_setting(s, "docker.logs.exclude", [])
|
||||||
|
# Reads only after the last begin block (they autobegin, but nothing
|
||||||
|
# opens a begin() after them). Toggle-off added nothing, so the state
|
||||||
|
# here still reflects the exclude phase.
|
||||||
|
kept = {r[0] for r in (await s.execute(text(
|
||||||
|
"SELECT DISTINCT container_name FROM docker_logs WHERE host_id=:h"),
|
||||||
|
{"h": hid})).all()}
|
||||||
|
after_off = (await s.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM docker_logs WHERE host_id=:h"), {"h": hid})).scalar()
|
||||||
|
return kept, after_off
|
||||||
|
|
||||||
|
kept, after_off = asyncio.run(_go())
|
||||||
|
assert kept == {"web"} # excluded 'noisy' dropped on ingest
|
||||||
|
assert after_off == 1 # toggle off added nothing → still just the one 'web' line
|
||||||
|
|
||||||
|
|
||||||
@_NEEDS_DB
|
@_NEEDS_DB
|
||||||
def test_large_memory_values_persist_as_bigint(app):
|
def test_large_memory_values_persist_as_bigint(app):
|
||||||
"""A container using >2^31 bytes of RAM must persist. Regression: mem_usage_bytes
|
"""A container using >2^31 bytes of RAM must persist. Regression: mem_usage_bytes
|
||||||
@@ -451,6 +564,67 @@ def test_retention_rollup_and_prune(app):
|
|||||||
assert counts["events_pruned"] == 1 and counts["rollup_pruned"] == 1
|
assert counts["events_pruned"] == 1 and counts["rollup_pruned"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@_NEEDS_DB
|
||||||
|
def test_retention_logs_ring(app):
|
||||||
|
"""docker_logs ring (m79): lines past the age window are pruned; within it,
|
||||||
|
each container keeps only the newest ~cap bytes; containers are independent."""
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import text
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
|
||||||
|
run_retention = _retention_fn(app)
|
||||||
|
now = datetime(2026, 6, 19, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
line40 = "x" * 40 # 40 chars/line; cap=100 keeps 3 lines (excl-prefix 0/40/80)
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
async with s.begin():
|
||||||
|
await s.execute(text("DELETE FROM docker_logs"))
|
||||||
|
h = Host(id=str(uuid.uuid4()), name="loghost2", address="10.7.7.8")
|
||||||
|
s.add(h)
|
||||||
|
await s.flush()
|
||||||
|
hid = h.id
|
||||||
|
|
||||||
|
def ins(cn, ts, line):
|
||||||
|
return s.execute(text(
|
||||||
|
"INSERT INTO docker_logs "
|
||||||
|
"(id, host_id, container_name, ts, stream, line) "
|
||||||
|
"VALUES (:id,:h,:cn,:ts,'stdout',:line)"),
|
||||||
|
{"id": str(uuid.uuid4()), "h": hid, "cn": cn,
|
||||||
|
"ts": ts, "line": line})
|
||||||
|
|
||||||
|
# Past the 3-day age window → age-pruned.
|
||||||
|
await ins("old", now - timedelta(days=10), "z")
|
||||||
|
# 5 recent 'web' lines (40 bytes each = 200 > cap 100) → keep 3.
|
||||||
|
for i in range(1, 6):
|
||||||
|
await ins("web", now - timedelta(minutes=6 - i), line40)
|
||||||
|
# 2 recent 'db' lines (80 bytes ≤ cap) → both survive (isolation).
|
||||||
|
await ins("db", now - timedelta(minutes=2), line40)
|
||||||
|
await ins("db", now - timedelta(minutes=1), line40)
|
||||||
|
|
||||||
|
async with s.begin():
|
||||||
|
counts = await run_retention(
|
||||||
|
s, events_days=30, metrics_raw_days=7, metrics_rollup_days=90,
|
||||||
|
logs_retention_days=3, logs_max_bytes_per_container=100, now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
def n(cn):
|
||||||
|
return s.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM docker_logs WHERE host_id=:h AND container_name=:cn"),
|
||||||
|
{"h": hid, "cn": cn})
|
||||||
|
web = (await n("web")).scalar()
|
||||||
|
db = (await n("db")).scalar()
|
||||||
|
old = (await n("old")).scalar()
|
||||||
|
return counts, web, db, old
|
||||||
|
|
||||||
|
counts, web, db, old = asyncio.run(_go())
|
||||||
|
assert old == 0 # age window
|
||||||
|
assert web == 3 # newest ~100 bytes kept, oldest 2 dropped
|
||||||
|
assert db == 2 # under cap → untouched (per-container)
|
||||||
|
assert counts["logs_age_pruned"] == 1
|
||||||
|
assert counts["logs_size_pruned"] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_widget_dedup_collapses_cross_manager_duplicates():
|
def test_widget_dedup_collapses_cross_manager_duplicates():
|
||||||
"""The same swarm task is reported by every manager (identical container_id);
|
"""The same swarm task is reported by every manager (identical container_id);
|
||||||
the dashboard widget must count it once. Older agents send no container_id,
|
the dashboard widget must count it once. Older agents send no container_id,
|
||||||
|
|||||||
@@ -65,3 +65,35 @@ def test_no_event_when_unchanged():
|
|||||||
old = {"web": {"status": "running", "health": "healthy", "oom_killed": False, "exit_code": None}}
|
old = {"web": {"status": "running", "health": "healthy", "oom_killed": False, "exit_code": None}}
|
||||||
events = _derive_events(old, [_c("web", "running", health="healthy")])
|
events = _derive_events(old, [_c("web", "running", health="healthy")])
|
||||||
assert events == []
|
assert events == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── container-log row shaping (m79, pure) ─────────────────────────────────────
|
||||||
|
|
||||||
|
def test_log_rows_shapes_and_filters():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from plugins.docker.ingest import _log_rows
|
||||||
|
|
||||||
|
rec_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||||
|
batches = [(rec_at, [
|
||||||
|
{"container": "web", "stream": "stdout",
|
||||||
|
"ts": "2026-01-01T00:00:01+00:00", "line": "hello"},
|
||||||
|
{"container": "web", "stream": "stderr", "ts": None, "line": "no-ts"},
|
||||||
|
{"container": "_steward", "stream": "stderr",
|
||||||
|
"ts": None, "line": "truncated"}, # advisory marker → dropped
|
||||||
|
{"container": "web", "stream": "weird", "ts": None, "line": "bad-stream"},
|
||||||
|
{"container": "", "line": "x"}, # no name → dropped
|
||||||
|
{"container": "web", "ts": None, "line": None}, # no line → dropped
|
||||||
|
"not-a-dict", # malformed → dropped
|
||||||
|
])]
|
||||||
|
rows = list(_log_rows(batches, "host-1"))
|
||||||
|
assert [r["line"] for r in rows] == ["hello", "no-ts", "bad-stream"]
|
||||||
|
assert rows[0]["host_id"] == "host-1" and rows[0]["container_name"] == "web"
|
||||||
|
assert rows[1]["ts"] == rec_at # None ts → recorded_at fallback
|
||||||
|
assert rows[2]["stream"] == "stdout" # unknown stream normalised
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_rows_skips_non_list_records():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from plugins.docker.ingest import _log_rows
|
||||||
|
rec_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||||
|
assert list(_log_rows([(rec_at, None)], "h")) == []
|
||||||
|
|||||||
@@ -30,6 +30,28 @@ def test_routes_module_exposes_new_views():
|
|||||||
assert r.docker_bp.name == "docker"
|
assert r.docker_bp.name == "docker"
|
||||||
|
|
||||||
|
|
||||||
|
def test_disk_prune_view_defined():
|
||||||
|
# M78 admin-gated prune action.
|
||||||
|
assert callable(r.disk_prune)
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_log_views_defined():
|
||||||
|
# M79 per-container log viewer + its HTMX-polled line fragment.
|
||||||
|
assert callable(r.container_logs)
|
||||||
|
assert callable(r.container_logs_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_extra_vars_mapping():
|
||||||
|
"""The prune buttons drive one playbook via prune_target; only 'images'
|
||||||
|
widens to ALL unused images (docker image prune -a), system stays -f."""
|
||||||
|
assert r._prune_extra_vars("containers") == {"prune_target": "containers"}
|
||||||
|
assert r._prune_extra_vars("images") == {
|
||||||
|
"prune_target": "images", "prune_all_images": True,
|
||||||
|
}
|
||||||
|
# System prune stays conservative — no prune_all_images key.
|
||||||
|
assert r._prune_extra_vars("system") == {"prune_target": "system"}
|
||||||
|
|
||||||
|
|
||||||
def test_human_bytes_formats_binary_units():
|
def test_human_bytes_formats_binary_units():
|
||||||
assert r._human_bytes(None) == "—"
|
assert r._human_bytes(None) == "—"
|
||||||
assert r._human_bytes(0) == "0 B"
|
assert r._human_bytes(0) == "0 B"
|
||||||
|
|||||||
@@ -401,3 +401,190 @@ def test_read_config_honours_explicit_docker_socket(tmp_path):
|
|||||||
"docker_socket = /run/user/1000/docker.sock\n")
|
"docker_socket = /run/user/1000/docker.sock\n")
|
||||||
cfg = a.read_config(str(cfg_file))
|
cfg = a.read_config(str(cfg_file))
|
||||||
assert cfg["docker_socket"] == "/run/user/1000/docker.sock"
|
assert cfg["docker_socket"] == "/run/user/1000/docker.sock"
|
||||||
|
|
||||||
|
|
||||||
|
# ── container logs (m79) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _frame(stream: int, payload: bytes) -> bytes:
|
||||||
|
"""Build one Docker multiplexed-log frame (8-byte header + payload)."""
|
||||||
|
return bytes([stream, 0, 0, 0]) + len(payload).to_bytes(4, "big") + payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_demux_docker_logs_framed():
|
||||||
|
raw = _frame(1, b"out line\n") + _frame(2, b"err line\n")
|
||||||
|
assert a._demux_docker_logs(raw) == [
|
||||||
|
("stdout", b"out line\n"), ("stderr", b"err line\n")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_demux_docker_logs_tty_fallback():
|
||||||
|
# A TTY container's stream isn't framed — the leading byte ('2') is not a
|
||||||
|
# valid stream id, so the whole blob is treated as one stdout payload.
|
||||||
|
raw = b"2023-11-14T12:00:00.000000000Z hi\n"
|
||||||
|
assert a._demux_docker_logs(raw) == [("stdout", raw)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_demux_docker_logs_empty():
|
||||||
|
assert a._demux_docker_logs(b"") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_log_ts_handles_nanoseconds_and_z():
|
||||||
|
dt = a._parse_log_ts("2023-11-14T12:00:00.123456789Z")
|
||||||
|
assert dt is not None
|
||||||
|
assert dt.year == 2023 and dt.microsecond == 123456 # ns trimmed to µs
|
||||||
|
assert dt.utcoffset().total_seconds() == 0
|
||||||
|
assert a._parse_log_ts("not-a-time") is None
|
||||||
|
assert a._parse_log_ts("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_container_logs_splits_streams_and_ts():
|
||||||
|
raw = (_frame(1, b"2023-11-14T12:00:00.000000001Z hello world\n")
|
||||||
|
+ _frame(2, b"2023-11-14T12:00:01.000000000Z oops\n"))
|
||||||
|
parsed = a._parse_container_logs(raw)
|
||||||
|
assert len(parsed) == 2
|
||||||
|
dt0, s0, l0 = parsed[0]
|
||||||
|
dt1, s1, l1 = parsed[1]
|
||||||
|
assert (s0, l0) == ("stdout", "hello world")
|
||||||
|
assert (s1, l1) == ("stderr", "oops")
|
||||||
|
assert dt1 > dt0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_container_logs_keeps_unparseable_line():
|
||||||
|
# No timestamp prefix (e.g. a partial write) → dt is None, full line kept.
|
||||||
|
parsed = a._parse_container_logs(_frame(1, b"no-timestamp-here\n"))
|
||||||
|
assert parsed == [(None, "stdout", "no-timestamp-here")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_docker_logs_since_cursor_and_dedup(monkeypatch):
|
||||||
|
containers = [{"name": "web", "status": "running"}]
|
||||||
|
calls = []
|
||||||
|
t1 = "2023-11-14T12:00:00.000000000Z"
|
||||||
|
t2 = "2023-11-14T12:00:01.000000000Z"
|
||||||
|
t3 = "2023-11-14T12:00:02.000000000Z"
|
||||||
|
|
||||||
|
def fake_raw(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
|
||||||
|
calls.append(path)
|
||||||
|
if "tail=" in path: # first interval seeds from a tail
|
||||||
|
return _frame(1, f"{t1} a\n".encode()) + _frame(1, f"{t2} b\n".encode())
|
||||||
|
# second interval: daemon re-returns the boundary line (t2) + a new one
|
||||||
|
return _frame(1, f"{t2} b\n".encode()) + _frame(1, f"{t3} c\n".encode())
|
||||||
|
|
||||||
|
monkeypatch.setattr(a, "_docker_request_raw", fake_raw)
|
||||||
|
state: dict = {}
|
||||||
|
first = a.collect_docker_logs("/sock", containers, state)
|
||||||
|
assert [r["line"] for r in first] == ["a", "b"]
|
||||||
|
assert "tail=" in calls[0]
|
||||||
|
|
||||||
|
second = a.collect_docker_logs("/sock", containers, state)
|
||||||
|
# boundary line b (t2) deduped against the cursor; only c (t3) is new
|
||||||
|
assert [r["line"] for r in second] == ["c"]
|
||||||
|
assert "since=" in calls[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_docker_logs_excludes_and_skips_non_running(monkeypatch):
|
||||||
|
containers = [
|
||||||
|
{"name": "web", "status": "running"},
|
||||||
|
{"name": "noisy", "status": "running"},
|
||||||
|
{"name": "db", "status": "exited"},
|
||||||
|
]
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake_raw(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
|
||||||
|
seen.append(path)
|
||||||
|
return _frame(1, b"2023-11-14T12:00:00.000000000Z x\n")
|
||||||
|
|
||||||
|
monkeypatch.setattr(a, "_docker_request_raw", fake_raw)
|
||||||
|
out = a.collect_docker_logs("/sock", containers, {}, exclude=["noisy"])
|
||||||
|
# only web is fetched: noisy is excluded, db isn't running
|
||||||
|
assert seen and all("/containers/web/" in p for p in seen)
|
||||||
|
assert {r["container"] for r in out} == {"web"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_docker_logs_byte_cap_truncates(monkeypatch):
|
||||||
|
containers = [{"name": "web", "status": "running"}]
|
||||||
|
blob = b"".join(
|
||||||
|
_frame(1, f"2023-11-14T12:00:{i:02d}.000000000Z {'x' * 20}\n".encode())
|
||||||
|
for i in range(10))
|
||||||
|
monkeypatch.setattr(a, "_docker_request_raw",
|
||||||
|
lambda s, p, timeout=a.DOCKER_API_TIMEOUT: blob)
|
||||||
|
out = a.collect_docker_logs("/sock", containers, {}, max_bytes=50)
|
||||||
|
# Once the cap is crossed a single marker line is appended and we stop.
|
||||||
|
assert out[-1]["container"] == "_steward"
|
||||||
|
assert "truncated" in out[-1]["line"]
|
||||||
|
real = [r for r in out if r["container"] == "web"]
|
||||||
|
assert 0 < len(real) < 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_docker_logs_forgets_gone_container_cursors(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
a, "_docker_request_raw",
|
||||||
|
lambda s, p, timeout=a.DOCKER_API_TIMEOUT:
|
||||||
|
_frame(1, b"2023-11-14T12:00:00.000000000Z x\n"))
|
||||||
|
state: dict = {}
|
||||||
|
a.collect_docker_logs("/sock", [{"name": "web", "status": "running"}], state)
|
||||||
|
assert "web" in state["docker_log_cursors"]
|
||||||
|
# web is gone next interval → its cursor is pruned so state can't grow forever
|
||||||
|
a.collect_docker_logs("/sock", [{"name": "db", "status": "running"}], state)
|
||||||
|
assert "web" not in state["docker_log_cursors"]
|
||||||
|
assert "db" in state["docker_log_cursors"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sample_includes_logs_when_present(monkeypatch):
|
||||||
|
monkeypatch.setattr(a, "collect_docker",
|
||||||
|
lambda _s: [{"name": "web", "status": "running"}])
|
||||||
|
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||||
|
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
a, "collect_docker_logs",
|
||||||
|
lambda *args, **kw: [{"container": "web", "stream": "stdout",
|
||||||
|
"ts": "t", "line": "hello"}])
|
||||||
|
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||||
|
assert sample["docker_logs"][0]["line"] == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sample_omits_logs_when_disabled(monkeypatch):
|
||||||
|
monkeypatch.setattr(a, "collect_docker",
|
||||||
|
lambda _s: [{"name": "web", "status": "running"}])
|
||||||
|
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||||
|
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: None)
|
||||||
|
called = {"logs": False}
|
||||||
|
|
||||||
|
def _logs(*args, **kw):
|
||||||
|
called["logs"] = True
|
||||||
|
return [{"container": "web", "line": "x"}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(a, "collect_docker_logs", _logs)
|
||||||
|
sample = a.build_sample(["/"], {}, "/var/run/docker.sock",
|
||||||
|
docker_logs_enabled=False)
|
||||||
|
assert "docker_logs" not in sample
|
||||||
|
assert called["logs"] is False # collection skipped entirely, not just dropped
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_logs_strips_only_logs():
|
||||||
|
sample = {"ts": "t", "cpu_pct": 5.0,
|
||||||
|
"docker": [{"name": "web"}], "docker_logs": [{"line": "x"}]}
|
||||||
|
out = a._drop_logs(sample)
|
||||||
|
assert "docker_logs" not in out
|
||||||
|
assert out["docker"] == [{"name": "web"}] and out["cpu_pct"] == 5.0 # metrics kept
|
||||||
|
assert a._drop_logs(out) is out # idempotent
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_config_docker_logs_default_on(tmp_path):
|
||||||
|
p = tmp_path / "agent.conf"
|
||||||
|
p.write_text("url = x\ntoken = y\n")
|
||||||
|
cfg = a.read_config(str(p))
|
||||||
|
assert cfg["docker_logs_enabled"] is True
|
||||||
|
assert cfg["docker_log_exclude"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_config_disables_docker_logs(tmp_path):
|
||||||
|
p = tmp_path / "agent.conf"
|
||||||
|
p.write_text("url = x\ntoken = y\ndocker_logs_enabled = false\n")
|
||||||
|
cfg = a.read_config(str(p))
|
||||||
|
assert cfg["docker_logs_enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_config_parses_docker_log_exclude(tmp_path):
|
||||||
|
p = tmp_path / "agent.conf"
|
||||||
|
p.write_text("url = x\ntoken = y\ndocker_log_exclude = watchtower, foo\n")
|
||||||
|
cfg = a.read_config(str(p))
|
||||||
|
assert cfg["docker_log_exclude"] == ["watchtower", "foo"]
|
||||||
|
|||||||
@@ -65,3 +65,50 @@ def test_secret_key_unpersistable_raises_instead_of_ephemeral(tmp_path, monkeypa
|
|||||||
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
|
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
|
||||||
with pytest.raises(RuntimeError, match="could not persist"):
|
with pytest.raises(RuntimeError, match="could not persist"):
|
||||||
_resolve_secret_key({})
|
_resolve_secret_key({})
|
||||||
|
|
||||||
|
|
||||||
|
# ── database connect timeout (bootstrap-only: it governs reaching the DB) ────
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_connect_timeout_defaults_when_unset(tmp_path, monkeypatch):
|
||||||
|
from steward.database import DB_CONNECT_TIMEOUT_SECONDS
|
||||||
|
cfg_file = tmp_path / "config.yaml"
|
||||||
|
cfg_file.write_text("database:\n url: x\nsecret_key: s\n")
|
||||||
|
monkeypatch.delenv("STEWARD_DB_CONNECT_TIMEOUT", raising=False)
|
||||||
|
cfg = load_bootstrap(cfg_file)
|
||||||
|
assert cfg["db_connect_timeout"] == DB_CONNECT_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_connect_timeout_from_yaml(tmp_path, monkeypatch):
|
||||||
|
cfg_file = tmp_path / "config.yaml"
|
||||||
|
cfg_file.write_text(
|
||||||
|
"database:\n url: x\n connect_timeout: 120\nsecret_key: s\n")
|
||||||
|
monkeypatch.delenv("STEWARD_DB_CONNECT_TIMEOUT", raising=False)
|
||||||
|
assert load_bootstrap(cfg_file)["db_connect_timeout"] == 120.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_connect_timeout_env_overrides_yaml(tmp_path, monkeypatch):
|
||||||
|
cfg_file = tmp_path / "config.yaml"
|
||||||
|
cfg_file.write_text(
|
||||||
|
"database:\n url: x\n connect_timeout: 120\nsecret_key: s\n")
|
||||||
|
monkeypatch.setenv("STEWARD_DB_CONNECT_TIMEOUT", "5")
|
||||||
|
assert load_bootstrap(cfg_file)["db_connect_timeout"] == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_connect_timeout_garbage_falls_back_to_default(tmp_path, monkeypatch):
|
||||||
|
from steward.database import DB_CONNECT_TIMEOUT_SECONDS
|
||||||
|
cfg_file = tmp_path / "config.yaml"
|
||||||
|
cfg_file.write_text("database:\n url: x\nsecret_key: s\n")
|
||||||
|
monkeypatch.setenv("STEWARD_DB_CONNECT_TIMEOUT", "not-a-number")
|
||||||
|
cfg = load_bootstrap(cfg_file)
|
||||||
|
assert cfg["db_connect_timeout"] == DB_CONNECT_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_connect_timeout_non_positive_falls_back_to_default(tmp_path, monkeypatch):
|
||||||
|
# 0 would mean "never wait", reinstating the crash this setting exists to fix.
|
||||||
|
from steward.database import DB_CONNECT_TIMEOUT_SECONDS
|
||||||
|
cfg_file = tmp_path / "config.yaml"
|
||||||
|
cfg_file.write_text("database:\n url: x\nsecret_key: s\n")
|
||||||
|
monkeypatch.setenv("STEWARD_DB_CONNECT_TIMEOUT", "0")
|
||||||
|
cfg = load_bootstrap(cfg_file)
|
||||||
|
assert cfg["db_connect_timeout"] == DB_CONNECT_TIMEOUT_SECONDS
|
||||||
|
|||||||
Reference in New Issue
Block a user